dsh-armory 2.3.0 → 2.3.2

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
@@ -33,7 +33,7 @@ var __injectCss = function(text) {
33
33
  return () => tag.remove();
34
34
  };
35
35
  const React2 = React;
36
- const { useState, useEffect, useCallback, useMemo, useRef } = React2;
36
+ const { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } = React2;
37
37
 
38
38
  // ── OPT-11:i18n 骨架(gettext 模型,键 = 中文原文) ────────────────────────
39
39
  // zh-CN 是源语言:字典为空时 t() 恒等返回原文,缺键永远不会让界面出现 key 名。
@@ -137,7 +137,6 @@ const EN_US_MESSAGES = {
137
137
  "MCP 工具按 server 生效:选择后点名 mcp__服务__* 工具": "MCP tools apply per server: picking this names mcp__server__* tools",
138
138
  "MCP 市场服务未连接(面板仍可用)": "MCP market service not connected (panel still usable)",
139
139
  "MCP 市场面板": "MCP Market panel",
140
- "MCP 目录暂时是空的": "The MCP catalog is empty for now",
141
140
  "Memory · 知识图谱记忆": "Memory · knowledge-graph memory",
142
141
  "Schema 税": "Schema tax",
143
142
  "Sequential Thinking · 分步推理": "Sequential Thinking · step-by-step reasoning",
@@ -239,7 +238,6 @@ const EN_US_MESSAGES = {
239
238
  "保存": "Save",
240
239
  "保存中…": "Saving…",
241
240
  "保存失败": "Save failed",
242
- "免密钥": "Keyless",
243
241
  "入口": "Entries",
244
242
  "入口显示:": "Entry display:",
245
243
  "全局": "Global",
@@ -250,14 +248,12 @@ const EN_US_MESSAGES = {
250
248
  "全局面板 + 悬浮按钮(推荐)": "Global panel + floating button (recommended)",
251
249
  "全部": "All",
252
250
  "全部传输": "All transports",
253
- "全部分类": "All categories",
254
251
  "全部状态": "All states",
255
252
  "共 {0} 个": "Total {0}",
256
253
  "关闭": "Close",
257
254
  "关闭更新提示": "Dismiss update notice",
258
255
  "其他 bundle / Agent(只读)": "Other bundles / agents (read-only)",
259
256
  "其余分类仍可正常使用;完整堆栈已打印到控制台。": "Other categories keep working; the full stack trace is printed to the console.",
260
- "内置 MCP": "Built-in MCPs",
261
257
  "再看看这些(目录前 {0} 位未召唤)": "Also consider these (first {0} in catalog, not yet summoned)",
262
258
  "出现过的工具(含已卸载 MCP 的历史调用)": "Tools ever seen (including historical calls of uninstalled MCPs)",
263
259
  "列表(最多显示 6 行,滚动查看更多)": "List (shows up to 6 rows; scroll for more)",
@@ -296,7 +292,6 @@ const EN_US_MESSAGES = {
296
292
  "只看窗口内零调用的工具(默认关闭:默认显示全部工具与真实调用次数)": "Show only zero-call tools in the window (off by default: all tools with real call counts are shown)",
297
293
  "只看零调用": "Zero-call only",
298
294
  "只看零调用({0})": "Zero-call only ({0})",
299
- "只要免密钥": "Keyless only",
300
295
  "召唤": "Summon",
301
296
  "召唤 = 往 profile 补丁写入一条独立 Agent 预设(人设全程接管该预设的会话,标准工具原样保留);": "Summon = write an independent agent preset into the profile patch (the persona takes over that preset's sessions; standard tools are kept); ",
302
297
  "召唤专家团": "Summon expert team",
@@ -346,22 +341,10 @@ const EN_US_MESSAGES = {
346
341
  "正在安装插件 {0}…": "Installing plugin {0}…",
347
342
  "正在安装 {0} 个插件…": "Installing {0} plugin(s)…",
348
343
  "宿主未提供插件管理器(pluginManager),无法安装插件": "The host does not expose a plugin manager (pluginManager); cannot install plugins",
349
- "该插件未提供可推导的安装来源(repository),暂不能安装": "This plugin declares no derivable install source (repository); display only for now",
350
344
  "插件 {0} 安装成功,需重启 DSH 宿主后生效。": "Plugin {0} installed. Restart the DSH host for it to take effect.",
351
- "正在读取插件目录…": "Loading the plugin catalog…",
352
- "插件目录暂时是空的": "The plugin catalog is empty for now",
353
- "没有匹配的插件": "No plugins match",
354
- "插件详情": "Plugin details",
355
- "依赖 {0}": "{0} deps",
356
- "依赖": "Dependencies",
357
- "本插件声明了 {0} 个 LSP 服务,安装后由宿主按声明启动(此处仅展示)。": "This plugin declares {0} LSP services; the host starts them per declaration after install (display only here).",
358
- "来源:": "Source: ",
359
- "主页:": "Homepage: ",
360
- "安装 = 通过宿主 pluginManager 拉取并落盘;安装成功后需重启 DSH 宿主才会生效。": "Install fetches and lands the plugin through the host's pluginManager; restart the DSH host afterwards for it to take effect.",
361
345
  "知道了": "Got it",
362
346
  "调用失败": "Call failed",
363
347
  "({0})": " ({0})",
364
- "目录来自 codebuddy 官方市场 ZIP 分发,可能还没同步下来;重读一次通常就好。": "The catalog comes from the official codebuddy marketplace ZIP; it may not have synced yet. Re-reading usually fixes it.",
365
348
  "含技能": "With skills",
366
349
  "含需掩码字段:": "Contains fields needing masking: ",
367
350
  "启动失败策略": "Start-failure policy",
@@ -371,6 +354,7 @@ const EN_US_MESSAGES = {
371
354
  "命令面板": "Command palette",
372
355
  "团队型走 DSH 桥接:主理人人设接管会话,": "Team-type experts bridge through DSH: the lead's persona takes over the session, ",
373
356
  "团队编制({0} 人 · 召唤后成员人设落盘,由主理人调度)": "Team roster ({0} members · personas land on disk after summoning; dispatched by the lead)",
357
+ "在 GitHub 打开 dsh-armory 仓库,点右上角的 Star": "Open the dsh-armory repo on GitHub and click Star in its top-right corner",
374
358
  "在 Skill 页查看 {0}": "View {0} on the Skill page",
375
359
  "在 profile 里找不到这个插件,未升级。": "Plugin not found in the profile; not upgraded.",
376
360
  "在不离开会话的情况下查看 MCP": "Inspect MCPs without leaving the session",
@@ -388,13 +372,13 @@ const EN_US_MESSAGES = {
388
372
  "安装": "Install",
389
373
  "安装 = 逐文件下载并 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.",
390
374
  "安装中…": "Installing…",
375
+ "该技能正在安装中,请等当前任务结束": "This skill is already installing; wait for the current task to finish.",
391
376
  "安装到全局": "Install globally",
392
377
  "安装失败": "Install failed",
393
378
  "安装任务已结束": "Install finished",
394
379
  "大肥鱼军械库": "Big Fish Armory",
395
380
  "宿主正在停止,未升级;请重启后再试。": "Host is shutting down; not upgraded. Restart and try again.",
396
381
  "宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),本看板不可用": "Host has no skill registry mounted (@deepseek-ai/dsh-skill); this board is unavailable",
397
- "密钥:": "Key: ",
398
382
  "导入 MCP": "Import MCPs",
399
383
  "导入 MCP(": "Import MCPs (",
400
384
  "导入中…": "Importing…",
@@ -441,11 +425,7 @@ const EN_US_MESSAGES = {
441
425
  "目录默认顺序": "Catalog order",
442
426
  "技能数由多到少": "Most skills first",
443
427
  "名称 A→Z": "Name A→Z",
444
- "插件市场有新版本可更新(本地缓存 {0})。点「重新加载」可拉取最新目录。": "A newer plugin market catalog is available (local cache {0}). Click Reload to fetch the latest catalog.",
445
- "本地还没有插件市场缓存,点「重新加载」可首次拉取目录。": "No local plugin market cache yet. Click Reload to fetch the catalog for the first time.",
446
428
  "重新加载": "Reload",
447
- "插件市场更新探测失败(不影响已安装插件)。": "Plugin market update check failed (installed plugins are unaffected).",
448
- "重新探测": "Recheck",
449
429
  "已导入 MCP": "MCPs imported",
450
430
  "已屏蔽": "Blocked",
451
431
  "已屏蔽 ": "Blocked ",
@@ -462,12 +442,11 @@ const EN_US_MESSAGES = {
462
442
  "已连接": "Connected",
463
443
  "已连接 · ": "Connected · ",
464
444
  "已连接 · 当前无会话使用(连接保留,下次直接用)": "Connected · no session using it right now (connection kept for next time)",
465
- "已配置": "Configured",
466
445
  "已重连 ": "Reconnected ",
467
446
  "已隐藏 {0} 个不兼容条目": "Hidden {0} incompatible entries",
468
447
  "市场": "Market",
469
448
  "市场数据加载失败;其余分类仍可正常使用。": "Failed to load market data; other categories keep working.",
470
- "市场:连接器、内置 MCP、SkillHub 技能与专家召唤。": "Market: connectors, built-in MCPs, SkillHub skills and expert summoning.",
449
+ "市场:连接器、SkillHub 技能与专家召唤。": "Market: connectors, SkillHub skills and expert summoning.",
471
450
  "归属存疑": "Ownership uncertain",
472
451
  "当前": "Current",
473
452
  "当前 Web profile": "Current web profile",
@@ -479,7 +458,6 @@ const EN_US_MESSAGES = {
479
458
  "当前有 {0} 个 skill,但都被筛选条挡住了。": "{0} skills exist, but all are filtered out.",
480
459
  "当前生效专家(人设接管本会话;可在输入框工具行的专家菜单随时换/摘)": "Active expert (persona runs this session; swap/detach anytime from the expert menu on the input toolbar)",
481
460
  "当前筛选下没有可安装的连接器": "No installable connectors under the current filter",
482
- "当前筛选下没有服务": "No servers under the current filter",
483
461
  "当前运行在 Node {0},请升级到 22.15 以上后重启宿主。": "Running Node {0}; upgrade to 22.15+ and restart the host.",
484
462
  "必填": "Required",
485
463
  "快速调用": "Quick invoke",
@@ -489,7 +467,6 @@ const EN_US_MESSAGES = {
489
467
  "或粘贴 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",
490
468
  "手动添加": "Add manually",
491
469
  "打开": "Open",
492
- "打开官方文档": "Open official docs",
493
470
  "打开悬浮管理面板": "Open the floating panel",
494
471
  "打开技能 · MCP · 专家看板": "Open the Skills · MCPs · Experts board",
495
472
  "打开技能、MCP、专家快速调用菜单": "Open the skills/MCPs/experts quick-invoke menu",
@@ -537,7 +514,6 @@ const EN_US_MESSAGES = {
537
514
  "搜索全局 MCP…": "Search global MCPs…",
538
515
  "搜索工具名或描述…": "Search tool name or description…",
539
516
  "搜索时自动在全库匹配(忽略分类)": "Search matches across all libraries (ignores category)",
540
- "搜索服务、用途或提供方…": "Search services, purpose or provider…",
541
517
  "搜索连接器(名称 / 说明 / ID)": "Search connectors (name / description / ID)",
542
518
  "摘掉专家": "Detach expert",
543
519
  "摘掉专家(切回默认预设)": "Detach expert (back to default preset)",
@@ -545,7 +521,6 @@ const EN_US_MESSAGES = {
545
521
  "收起原始 JSON": "Collapse raw JSON",
546
522
  "收起参数": "Collapse parameters",
547
523
  "收起工具清单": "Collapse tool list",
548
- "文档": "Docs",
549
524
  "断开后自动重连": "Auto-reconnect after disconnect",
550
525
  "新增:": "New: ",
551
526
  "新建会话时选择「WorkBuddy 专家 · ": "When creating a session, pick \"WorkBuddy Expert · ",
@@ -608,7 +583,6 @@ const EN_US_MESSAGES = {
608
583
  "正在安装 {0}…": "Installing {0}…",
609
584
  "正在扫描本机客户端配置…": "Scanning local client configs…",
610
585
  "正在挂载专家…": "Attaching expert…",
611
- "正在读取 MCP 目录…": "Reading MCP catalog…",
612
586
  "正在读取…": "Reading…",
613
587
  "正在读取专家目录…": "Reading expert catalog…",
614
588
  "正在读取已召唤专家…": "Reading summoned experts…",
@@ -645,6 +619,7 @@ const EN_US_MESSAGES = {
645
619
  "清空搜索": "Clear search",
646
620
  "清除全部筛选": "Clear all filters",
647
621
  "清除来源筛选": "Clear source filter",
622
+ "点 Star": "Star it",
648
623
  "点击切换到此分类": "Click to switch to this category",
649
624
  "版本号不合法,未升级。": "Invalid version; not upgraded.",
650
625
  "状态未知": "State unknown",
@@ -657,8 +632,6 @@ const EN_US_MESSAGES = {
657
632
  "用户可调用": "User-invocable",
658
633
  "用法示例": "Usage example",
659
634
  "目录来自 WorkBuddy 官方分发,可能还没同步下来;重读一次通常就好。": "The catalog comes from official WorkBuddy distribution and may not have synced; re-reading usually fixes it.",
660
- "目录由宿主侧维护,可能还没同步下来;重读一次通常就好。": "The catalog is maintained host-side and may not have synced; re-reading usually fixes it.",
661
- "目录里有 {0} 个服务,都被关键词、分类或「只要免密钥」筛掉了。": "The catalog has {0} servers, all filtered out by keyword, category or 'Keyless only'.",
662
635
  "目录里有 {0} 个条目,「只看可直接安装」把不具备传输类型的挡掉了。": "The catalog has {0} entries; 'Directly installable only' hides the ones without a transport type.",
663
636
  "看板分类": "Board categories",
664
637
  "看这次改了什么": "See what changed",
@@ -675,7 +648,8 @@ const EN_US_MESSAGES = {
675
648
  "第 {0} / {1} 页 · 共 {2} 个技能": "Page {0} / {1} · {2} skills",
676
649
  "第三方接口的字段可能随上游改版变化,安装前请在详情里核对传输类型与凭据要求。": "Third-party API fields may change upstream; verify transport type and credential requirements in details before installing.",
677
650
  "筛选": "Filters",
678
- "管理": "Manage",
651
+ "管理{0}…": "Manage {0}…",
652
+ "给 dsh-armory 点 Star(新标签页打开 GitHub 仓库)": "Star dsh-armory on GitHub (opens the repo in a new tab)",
679
653
  "综合评分": "Overall score",
680
654
  "编辑": "Edit",
681
655
  "编辑 MCP": "Edit MCP",
@@ -696,13 +670,11 @@ const EN_US_MESSAGES = {
696
670
  "详情": "Details",
697
671
  "请使用 {0} 的 MCP 工具(mcp__{1}__*)处理接下来的任务": "Use {0}'s MCP tools (mcp__{1}__*) for the tasks ahead",
698
672
  "请填写 MCP 名称": "Please fill in the MCP name",
699
- "读取 MCP 目录失败": "Failed to read the MCP catalog",
700
673
  "读取 skill 内容…": "Reading skill content…",
701
674
  "读取 skill 内容失败": "Failed to read skill content",
702
675
  "读取 skill 列表失败": "Failed to read the skill list",
703
676
  "读取专家目录失败": "Failed to read the expert catalog",
704
677
  "读取中…": "Reading…",
705
- "读取内置 MCP 目录": "Read the built-in MCP catalog",
706
678
  "读取已召唤专家失败": "Failed to read summoned experts",
707
679
  "读取技能目录失败": "Failed to read the skill catalog",
708
680
  "读取本机客户端配置失败": "Failed to read local client configs",
@@ -744,7 +716,6 @@ const EN_US_MESSAGES = {
744
716
  "连接器详情": "Connector details",
745
717
  "连接失败": "Connection failed",
746
718
  "连接状态": "Connection status",
747
- "连接:": "Connection: ",
748
719
  "送还": "Return",
749
720
  "送还后此人设从所有会话移除,确认送还?": "Returning removes this persona from all sessions. Confirm return?",
750
721
  "送还失败": "Return failed",
@@ -773,10 +744,7 @@ const EN_US_MESSAGES = {
773
744
  "零调用的 MCP 工具 · 点击下钻": "Zero-call MCP tools · click to drill down",
774
745
  "零调用的 MCP 工具(已筛选)": "Zero-call MCP tools (filtered)",
775
746
  "需上游提供传输类型": "Needs upstream transport type",
776
- "需填参数": "Needs arguments",
777
- "需密钥": "Needs a key",
778
747
  "需更新 · 点击更新": "Update available · click to update",
779
- "需连接信息": "Needs connection info",
780
748
  "项目 .agents/skills": "Project .agents/skills",
781
749
  "项目 .dsh/skills": "Project .dsh/skills",
782
750
  "项目级": "Project-level",
@@ -875,7 +843,6 @@ const EN_US_MESSAGES = {
875
843
  "页": "page",
876
844
  "跳转到指定页": "Jump to a specific page",
877
845
  "共 {0} 条": "{0} total",
878
- "免认证": "No auth",
879
846
  "宿主只提供 skill 的读取接口;这里可以卸载用户级/项目级目录里的 skill(永久删除,不可撤销)。随 DSH 分发、运行时注册、自定义目录的 skill 不可卸载——要改内容请直接编辑 SKILL.md。": "The host only exposes read endpoints for skills. Skills in user-level/project-level directories can be uninstalled here (permanent deletion, no undo). Skills shipped with DSH, registered at runtime, or living in custom directories cannot be uninstalled — edit SKILL.md directly to change them.",
880
847
  "将从磁盘永久删除 {0}(不可撤销),确认卸载?": "This permanently deletes {0} from disk (no undo). Uninstall?",
881
848
  "确认卸载": "Uninstall",
@@ -969,6 +936,10 @@ const EN_US_MESSAGES = {
969
936
  "正在启动授权…": "Starting authorization…",
970
937
  "无法启动授权流程": "Unable to start the authorization flow",
971
938
  "没能自动唤起浏览器,请复制下方链接手动打开。": "The browser could not be opened automatically — copy the link below and open it manually.",
939
+ // 服务器不做 OAuth 发现(如腾讯 ima)时授权页根本开不出来,这里把手填令牌那条
940
+ // 兜底路的弹层文案补齐——只报一句「启动失败」等于把用户堵死在门外。
941
+ "该服务器无法自动打开授权页。": "This server can't have an authorization page opened automatically.",
942
+ "该服务器没有提供标准 OAuth 授权发现,无法自动打开授权页。如果你已经从服务方拿到访问令牌,粘贴在下面即可安装。": "This server publishes no standard OAuth authorization-server metadata, so an authorization page can't be opened automatically. If you already have an access token from the provider, paste it below to install.",
972
943
  "授权链接": "Authorization link",
973
944
  "重新打开浏览器": "Reopen browser",
974
945
  "该服务器只接受自定义协议回调,浏览器跳转后页面会打不开。请把地址栏里的完整地址复制到下面。": "This server only accepts a custom-scheme callback, so the redirect page will not load. Copy the full address from the browser's address bar into the field below.",
@@ -1006,10 +977,120 @@ const EN_US_MESSAGES = {
1006
977
  "{0} README 预览": "{0} · README preview",
1007
978
  "正在读取 README…": "Loading README…",
1008
979
  "README 过长,已截断显示。": "README is long; showing a truncated version.",
980
+ "跳到末尾": "Jump to end",
981
+ "滑动到 README 末尾": "Scroll to the end of README",
1009
982
  "重新读取": "Reload",
1010
983
  "读取 README 失败:未知错误": "Failed to read README: unknown error",
1011
984
  "预览服务尚未加载,请完全退出 DSH 后重新打开再试": "The preview service is not loaded yet. Fully quit DSH, reopen it, then try again.",
1012
985
 
986
+ "目录维护方:{0}": "Catalog maintained by: {0}",
987
+ "目录更新于 {0}": "Catalog updated {0}",
988
+ "查看来源": "View source",
989
+ "更新于 {0}": "Updated {0}",
990
+ "专家人设": "Expert persona",
991
+ "加入时间 {0}": "Added {0}",
992
+ "人设更新于 {0}": "Persona updated {0}",
993
+ "已归档": "Archived",
994
+ "近 30 天有提交": "Committed in the last 30 days",
995
+ "最近更新 {0}": "Last updated {0}",
996
+ "超过半年未更新": "No updates for over 6 months",
997
+ "打开维护者主页": "Open maintainer profile",
998
+ "来源未标注": "Source not listed",
999
+ "读取维护动态…": "Loading maintenance activity…",
1000
+ "最近提交 {0}": "Last commit {0}",
1001
+ "{0} 个开放 issue": "{0} open issues",
1002
+ "维护动态读取失败:{0}": "Couldn't load maintenance activity: {0}",
1003
+ "维护动态读取中…": "Loading maintenance activity…",
1004
+ "这个条目没有公开仓库": "This entry has no public repository",
1005
+ "条目": "Item",
1006
+ "未命名条目": "Unnamed item",
1007
+ "条目:{0}": "Item: {0}",
1008
+ "类型:{0}": "Type: {0}",
1009
+ "来源:{0}": "Source: {0}",
1010
+ "版本:{0}": "Version: {0}",
1011
+ "发生了什么": "What happened",
1012
+ "环境": "Environment",
1013
+ "系统:{0}": "System: {0}",
1014
+ "{0} · {1} 的反馈": "{0} · feedback on {1}",
1015
+ "找到真实的人": "Reach the real people",
1016
+ "去仓库": "Repository",
1017
+ "看讨论 / 提 Issue": "Discussions / Issues",
1018
+ "来源主页": "Source page",
1019
+ "向军械库反馈": "Report to Armory",
1020
+ "已复制模板": "Template copied",
1021
+ "复制反馈模板": "Copy feedback template",
1022
+ "反馈会打开 GitHub 新标签页;内容先由你过目,确认后才提交。": "Feedback opens a new GitHub tab; you review the text before submitting it.",
1023
+ "专家中心": "Expert Center",
1024
+ "连接器市场": "Connector market",
1025
+ "最近动静": "Recent activity",
1026
+ "暂无新变化 · 上次对比 {0} · 只在本机对比快照": "No new changes · last compared {0} · local snapshot comparison only",
1027
+ "已开始记录市场动态;下次打开时这里会列出真实变化": "Started tracking market activity; real changes will appear here next time",
1028
+ "自上次打开市场以来,目录里真实发生的变化(本机快照对比)": "Real catalog changes since you last opened the market (local snapshot comparison)",
1029
+ "清除": "Clear",
1030
+ "{0} 新增 {1} 条": "{0}: {1} added",
1031
+ "{0} 移除 {1} 条": "{0}: {1} removed",
1032
+ "{0} 更新 {1} 条": "{0}: {1} updated",
1033
+ " 等": " and more",
1034
+ "跳到对应市场": "Jump to the matching market",
1035
+ "核验 {0}": "Verified {0}",
1036
+ "已验证 · dsh {0}": "Verified · dsh {0}",
1037
+ // 底栏两枚 tooltip:口径是要命的信息,不能省。「↓」是近 30 天(技能卡那枚是累计),
1038
+ // 「核验」是目录批次核验时间(不是插件自己的更新时间),英文必须把这两点讲全。
1039
+ "npm 近 30 天下载量": "npm downloads in the last 30 days",
1040
+ "核验日期来自目录批次核验,不是插件自身的更新时间": "The verification date is when the catalog batch was checked, not when this plugin was updated",
1041
+ "查看 {0} 的 README": "View the README for {0}",
1042
+ "来源:WorkBuddy 官方分发": "Source: official WorkBuddy distribution",
1043
+
1044
+ "装备清单": "Loadout",
1045
+ "导出": "Export",
1046
+ "导入对比": "Compare import",
1047
+ "把本机的 MCP、技能、专家与插件导出成一份可分享的清单;导入只做对比,不会自动安装。": "Export your local MCP servers, skills, experts, and plugins as a shareable list. Import only compares — nothing is installed automatically.",
1048
+ "清单名称": "Loadout name",
1049
+ "我的 DSH 装备清单": "My DSH loadout",
1050
+ "读取本机配置…": "Reading local setup…",
1051
+ "重新生成": "Regenerate",
1052
+ "超过 200 条,只导出前 200 条。": "More than 200 items; only the first 200 are exported.",
1053
+ "复制 JSON": "Copy JSON",
1054
+ "已复制 JSON": "JSON copied",
1055
+ "下载 .json": "Download .json",
1056
+ "已开始下载": "Download started",
1057
+ "浏览器不允许下载,请改用复制 JSON": "The browser blocked the download; use Copy JSON instead",
1058
+ "还没有生成清单。": "No loadout generated yet.",
1059
+ "这份清单只含名称、类型、来源与版本;不会写入密钥、header 或环境变量的值。": "This loadout only includes names, types, sources, and versions; it never contains keys, headers, or environment variable values.",
1060
+ "把朋友发来的清单贴在这里,或选择文件。导入只做对比,不会自动安装。": "Paste a loadout a friend sent you, or choose a file. Import only compares — nothing is installed automatically.",
1061
+ "粘贴清单 JSON…": "Paste loadout JSON…",
1062
+ "选择 .json 文件": "Choose a .json file",
1063
+ "文件过大(上限 256KB)": "File too large (256KB max)",
1064
+ "文件读取失败:{0}": "Couldn't read the file: {0}",
1065
+ "先粘贴一份清单,或选择 .json 文件。": "Paste a loadout or choose a .json file first.",
1066
+ "不是有效的 JSON:{0}": "Not valid JSON: {0}",
1067
+ "对比失败": "Comparison failed",
1068
+ "对比中…": "Comparing…",
1069
+ "对比本机": "Compare with this device",
1070
+ "清单为空。": "The loadout is empty.",
1071
+ "共 {0} 项 · 已安装 {1} · 可更新 {2} · 本机没有 {3}": "{0} items · {1} installed · {2} updates · {3} missing",
1072
+ "清单来自 {0}": "Loadout from {0}",
1073
+ "本机:{0}": "Local: {0}",
1074
+ "可更新": "Update available",
1075
+ "本机没有": "Not installed",
1076
+ "去市场": "Find in market",
1077
+ "复制名称": "Copy name",
1078
+ "已复制名称": "Name copied",
1079
+ "导入只做对比;点「去市场」逐项安装,不会自动写入任何配置。": "Import only compares; use Find in market to install each item. Nothing is written automatically.",
1080
+ "内置 · {0}": "Built-in · {0}",
1081
+ "连接器 · {0}": "Connector · {0}",
1082
+ "手动配置": "Manual config",
1083
+ "WorkBuddy 专家中心": "WorkBuddy Expert Center",
1084
+ "本机 / 自带": "Local / bundled",
1085
+ "导出失败": "Export failed",
1086
+ "生成失败:{0}": "Couldn't generate: {0}",
1087
+ "已定位到连接器市场:{0}": "Opened the connector market: {0}",
1088
+ "这是手动配置的 MCP,名称已复制;可到 MCP 页自行添加。": "This MCP was configured manually; its name is copied. Add it on the MCP page.",
1089
+ "已定位到技能市场:{0}": "Opened the skill market: {0}",
1090
+ "已定位到专家市场:{0}": "Opened the expert market: {0}",
1091
+ "已定位到插件市场:{0}": "Opened the plugin market: {0}",
1092
+ "导出本机配置,或导入朋友的清单做差异对比": "Export your local setup, or import a friend's loadout to see the differences",
1093
+
1013
1094
  ":": ": ",
1014
1095
  };
1015
1096
  // 模块加载时先用 ①/③ 判定的 locale 装载一次;apply() 拿到宿主 locale 后会再按
@@ -1042,13 +1123,13 @@ const useI18nRevision = () => {
1042
1123
  useEffect(() => onI18nChange(() => setRevision((value) => value + 1)), []);
1043
1124
  };
1044
1125
 
1045
- // ── P1:数据型英文覆盖(更新日志 / 内置 MCP 目录)───────────────────────────
1046
- // 这两类文案不是 UI 字面量,而是宿主随 RPC 送来的数据:更新日志在 whats-new.js,
1047
- // 目录在生成的 mcp-catalog.js。它们不能走 t() 字典(键集合由那两个文件决定,硬抄
1048
- // 进字典会让 i18n-coverage 的「死键」断言永久变红),所以由宿主同时送中文与英文
1049
- // 字段,客户端按当前 locale 选择。英文缺失时逐条回落中文,绝不渲染 undefined。
1126
+ // ── P1:数据型英文覆盖(更新日志)───────────────────────────────────────────
1127
+ // 这类文案不是 UI 字面量,而是宿主随 RPC 送来的数据:更新日志在 whats-new.js。
1128
+ // 它不能走 t() 字典(键集合由那个文件决定,硬抄进字典会让 i18n-coverage 的「死键」
1129
+ // 断言永久变红),所以由宿主同时送中文与英文字段,客户端按当前 locale 选择。
1130
+ // 英文缺失时逐条回落中文,绝不渲染 undefined。
1050
1131
  //
1051
- // 两个 helper 的 `english` 参数只用于测试时显式指定;运行时用当前 locale 判定。
1132
+ // helper 的 `english` 参数只用于测试时显式指定;运行时用当前 locale 判定。
1052
1133
  const localizeWhatsNewEntries = (entries, english = usesEnglish()) => {
1053
1134
  if (!english || !Array.isArray(entries)) return entries;
1054
1135
  return entries.map((entry) => ({
@@ -1062,20 +1143,6 @@ const localizeWhatsNewEntries = (entries, english = usesEnglish()) => {
1062
1143
  : [],
1063
1144
  }));
1064
1145
  };
1065
- const localizeBuiltinItem = (item, english = usesEnglish()) => {
1066
- if (!english || !item) return item;
1067
- const authNote = item.authNoteEn || item.authNote;
1068
- // 只在英文字段真的存在时才覆盖:缺译文时保留原键(连 undefined 键都不新增),
1069
- // 面板据此区分「没有说明」与「说明是空的」。
1070
- return {
1071
- ...item,
1072
- ...(item.labelEn ? { label: item.labelEn } : {}),
1073
- ...(item.summaryEn ? { summary: item.summaryEn } : {}),
1074
- ...(item.accessEn ? { access: item.accessEn } : {}),
1075
- ...(authNote ? { authNote } : {}),
1076
- ...(item.categoryLabelEn ? { categoryLabel: item.categoryLabelEn } : {}),
1077
- };
1078
- };
1079
1146
 
1080
1147
  // ── 旧插件名 → 新插件名的 localStorage 键迁移 ──────────────────────────────
1081
1148
  // 1.9.0 起本插件由 dsh-skill-mcp-board 更名为 dsh-armory(package.json 与
@@ -1144,7 +1211,6 @@ function writeAwesomeSnapshot(payload) {
1144
1211
  localStorage.setItem(AWESOME_SNAPSHOT_KEY, text);
1145
1212
  } catch { /* 配额溢出 / 隐私模式:快照只是加速,失败不致命 */ }
1146
1213
  }
1147
-
1148
1214
  // ── 更新探测节流:把「每次进面板都 HEAD 一发」压到 6 小时一次 ────────────────
1149
1215
  // 目录本身已有服务端 stale-while-revalidate 在后台自动换新,这个 ETag 探测只是
1150
1216
  // 锦上添花;没必要每开一次面板就多打一次网络、多一次误报机会。
@@ -1294,6 +1360,12 @@ function ensureReadmeStyle() {
1294
1360
  '.dsh-mcp-readme-modal{width:min(880px,94vw);max-width:94vw;max-height:86vh}',
1295
1361
  '.dsh-mcp-readme-meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px;font-size:12px;opacity:.75}',
1296
1362
  '.dsh-mcp-readme-body{margin-top:12px;overflow-wrap:anywhere}',
1363
+ // 长 README 的右下角「跳到末尾」:sticky 让它不随正文滚走,又留在弹层内部,
1364
+ // 焦点陷阱、Esc 关闭与「点遮罩关闭」都不受影响。外层不拦鼠标,只有按钮可点。
1365
+ '.dsh-mcp-readme-jump-wrap{position:sticky;bottom:12px;z-index:2;display:flex;justify-content:flex-end;margin-top:12px;pointer-events:none}',
1366
+ '.dsh-mcp-readme-jump{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px;border:1px solid var(--mcp-line);background:var(--mcp-bg-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;font-weight:600;line-height:1.2;cursor:pointer;pointer-events:auto;box-shadow:var(--dsw-elevation-prominent);transition:border-color .12s,background .12s}',
1367
+ '.dsh-mcp-readme-jump:hover{border-color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover)}',
1368
+ '.dsh-mcp-readme-jump:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:2px}',
1297
1369
  '.dsh-mcp-md-h{margin:20px 0 8px;font-weight:600;line-height:1.35;letter-spacing:0}',
1298
1370
  '.dsh-mcp-md-h:first-child{margin-top:0}',
1299
1371
  '.dsh-mcp-md-p{margin:8px 0;line-height:1.72}',
@@ -1857,6 +1929,10 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
1857
1929
  .dsh-mcp-wb-search-input:hover:not(:disabled){border-color:var(--dsw-alias-border-l3)}
1858
1930
  .dsh-mcp-wb-search-input:focus{border-color:var(--mcp-accent);background:var(--mcp-bg-2);box-shadow:0 0 0 3px color-mix(in srgb,var(--mcp-accent) 16%,transparent)}
1859
1931
  .dsh-mcp-wb-search-input:disabled{opacity:.5;cursor:not-allowed}
1932
+ /* busy=服务端搜索在飞:输入框保持可用(disabled 会收走焦点,见 WbSearchBox 注释),
1933
+ 只让放大镜呼吸提示「在加载」,光标与选区都不受影响。 */
1934
+ .dsh-mcp-wb-search.busy .dsh-mcp-wb-search-ico{color:var(--mcp-accent);animation:dsh-wb-search-busy 1.1s ease-in-out infinite}
1935
+ @keyframes dsh-wb-search-busy{0%,100%{opacity:1}50%{opacity:.3}}
1860
1936
  .dsh-mcp-wb-search-input::-webkit-search-cancel-button{display:none}
1861
1937
  .dsh-mcp-wb-search-clear{position:absolute;right:7px;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border:0;border-radius:999px;background:var(--dsw-alias-interactive-bg-hover);color:var(--mcp-mut);cursor:pointer;padding:0;transition:color .12s,background .12s}
1862
1938
  .dsh-mcp-wb-search-clear:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-active)}
@@ -1870,6 +1946,55 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
1870
1946
  .dsh-mcp-card.wb.off{opacity:.82}
1871
1947
  .dsh-mcp-card.wb .dsh-mcp-card-summary{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:11.5px;color:var(--mcp-mut);line-height:1.5;min-height:2.9em}
1872
1948
  .dsh-mcp-wb-card-actions{display:flex;align-items:center;gap:8px;margin-top:auto;flex-wrap:wrap}
1949
+ /* ── DSH 社区插件卡片:五条固定水平线(头部 34 / 摘要 38 / 标签 18 / 页脚 38)──
1950
+ 只作用于 .dsh-mcp-pcard,连接器 / 技能 / 专家卡片不受影响。
1951
+ 卡片固定 176px:标题永远一行,标签最多三个,按钮落在同一水平线。 */
1952
+ .dsh-mcp-pcard{display:flex;flex-direction:column;gap:7px;min-width:0;min-height:176px;padding:12px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);cursor:pointer;transition:border-color .15s,box-shadow .15s,background .15s}
1953
+ .dsh-mcp-pcard:hover{border-color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover);box-shadow:0 10px 28px color-mix(in srgb,var(--mcp-accent) 12%,transparent)}
1954
+ .dsh-mcp-pcard:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:2px}
1955
+ .dsh-mcp-pcard.off{opacity:.82}
1956
+ .dsh-mcp-phead{display:grid;grid-template-columns:32px minmax(0,1fr) auto;align-items:center;gap:10px;min-height:34px}
1957
+ .dsh-mcp-pavatar{position:relative;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;flex:none;overflow:hidden;border:1px solid var(--mcp-line);border-radius:50%;corner-shape:round;background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--mcp-accent);font-size:12px;font-weight:650}
1958
+ .dsh-mcp-pavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
1959
+ .dsh-mcp-piden{display:flex;flex-direction:column;gap:1px;min-width:0}
1960
+ .dsh-mcp-pname{margin:0;font-size:14px;line-height:1.3;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1961
+ .dsh-mcp-pmeta{display:flex;align-items:center;gap:5px;min-width:0;overflow:hidden;font-size:10.5px;line-height:1.4;color:var(--mcp-mut);white-space:nowrap}
1962
+ .dsh-mcp-pmeta .official{display:inline-flex;align-items:center;gap:4px;flex:none;color:var(--dsw-alias-label-primary);font-weight:600}
1963
+ .dsh-mcp-pmeta .odot{width:5px;height:5px;flex:none;border-radius:50%;background:var(--mcp-accent)}
1964
+ .dsh-mcp-pmeta .pdot{flex:none;opacity:.55}
1965
+ .dsh-mcp-prepo{min-width:0;overflow:hidden;text-overflow:ellipsis}
1966
+ .dsh-mcp-pverified{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex:none;justify-self:end;color:var(--dsw-alias-state-success-primary)}
1967
+ .dsh-mcp-pdesc{margin:0;height:38px;overflow:hidden;font-size:12px;line-height:1.55;color:var(--mcp-mut);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
1968
+ .dsh-mcp-pchips{display:flex;align-items:center;gap:5px;height:18px;min-width:0;overflow:hidden;white-space:nowrap}
1969
+ .dsh-mcp-pchip{display:inline-flex;align-items:center;flex:none;padding:0 7px;border:1px solid var(--mcp-line);border-radius:999px;background:var(--mcp-bg-2);font-size:10.5px;line-height:16px;color:var(--mcp-mut)}
1970
+ .dsh-mcp-pchip.more{border-style:dashed}
1971
+ /* 包名芯片是标签行里唯一允许收缩的一枚(其余 flex:none):放不下时它自己省略号截断,
1972
+ 分类/标签不会被挤成半截。行高仍由 .dsh-mcp-pchip 的 line-height:16px 决定 → 18px 不漂。*/
1973
+ .dsh-mcp-pchip.mono{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;font-family:var(--mcp-mono);font-size:10px;letter-spacing:.2px;background:var(--mcp-bg-3)}
1974
+ .dsh-mcp-pfoot{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0;min-height:38px;margin-top:auto;padding-top:8px;border-top:1px solid var(--mcp-line)}
1975
+ /* 底栏事实行(2.3.x 起 3 枚:↓近30天下载 / ★星数 / 核验日期):
1976
+ · 字号 10.5→11.5px。实测卡高由 .dsh-mcp-pfoot 的 min-height:38 决定(footH 恒 47),
1977
+ 这行字变大不动卡片一 px——用户明确要求「信息更丰富但卡不再变大」。
1978
+ · 去掉两枚「·」分隔点(每枚连 gap ≈12.7px),改成纯 gap:7px 间距,把省下的宽度
1979
+ 付给新出现的「↓」,而不是让它去挤日期。
1980
+ · flex:1 1 0 + position:relative:这一行的盒子宽度**恒等于可用宽**(不随内容伸缩),
1981
+ JS 量到的 clientWidth 就是真可用宽;同时它独吞全部收缩,右侧按钮(.dsh-mcp-pbtn
1982
+ flex:none)永不被挤到换行——换行会把卡撑高。
1983
+ · .probe 是离流探针(width:max-content 才不会被可用宽夹住,否则永远量不出溢出),
1984
+ 与真行同构,用来判断「三枚全画要多宽」;放不下时整枚收起日期,不裁半截字。 */
1985
+ .dsh-mcp-pfacts{position:relative;flex:1 1 0;display:flex;align-items:center;gap:7px;min-width:0;overflow:hidden;font-size:11.5px;line-height:1.4;color:var(--mcp-mut);white-space:nowrap;font-variant-numeric:tabular-nums}
1986
+ .dsh-mcp-pfacts .dl{display:inline-flex;align-items:center;flex:none;color:var(--dsw-alias-label-primary);opacity:.72}
1987
+ .dsh-mcp-pfacts .star{display:inline-flex;align-items:center;gap:3px;flex:none;color:var(--dsw-alias-label-primary);opacity:.72}
1988
+ .dsh-mcp-pfacts .when{flex:none;opacity:.85}
1989
+ .dsh-mcp-pfacts-probe{position:absolute;left:0;top:0;width:max-content;display:flex;align-items:center;gap:7px;white-space:nowrap;visibility:hidden;pointer-events:none}
1990
+ .dsh-mcp-pbtn{display:inline-flex;align-items:center;justify-content:center;height:28px;padding:0 12px;flex:none;border:1px solid var(--mcp-accent);border-radius:8px;background:var(--mcp-accent);color:#fff;font:inherit;font-size:12px;font-weight:600;line-height:1;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s}
1991
+ .dsh-mcp-pbtn:hover:not(:disabled){border-color:var(--mcp-accent);background:color-mix(in srgb,var(--mcp-accent) 86%,#000)}
1992
+ .dsh-mcp-pbtn:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:2px}
1993
+ .dsh-mcp-pbtn:disabled{opacity:.55;cursor:not-allowed}
1994
+ .dsh-mcp-pbtn.installed{border-color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-tertiary);color:var(--dsw-alias-label-primary)}
1995
+ .dsh-mcp-pbtn.installed:hover:not(:disabled){border-color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 14%,transparent)}
1996
+ .dsh-mcp-phost{display:inline-flex;align-items:center;gap:4px;flex:none;min-width:0;font-size:10.5px;line-height:1.4;color:var(--mcp-mut);white-space:nowrap}
1997
+ .dsh-mcp-phost .ico{flex:none;color:var(--mcp-warn)}
1873
1998
  .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}
1874
1999
  .dsh-mcp-wb-pager{display:flex;align-items:center;justify-content:center;gap:12px;padding:6px 0 2px}
1875
2000
  .dsh-mcp-page-jump{display:flex;align-items:center;gap:4px;font-size:11.5px;color:var(--mcp-mut)}
@@ -1878,6 +2003,8 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
1878
2003
  .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)}
1879
2004
  .dsh-mcp-readonly-note>svg{flex:none;margin-top:1px;opacity:.75}
1880
2005
  .dsh-mcp-wb-modal{width:640px;max-width:92vw}
2006
+ .dsh-mcp-wb-modal-head{display:flex;align-items:center;gap:10px;min-width:0}
2007
+ .dsh-mcp-wb-modal-head h3{margin:0;min-width:0;overflow-wrap:anywhere}
1881
2008
  .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}
1882
2009
  .dsh-mcp-wb-modal-note{font-size:11.5px;line-height:1.6;color:var(--mcp-mut)}
1883
2010
  .dsh-mcp-wb-examples{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary);background:var(--mcp-bg-2);border-radius:var(--mcp-r-sm);padding:8px 12px;margin:10px 0}
@@ -1895,7 +2022,7 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
1895
2022
  .dsh-mcp-wb-roster-prof{font-size:11px;color:var(--mcp-mut);flex:none}
1896
2023
  .dsh-mcp-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px;flex-wrap:wrap}
1897
2024
  .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}
1898
- .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}
2025
+ .dsh-mcp-builtin-count{font-size:11.5px;color:var(--mcp-mut)}
1899
2026
  .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)}
1900
2027
  /* OPT-17 · 2.5:不支持分组的呈现(复用 source-row 的排版,只补两处色语与分组间距) */
1901
2028
  .dsh-mcp-source-unsupported{font-size:11px;line-height:1.5;color:var(--mcp-warn);overflow-wrap:anywhere}
@@ -2006,7 +2133,8 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
2006
2133
  「整块突现→重绘」残影。定位 position:fixed(由 JS 实测 trigger 视口坐标后
2007
2134
  内联写入):absolute 锚定会让菜单变高时伸展祖先滚动容器,整个会话页跟着
2008
2135
  跳动;fixed 脱离滚动容器,菜单再高页面也零位移。left/top/bottom 全部内联。 */
2009
- .dsh-mcp-quick-menu{position:fixed;z-index:950;min-width:148px;padding:5px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-1);box-shadow:var(--dsw-elevation-overlay,0 8px 28px rgba(0,0,0,.14));transform-origin:left bottom;animation:dshMcpMenuIn .12s ease-out}
2136
+ /* max-width:窄窗下菜单本身也不许顶出视口(width 是内容盒,+10 padding +2 border 仍留 8px 余量)。 */
2137
+ .dsh-mcp-quick-menu{position:fixed;z-index:950;min-width:128px;max-width:calc(100vw - 20px);padding:5px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-1);box-shadow:var(--dsw-elevation-overlay,0 8px 28px rgba(0,0,0,.14));transform-origin:left bottom;animation:dshMcpMenuIn .12s ease-out}
2010
2138
  @keyframes dshMcpMenuIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
2011
2139
  @media (prefers-reduced-motion: reduce){.dsh-mcp-quick-menu{animation:none}.dsh-mcp-quick-sub{animation:none}}
2012
2140
  .dsh-mcp-quick-row{position:relative}
@@ -2019,7 +2147,8 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
2019
2147
  出场动画只动 opacity/transform(动效克制),替代上一版「整块突现」的残影来源。
2020
2148
  面板本体 max-height 由「6 行列表 + 底部固定管理按钮」精确算出(6×28 + 5×2 padding
2021
2149
  + 30 按钮 + 4 间距 + 边框),列表自身滚动,管理按钮永远贴底可见。 */
2022
- .dsh-mcp-quick-sub{position:absolute;top:50%;left:calc(100% + 6px);z-index:951;display:flex;flex-direction:column;width:232px;max-height:min(216px,40vh);padding:5px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-1);box-shadow:var(--dsw-elevation-overlay,0 8px 28px rgba(0,0,0,.14));transform:translateY(-50%);transform-origin:left center;animation:dshMcpQuickIn .13s ease-out}
2150
+ /* 232px 是常用的展开宽度;max-width 保证极窄窗下也不横出视口。 */
2151
+ .dsh-mcp-quick-sub{position:absolute;top:50%;left:calc(100% + 4px);z-index:951;display:flex;flex-direction:column;width:232px;max-width:calc(100vw - 28px);max-height:min(216px,40vh);padding:5px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-1);box-shadow:var(--dsw-elevation-overlay,0 8px 28px rgba(0,0,0,.14));transform:translateY(-50%);transform-origin:left center;animation:dshMcpQuickIn .13s ease-out}
2023
2152
  /* 可滚动列表:最多 6 行(6 × 28px),溢出即滚动;隐藏水平溢出防止拖宽面板 */
2024
2153
  .dsh-mcp-quick-list{min-height:0;max-height:168px;overflow-y:auto;overflow-x:hidden;border-radius:var(--mcp-r-sm)}
2025
2154
  /* 细滚动条(跟随主题 token,悬停加深;hover 之外 6px 宽半透明,几乎不占视觉) */
@@ -2030,10 +2159,18 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
2030
2159
  .dsh-mcp-quick-list:hover::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--dsw-alias-label-tertiary) 45%,transparent)}
2031
2160
  .dsh-mcp-quick-list:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-1px}
2032
2161
  /* 视口下缘不够时 JS 加 flip:子菜单改为向锚点行上方展开 */
2033
- .dsh-mcp-quick-row.flip .dsh-mcp-quick-sub{top:auto;bottom:50%;transform:translateY(50%)}
2162
+ /* 向上展开 = 子菜单底边贴锚点行底边、向上生长。旧写法 bottom:50% + translateY(50%)
2163
+ 与默认的 top:50% + translateY(-50%) 数学上等价,等于没翻,子菜单照样越过视口下缘。 */
2164
+ .dsh-mcp-quick-row.flip .dsh-mcp-quick-sub{top:auto;bottom:0;transform:none}
2034
2165
  .dsh-mcp-quick-row.flip .dsh-mcp-quick-sub{animation-name:dshMcpQuickInFlip}
2166
+ /* 视口右缘不够时 JS 加 hflip:子菜单改到锚点行左侧展开,出场动画镜像。
2167
+ 菜单本体贴屏幕右边时,这一侧才是「向内开」,否则 232px 的第二级会整块伸出视口。 */
2168
+ .dsh-mcp-quick-row.hflip .dsh-mcp-quick-sub{left:auto;right:calc(100% + 4px);transform-origin:right center;animation-name:dshMcpQuickInLeft}
2169
+ .dsh-mcp-quick-row.hflip.flip .dsh-mcp-quick-sub{animation-name:dshMcpQuickInFlipLeft}
2035
2170
  @keyframes dshMcpQuickIn{from{opacity:0;transform:translateY(-50%) translateX(-4px)}to{opacity:1;transform:translateY(-50%)}}
2036
- @keyframes dshMcpQuickInFlip{from{opacity:0;transform:translateY(50%) translateX(-4px)}to{opacity:1;transform:translateY(50%)}}
2171
+ @keyframes dshMcpQuickInFlip{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
2172
+ @keyframes dshMcpQuickInLeft{from{opacity:0;transform:translateY(-50%) translateX(4px)}to{opacity:1;transform:translateY(-50%)}}
2173
+ @keyframes dshMcpQuickInFlipLeft{from{opacity:0;transform:translateX(4px)}to{opacity:1;transform:none}}
2037
2174
  @media (prefers-reduced-motion: reduce){.dsh-mcp-quick-sub{animation:none}}
2038
2175
  .dsh-mcp-quick-sub.empty{display:block;padding:9px 11px;color:var(--mcp-mut);font-size:12px}
2039
2176
  .dsh-mcp-quick-sub-note{padding:6px 9px;color:var(--mcp-mut);font-size:11px;white-space:normal}
@@ -2092,6 +2229,39 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
2092
2229
  .dsh-mcp-cat-count{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
2093
2230
  .dsh-mcp-cat[aria-pressed="true"] .dsh-mcp-cat-count{color:var(--dsw-alias-label-secondary)}
2094
2231
 
2232
+ /* ── 顶栏右端「点 Star」──────────────────────────────────────────────
2233
+ 分类栏本身是 flex-wrap 的(窄屏换行),所以外面套一行:chip 们占剩余宽度自由换行,
2234
+ star 贴右上不参与换行。它挂在 role=tablist 容器**之外**——tablist 里只该有 tab。 */
2235
+ .dsh-mcp-cats-row{display:flex;align-items:flex-start;gap:10px}
2236
+ .dsh-mcp-cats-row>.dsh-mcp-cats{flex:1 1 auto;min-width:0}
2237
+ .dsh-mcp-star{position:relative;display:inline-flex;align-items:center;gap:6px;flex:none;box-sizing:border-box;padding:5px 11px 5px 9px;border:1px solid var(--mcp-line);border-radius:999px;corner-shape:round;background:var(--mcp-bg-1);color:var(--mcp-mut);font:inherit;font-size:12px;line-height:18px;white-space:nowrap;text-decoration:none;cursor:pointer;transition:border-color .14s,color .14s,background .14s}
2238
+ .dsh-mcp-star:hover{border-color:var(--mcp-cat-auto);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
2239
+ .dsh-mcp-star:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--mcp-accent));outline-offset:1px}
2240
+ .dsh-mcp-star-label{font-weight:600}
2241
+ .dsh-mcp-star-count{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}
2242
+ .dsh-mcp-star:hover .dsh-mcp-star-count{color:var(--mcp-cat-auto)}
2243
+ /* 星形:平时淡灰描边感(低不透明度实心),hover 转实心琥珀并轻转——「这里可以点」的线索 */
2244
+ .dsh-mcp-star-svg{width:15px;height:15px;flex:none;color:var(--dsw-alias-label-tertiary);transition:color .14s,transform .2s}
2245
+ .dsh-mcp-star:hover .dsh-mcp-star-svg{color:var(--mcp-cat-auto);transform:rotate(18deg) scale(1.12)}
2246
+ .dsh-mcp-star-svg path{fill:currentColor;stroke:none;opacity:.3;transition:opacity .14s}
2247
+ .dsh-mcp-star:hover .dsh-mcp-star-svg path{opacity:1}
2248
+ /* 小交互图案:两颗四角星屑,平时不显示,hover / 点下去才闪出来(不占布局) */
2249
+ .dsh-mcp-star-spark{position:absolute;width:5px;height:5px;border-radius:1px;background:var(--mcp-cat-auto);opacity:0;pointer-events:none;transform:scale(.3)}
2250
+ .dsh-mcp-star-spark.a{left:3px;top:3px}
2251
+ .dsh-mcp-star-spark.b{right:4px;bottom:4px;width:4px;height:4px}
2252
+ .dsh-mcp-star:hover .dsh-mcp-star-spark{animation:dshMcpStarTwinkle .9s ease-out}
2253
+ .dsh-mcp-star:hover .dsh-mcp-star-spark.b{animation-delay:.16s}
2254
+ /* 点下去:星形放大回弹 + 星屑迸一下,明确「已经跳去 GitHub 了」 */
2255
+ .dsh-mcp-star.burst{border-color:var(--mcp-cat-auto)}
2256
+ .dsh-mcp-star.burst .dsh-mcp-star-svg{animation:dshMcpStarPop .55s cubic-bezier(.22,1.4,.36,1)}
2257
+ .dsh-mcp-star.burst .dsh-mcp-star-spark{animation:dshMcpStarTwinkle .55s ease-out}
2258
+ @keyframes dshMcpStarTwinkle{0%{opacity:0;transform:scale(.3) rotate(0)}45%{opacity:1;transform:scale(1) rotate(35deg)}100%{opacity:0;transform:scale(.5) rotate(70deg)}}
2259
+ @keyframes dshMcpStarPop{0%{transform:scale(1) rotate(0)}35%{transform:scale(1.5) rotate(24deg)}70%{transform:scale(.92) rotate(-6deg)}100%{transform:scale(1.12) rotate(18deg)}}
2260
+ /* 窄屏只留图案与数字,文字标签让位给分类 chip 的换行 */
2261
+ @media (max-width:760px){.dsh-mcp-star{padding:5px 9px}.dsh-mcp-star-label{display:none}}
2262
+ /* 减弱动效:星形不再转、不再迸,但 hover 的配色反馈与静态星屑都留着——信息量不打折 */
2263
+ @media (prefers-reduced-motion: reduce){.dsh-mcp-star,.dsh-mcp-star-svg,.dsh-mcp-star-svg path{transition:none}.dsh-mcp-star:hover .dsh-mcp-star-svg{transform:none}.dsh-mcp-star.burst .dsh-mcp-star-svg{animation:none;transform:none}.dsh-mcp-star:hover .dsh-mcp-star-spark,.dsh-mcp-star.burst .dsh-mcp-star-spark{animation:none;opacity:.9}}
2264
+
2095
2265
  /* ── Skill 看板 ─────────────────────────────────────────────────── */
2096
2266
  .dsh-mcp-skill-view{display:flex;flex-direction:column;gap:12px;min-height:0}
2097
2267
  .dsh-mcp-skill-body{display:flex;gap:14px;min-height:0;align-items:stretch}
@@ -2124,7 +2294,21 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
2124
2294
  /* Skill 看板升级:分类轨道 + 网格/列表双视图 + 详情操作区。
2125
2295
  全部走宿主主题令牌(决策 2/10),分类色只出现在色点与徽标,且永远伴随文字标签。 */
2126
2296
  .dsh-mcp-skill-toolbar{display:flex;align-items:center;gap:10px;flex:none}
2127
- .dsh-mcp-skill-search{flex:1 1 auto;min-width:0}
2297
+ /* 看板搜索框自带完整样式(此前只有 flex 尺寸,放大镜裸排在输入框外、输入框吃浏览器默认态)。
2298
+ 观感对齐 .suite-search:图标内嵌居中、统一 30px 高。聚焦圈只能用 inset 内描边:
2299
+ 搜索框贴着 .dsh-mcp-skill-content(overflow 滚动容器)的边缘,向外的 shadow 光环
2300
+ 会被裁出残缺口(.dsh-mcp-search 聚焦因此直接 box-shadow:none,同理)。
2301
+ 输入非空时容器挂 querying 类,input 的右 padding 给「清空」按钮让位。 */
2302
+ .dsh-mcp-skill-search{position:relative;display:flex;align-items:center;flex:1 1 auto;min-width:0;height:30px}
2303
+ .dsh-mcp-skill-search>svg{position:absolute;top:50%;left:9px;transform:translateY(-50%);pointer-events:none;color:var(--mcp-mut)}
2304
+ .dsh-mcp-skill-search input{box-sizing:border-box;width:100%;height:30px;padding:0 10px 0 28px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);corner-shape:round;background:var(--mcp-bg-1);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12px;outline:0;transition:border-color .15s,box-shadow .15s}
2305
+ .dsh-mcp-skill-search input:hover{border-color:var(--dsw-alias-border-l3)}
2306
+ .dsh-mcp-skill-search input::placeholder{color:var(--mcp-mut)}
2307
+ .dsh-mcp-skill-search input:focus{border-color:var(--mcp-accent);box-shadow:inset 0 0 0 1px var(--mcp-accent)}
2308
+ .dsh-mcp-skill-search.querying input{padding-right:28px}
2309
+ .dsh-mcp-skill-search .clear{position:absolute;top:50%;right:5px;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;border:0;border-radius:var(--mcp-r-xs);corner-shape:round;background:0 0;color:var(--mcp-mut);cursor:pointer;transition:background .12s,color .12s}
2310
+ .dsh-mcp-skill-search .clear:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
2311
+ .dsh-mcp-skill-search .clear:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:1px}
2128
2312
  .dsh-mcp-skill-seg{display:inline-flex;flex:none;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);corner-shape:round;background:var(--mcp-bg-2);padding:2px;gap:2px}
2129
2313
  .dsh-mcp-skill-seg-btn{display:inline-flex;align-items:center;gap:5px;border:0;border-radius:var(--mcp-r-sm);corner-shape:round;background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11.5px;line-height:20px;padding:2px 9px;cursor:pointer;transition:background .12s,color .12s}
2130
2314
  .dsh-mcp-skill-seg-btn:hover{color:var(--dsw-alias-label-primary)}
@@ -2149,6 +2333,20 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
2149
2333
  .dsh-mcp-skill-card:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:2px}
2150
2334
  .dsh-mcp-skill-card-top{display:flex;align-items:center;gap:8px;min-width:0}
2151
2335
  .dsh-mcp-skill-cat-tile{display:inline-block;width:10px;height:10px;border-radius:3px;flex:none}
2336
+ /* Skill 头像:真图与生成头像同一几何,直径对齐市场(网格 30 / 列表 22 / 详情 42)。
2337
+ 生成版渐变底色由组件按名字哈希内联,颜色这里只管字形与描边——白字在深浅两主题的
2338
+ 饱和底上都可读;真图版与 .dsh-mcp-card-avatar 同规格,探活失败同样回落字母。 */
2339
+ .dsh-mcp-skill-avatar{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;corner-shape:round;flex:none;font-size:13px;font-weight:650;line-height:1;color:#fff;text-shadow:0 1px 2px rgb(0 0 0 / 22%);border:1px solid var(--mcp-line);box-shadow:inset 0 0 0 1px rgb(255 255 255 / 18%)}
2340
+ .dsh-mcp-skill-avatar.sm{width:22px;height:22px;font-size:11px}
2341
+ .dsh-mcp-skill-avatar.lg{width:42px;height:42px;font-size:17px}
2342
+ .dsh-mcp-skill-avatar-img{width:30px;height:30px;border-radius:50%;corner-shape:round;object-fit:cover;flex:none;border:1px solid var(--mcp-line);background:var(--mcp-bg-1)}
2343
+ .dsh-mcp-skill-avatar-img.sm{width:22px;height:22px}
2344
+ .dsh-mcp-skill-avatar-img.lg{width:42px;height:42px}
2345
+ .dsh-mcp-skill-avatar-img.fallback{display:inline-flex;align-items:center;justify-content:center;font-size:13px;font-weight:650;color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover-accent)}
2346
+ .dsh-mcp-skill-d-id{display:flex;align-items:flex-start;gap:10px;min-width:0}
2347
+ .dsh-mcp-skill-d-id .dsh-mcp-skill-avatar.lg,.dsh-mcp-skill-d-id .dsh-mcp-skill-avatar-img.lg{margin-top:2px}
2348
+ .dsh-mcp-onsite-avatar{width:24px;height:24px;border-radius:50%;corner-shape:round;object-fit:cover;flex:none;border:1px solid var(--mcp-line);font-size:11px}
2349
+ .dsh-mcp-onsite-avatar.fallback{display:inline-flex;align-items:center;justify-content:center;font-weight:650;color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover-accent)}
2152
2350
  .dsh-mcp-skill-card-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;font-size:13px;line-height:18px;color:var(--dsw-alias-label-primary)}
2153
2351
  .dsh-mcp-skill-desc-none{color:var(--dsw-alias-label-tertiary)}
2154
2352
  .dsh-mcp-skill-cat-tag{color:var(--dsw-alias-label-secondary);font-size:10.5px;line-height:16px;white-space:nowrap}
@@ -2238,8 +2436,8 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
2238
2436
  .suite-body{flex:1;min-height:0;display:grid;grid-template-columns:minmax(320px,44%) 1fr}
2239
2437
  /* ── 左:技能库选择器 ──────────────────────────────────────────────── */
2240
2438
  .suite-left{display:flex;flex-direction:column;gap:9px;min-height:0;padding:13px 14px;border-right:1px solid var(--mcp-line);background:var(--mcp-bg-2)}
2241
- /* 搜索框不复用 .dsh-mcp-skill-search:那条在本仓库只有 flex 尺寸、没有图标定位,
2242
- 看板里的放大镜其实是裸排的(既有小瑕疵,不在本次范围)。这里自带一份完整的。 */
2439
+ /* 搜索框不复用 .dsh-mcp-skill-search:看板那条已补齐自己的完整样式(图标内嵌 + 清空按钮),
2440
+ 但它是工具条场景的尺寸与交互;弹窗这边保留独立一份,两处后续调整互不牵连。 */
2243
2441
  .suite-search{position:relative;display:flex;align-items:center;flex:none;height:30px}
2244
2442
  .suite-search svg{position:absolute;top:50%;left:9px;transform:translateY(-50%);pointer-events:none;color:var(--mcp-mut)}
2245
2443
  .suite-search input{box-sizing:border-box;width:100%;height:30px;padding:0 10px 0 28px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12px;outline:0;transition:border-color .15s,box-shadow .15s}
@@ -2587,7 +2785,8 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
2587
2785
  .dsh-mcp-rowmenu-sep{height:1px;background:var(--mcp-line);margin:3px 2px}
2588
2786
 
2589
2787
  /* ── §4.1 专家「当前在场」区:绿顶条 + 更大的卡,与普通罗列拉开权重 ───────────── */
2590
- .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}
2788
+ /* margin-bottom:与下方专家卡网格留一口气(两者原本紧贴,卡片像从提示框里溢出来)。 */
2789
+ .dsh-mcp-onsite{display:flex;flex-direction:column;gap:9px;flex:none;margin-bottom:12px;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}
2591
2790
  .dsh-mcp-onsite-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
2592
2791
  .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}
2593
2792
  .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}
@@ -2604,6 +2803,10 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
2604
2803
  .dsh-mcp-onsite-chips>button.dsh-mcp-chip-s:hover{border-color:var(--mcp-accent);color:var(--dsw-alias-brand-text)}
2605
2804
  /* 推荐位与在场区之间留一口气,否则两块卡片网格会连成一片。 */
2606
2805
  .dsh-mcp-wb-grid+.dsh-mcp-item-sub{margin-top:14px}
2806
+ /* 推荐位整块(提示行 + 推荐卡网格):上方与已召唤网格隔 18px,提示行与推荐卡之间
2807
+ 隔 10px——此前提示行与推荐卡之间是 0 间距,小字提示直接贴在卡片圆角上。 */
2808
+ .dsh-mcp-wb-reco{margin-top:18px}
2809
+ .dsh-mcp-wb-reco>.dsh-mcp-item-sub{margin-bottom:10px}
2607
2810
  /* 送还内联二次确认:把「点一下就没了」改成「点一下先问一句」 */
2608
2811
  .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)}
2609
2812
  .dsh-mcp-confirm>span{color:var(--mcp-err);font-weight:600}
@@ -2629,10 +2832,43 @@ button.dsh-mcp-chip-s.on .dsh-mcp-chip-s-count{color:var(--mcp-accent)}
2629
2832
  .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}
2630
2833
 
2631
2834
  /* ── §6 市场:面包屑 + 子页签(下划线式)+ 兼容性前置 + 密度 + 分类条 + 免责折叠 ── */
2632
- .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}
2835
+ /* 面包屑不再独占一行:作为子页签行的最后一个元素靠右贴齐(margin-left:auto),
2836
+ 与页签同一基线、正好坐在分割线上方。 */
2837
+ .dsh-mcp-mk-crumb{display:flex;align-items:center;gap:6px;flex:none;margin-left:auto;padding-left:12px;font-size:11px;line-height:17px;color:var(--dsw-alias-label-tertiary);min-height:17px;white-space:nowrap}
2633
2838
  .dsh-mcp-mk-crumb-root{color:var(--dsw-alias-label-secondary)}
2634
2839
  .dsh-mcp-mk-crumb-sep{opacity:.55}
2635
2840
  .dsh-mcp-mk-crumb>b{font-weight:600;color:var(--dsw-alias-label-primary);font-size:11.5px}
2841
+ .dsh-mcp-mk-topright{display:flex;align-items:center;gap:10px;flex:none;margin-left:auto}
2842
+ .dsh-mcp-mk-topright .dsh-mcp-mk-crumb{margin-left:0;padding-left:0}
2843
+ .dsh-mcp-loadout-open{border:1px solid var(--mcp-line);background:transparent;color:var(--mcp-mut);border-radius:999px;padding:4px 10px;font-size:11px;line-height:17px;cursor:pointer;white-space:nowrap}
2844
+ .dsh-mcp-loadout-open:hover{border-color:var(--mcp-accent);color:var(--dsw-alias-label-primary)}
2845
+ .dsh-mcp-loadout-modal{width:min(780px,94vw);height:min(720px,88vh);max-height:88vh;display:flex;flex-direction:column;padding:18px 20px;overflow:hidden}
2846
+ .dsh-mcp-loadout-tabs{display:flex;gap:2px;flex:none;border-bottom:1px solid var(--mcp-line);margin:-4px 0 12px}
2847
+ .dsh-mcp-loadout-tab{border:0;background:transparent;color:var(--mcp-mut);font:inherit;font-size:12px;padding:7px 11px;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
2848
+ .dsh-mcp-loadout-tab[aria-selected="true"]{color:var(--dsw-alias-label-primary);border-bottom-color:var(--mcp-accent);font-weight:600}
2849
+ .dsh-mcp-loadout-pane{flex:1;min-height:0;display:flex;flex-direction:column;gap:10px;overflow-y:auto;padding-right:2px}
2850
+ .dsh-mcp-loadout-hint,.dsh-mcp-loadout-note{margin:0;font-size:11.5px;line-height:1.55;color:var(--mcp-mut)}
2851
+ .dsh-mcp-loadout-note{flex:none;border-top:1px dashed var(--mcp-line);padding-top:8px}
2852
+ .dsh-mcp-loadout-name{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--mcp-mut);flex:none}
2853
+ .dsh-mcp-loadout-name input{flex:1;min-width:180px;box-sizing:border-box;padding:7px 9px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);color:var(--dsw-alias-label-primary);font:inherit}
2854
+ .dsh-mcp-loadout-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex:none}
2855
+ .dsh-mcp-loadout-result{display:flex;flex-direction:column;gap:8px;min-height:0}
2856
+ .dsh-mcp-loadout-summary{display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;font-size:11.5px;color:var(--mcp-mut);flex:none}
2857
+ .dsh-mcp-loadout-warn{color:var(--dsw-alias-state-warning-primary)}
2858
+ .dsh-mcp-loadout-from{color:var(--dsw-alias-label-tertiary)}
2859
+ .dsh-mcp-loadout-json{margin:0;min-height:160px;max-height:320px;overflow:auto;padding:10px 12px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);font-family:var(--mcp-mono);font-size:11px;line-height:1.5;white-space:pre;color:var(--dsw-alias-label-secondary)}
2860
+ .dsh-mcp-loadout-input{width:100%;box-sizing:border-box;resize:vertical;min-height:150px;padding:10px 12px;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:var(--mcp-mono);font-size:11.5px;line-height:1.5}
2861
+ .dsh-mcp-loadout-file{cursor:pointer}
2862
+ .dsh-mcp-loadout-rows{display:flex;flex-direction:column;gap:6px;overflow-y:auto;min-height:0;padding-right:2px}
2863
+ .dsh-mcp-loadout-row{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:8px 10px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1)}
2864
+ .dsh-mcp-loadout-row-main{min-width:0;display:flex;flex-direction:column;gap:3px}
2865
+ .dsh-mcp-loadout-row-title{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px;overflow-wrap:anywhere}
2866
+ .dsh-mcp-loadout-kind{font-size:10px;color:var(--mcp-mut);border:1px solid var(--mcp-line);border-radius:999px;padding:0 6px}
2867
+ .dsh-mcp-loadout-row-meta{display:flex;gap:4px 10px;flex-wrap:wrap;font-size:10.5px;color:var(--mcp-mut)}
2868
+ .dsh-mcp-loadout-row-actions{display:flex;gap:6px;flex:none;flex-wrap:wrap;justify-content:flex-end}
2869
+ @media (max-width:480px){.dsh-mcp-loadout-modal{width:100%;height:calc(100vh - 24px);max-height:none;padding:14px}.dsh-mcp-loadout-row{flex-direction:column}.dsh-mcp-loadout-row-actions{width:100%;justify-content:flex-start}}
2870
+ /* 窄屏页签行放不下 5 个页签 + 面包屑:页签优先,面包屑让位(页签本身已标明当前位置)。 */
2871
+ @media (max-width:480px){.dsh-mcp-mk-crumb{display:none}}
2636
2872
  .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}
2637
2873
  .dsh-mcp-mk-subtabs-idle:hover{opacity:1;color:var(--dsw-alias-label-secondary);text-decoration:underline;text-underline-offset:2px}
2638
2874
  .dsh-mcp-mk-subtabs-idle:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-2px}
@@ -2678,9 +2914,70 @@ button.dsh-mcp-chip-s.on .dsh-mcp-chip-s-count{color:var(--mcp-accent)}
2678
2914
  @keyframes mcpSkeletonShimmer{100%{transform:translateX(100%)}}
2679
2915
  @media (prefers-reduced-motion: reduce){.dsh-mcp-skeleton-bar::after{animation:none;opacity:0}}
2680
2916
 
2917
+ /* ── 社区「人味」:作者头像 / 维护状态 / 最近动静 / 真实反馈出口 ────────
2918
+ 全部使用既有主题令牌;头像只是 <img>,加载失败就隐藏,不留破图占位。 */
2919
+ .dsh-mcp-card-person{display:flex;align-items:flex-start;gap:8px;min-width:0}
2920
+ .dsh-mcp-card-person>div{min-width:0}
2921
+ .dsh-mcp-card-avatar{width:30px;height:30px;border-radius:50%;corner-shape:round;object-fit:cover;flex:none;border:1px solid var(--mcp-line);background:var(--mcp-bg-1)}
2922
+ .dsh-mcp-card-avatar.fallback{display:inline-flex;align-items:center;justify-content:center;font-size:13px;font-weight:650;color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover-accent)}
2923
+ .dsh-mcp-detail-person{display:flex;align-items:flex-start;gap:10px;margin-bottom:8px}
2924
+ .dsh-mcp-detail-person>div{min-width:0}
2925
+ .dsh-mcp-detail-person h3{margin:0}
2926
+ .dsh-mcp-detail-avatar{width:42px;height:42px;border-radius:50%;corner-shape:round;object-fit:cover;flex:none;border:1px solid var(--mcp-line);background:var(--mcp-bg-1)}
2927
+ .dsh-mcp-detail-avatar.fallback{display:inline-flex;align-items:center;justify-content:center;font-size:18px;font-weight:650;color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover-accent)}
2928
+ .dsh-mcp-presence{display:flex;align-items:flex-start;gap:9px;padding:8px 10px;margin:7px 0 10px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-2)}
2929
+ .dsh-mcp-presence.compact{padding:6px 8px;margin:6px 0 8px}
2930
+ .dsh-mcp-presence-avatar{width:32px;height:32px;border-radius:50%;corner-shape:round;object-fit:cover;flex:none;border:1px solid var(--mcp-line)}
2931
+ .dsh-mcp-presence-avatar.fallback{display:inline-flex;align-items:center;justify-content:center;font-size:13px;font-weight:650;color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover-accent)}
2932
+ .dsh-mcp-presence-body{min-width:0;flex:1 1 auto;display:flex;flex-direction:column;gap:3px}
2933
+ .dsh-mcp-presence-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px;min-width:0}
2934
+ .dsh-mcp-presence-owner{font-weight:650;color:var(--dsw-alias-label-primary);text-decoration:none;overflow-wrap:anywhere}
2935
+ a.dsh-mcp-presence-owner:hover{color:var(--mcp-accent);text-decoration:underline}
2936
+ .dsh-mcp-presence-loading{font-size:10.5px;color:var(--mcp-mut)}
2937
+ .dsh-mcp-presence-meta{display:flex;align-items:center;gap:5px 10px;flex-wrap:wrap;font-size:10.5px;line-height:1.45;color:var(--mcp-mut)}
2938
+ .dsh-mcp-presence-inline{display:flex;align-items:center;gap:6px;font-size:10.5px;line-height:1.4;color:var(--mcp-mut);min-width:0;flex-wrap:wrap}
2939
+ .dsh-mcp-presence-inline-avatar{width:18px;height:18px;border-radius:50%;corner-shape:round;object-fit:cover;flex:none;border:1px solid var(--mcp-line)}
2940
+ .dsh-mcp-presence-inline-avatar.fallback{display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:650;color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover-accent)}
2941
+ .dsh-mcp-presence-inline-owner{color:var(--dsw-alias-label-secondary);font-weight:600;overflow-wrap:anywhere}
2942
+ .dsh-mcp-presence-inline-owner a{color:inherit;text-decoration:none}
2943
+ .dsh-mcp-presence-inline-owner a:hover{color:var(--mcp-accent);text-decoration:underline}
2944
+ .dsh-mcp-presence-inline-note{color:var(--mcp-mut);font-variant-numeric:tabular-nums}
2945
+ .dsh-mcp-presence-card{margin-top:-3px}
2946
+ .dsh-mcp-presence-detail{background:transparent;border:0;padding:0;margin:0 0 8px}
2947
+ .dsh-mcp-catalog-meta{display:flex;align-items:center;gap:6px 12px;flex-wrap:wrap;font-size:11px;color:var(--mcp-mut);padding:0 1px;margin:-4px 0 9px}
2948
+ .dsh-mcp-catalog-meta a{color:var(--mcp-accent);text-decoration:none}
2949
+ .dsh-mcp-catalog-meta a:hover{text-decoration:underline}
2950
+ .dsh-mcp-feedback{margin:10px 0 4px;padding:9px 10px;border:1px dashed var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-2)}
2951
+ .dsh-mcp-feedback-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin-bottom:7px;font-size:11.5px;color:var(--dsw-alias-label-primary)}
2952
+ .dsh-mcp-feedback-source{font-size:10.5px;color:var(--mcp-mut);font-weight:400}
2953
+ .dsh-mcp-feedback-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
2954
+ .dsh-mcp-feedback-actions .dsh-mcp-btn{font-size:11px;padding:5px 9px;text-decoration:none}
2955
+ .dsh-mcp-feedback-note{margin:7px 0 0;font-size:10.5px;line-height:1.5;color:var(--mcp-mut)}
2956
+ /* 与下方工具的间距交给 .dsh-mcp-wb 的 gap:再加 margin 会叠成 20px,显得空 */
2957
+ .dsh-mcp-pulse{flex:none;margin:0;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-2);padding:8px 10px}
2958
+ /* 首个内容块再上提 4px:上侧留白才与下侧(工具条贴分类条)接近 */
2959
+ .dsh-mcp-pulse + *{margin-top:-4px}
2960
+ .dsh-mcp-pulse.quiet{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:6px 10px;border-style:dashed;background:transparent}
2961
+ .dsh-mcp-pulse-title{font-size:11.5px;font-weight:700;color:var(--dsw-alias-label-primary);letter-spacing:.01em}
2962
+ .dsh-mcp-pulse-quiet{font-size:10.5px;color:var(--mcp-mut)}
2963
+ .dsh-mcp-pulse-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin-bottom:5px}
2964
+ .dsh-mcp-pulse-note{font-size:10.5px;color:var(--mcp-mut);flex:1 1 auto}
2965
+ .dsh-mcp-pulse-clear{border:0;background:transparent;color:var(--mcp-mut);font:inherit;font-size:10.5px;cursor:pointer;padding:2px 4px;border-radius:var(--mcp-r-sm)}
2966
+ .dsh-mcp-pulse-clear:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
2967
+ .dsh-mcp-pulse-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}
2968
+ .dsh-mcp-pulse-list>li{display:flex;align-items:center;gap:8px;min-width:0}
2969
+ .dsh-mcp-pulse-item{display:flex;align-items:center;gap:6px;min-width:0;flex:1 1 auto;border:0;background:transparent;font:inherit;font-size:11px;color:var(--dsw-alias-label-secondary);text-align:left;padding:3px 4px;border-radius:var(--mcp-r-sm);cursor:pointer}
2970
+ .dsh-mcp-pulse-item:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
2971
+ .dsh-mcp-pulse-kind{font-weight:600;white-space:nowrap}
2972
+ .dsh-mcp-pulse-kind.added{color:var(--dsw-alias-state-success-primary)}
2973
+ .dsh-mcp-pulse-kind.removed{color:var(--dsw-alias-state-warn-primary)}
2974
+ .dsh-mcp-pulse-kind.updated{color:var(--mcp-accent)}
2975
+ .dsh-mcp-pulse-sample{color:var(--mcp-mut);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
2976
+ .dsh-mcp-pulse-time{flex:none;font-size:10px;color:var(--mcp-mut);font-variant-numeric:tabular-nums}
2977
+
2681
2978
  /* 响应式覆盖必须位于全部基础规则之后:同特异性下后出现者胜出。 */
2682
2979
  @media (max-width:760px){.dsh-mcp-panel-overlay{padding:10px}.dsh-mcp-panel{max-width:none;width:100%;height:calc(100dvh - 20px);border-radius:var(--mcp-r-panel)}.dsh-mcp-panel .dsh-mcp-wrap{padding:12px}.dsh-mcp-head{align-items:flex-start}.dsh-mcp-body{flex-direction:column;overflow-y:auto;overflow-x:hidden}.dsh-mcp-side,.dsh-mcp-ws-body{width:auto;min-width:0;max-height:none;flex:none}.dsh-mcp-side:not(.embedded) .dsh-mcp-list{max-height:38vh}.dsh-mcp-section.local,.dsh-mcp-section.global{flex:none;min-height:0;max-height:none}.dsh-mcp-section.local .dsh-mcp-list{max-height:30vh}.dsh-mcp-global-list{max-height:30vh}.dsh-mcp-detail{padding:12px;flex:none}.dsh-mcp-d-head{flex-direction:column}.dsh-mcp-kv-edit{grid-template-columns:minmax(90px,35%) minmax(0,1fr) auto}.dsh-mcp-page{padding:10px 12px 20px}[data-platform=darwin] .dsh-mcp-page{padding-top:calc(var(--dsh-frame-top-clearance,0px) + 10px)}.dsh-mcp-page>.dsh-mcp-wrap{flex:0 0 auto;width:100%;min-height:0;height:auto}.dsh-mcp-skill-body{flex-direction:column;overflow:visible}.dsh-mcp-skill-side{width:auto;flex:none}.dsh-mcp-skill-list{max-height:34vh}.dsh-mcp-skill-detail{flex:none;max-height:none}.dsh-mcp-skill-d-md{max-height:38vh}}
2683
- @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}}
2980
+ @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}}
2684
2981
  `;
2685
2982
 
2686
2983
  // ── DOM 基础件:createElement 绑定、值遮蔽、通用小控件与图标表 ──────────────────────
@@ -2801,6 +3098,14 @@ const Badge = ({ cls, children, ...rest }) => h('span', { ...rest, className: 'd
2801
3098
  const Icon = ({ d, size, className }) =>
2802
3099
  h('svg', { className, width: size || 13, height: size || 13, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }, h('path', { d }));
2803
3100
 
3101
+ // 星标用实心小星;验证用「印章 + 白色对勾」。两者都不吃主题文字色,只在卡片里当状态图标。
3102
+ const StarIcon = ({ size }) =>
3103
+ h('svg', { width: size || 10, height: size || 10, viewBox: '0 0 16 16', 'aria-hidden': 'true', focusable: 'false' }, h('path', { fill: 'currentColor', d: 'M8 1.7l1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.5l-3.8 2 .7-4.3-3.1-3 4.3-.6z' }));
3104
+ const VerifiedSeal = ({ size }) =>
3105
+ h('svg', { width: size || 15, height: size || 15, viewBox: '0 0 16 16', 'aria-hidden': 'true', focusable: 'false' },
3106
+ h('path', { fill: 'currentColor', d: 'M8 .9l1.8 1.4 2.3-.3 1 2.1 2 1.2-.5 2.3.5 2.3-2 1.2-1 2.1-2.3-.3L8 15.1l-1.8-1.4-2.3.3-1-2.1-2-1.2.5-2.3-.5-2.3 2-1.2 1-2.1 2.3.3z' }),
3107
+ h('path', { fill: 'none', stroke: '#fff', strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M5.1 8.2l1.9 1.9 3.9-4' }));
3108
+
2804
3109
  const ICONS = {
2805
3110
  search: 'M7 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM13.5 13.5L11 11',
2806
3111
  plus: 'M8 3v10M3 8h10',
@@ -2831,6 +3136,10 @@ const ICONS = {
2831
3136
  // 用 .2 长的短线段而不是零长路径——零长路径在部分渲染器里画不出圆点)。
2832
3137
  layers: 'M8 2l6 3.3-6 3.4-6-3.4zM2.4 8.4L8 11.5l5.6-3.1M2.4 11.1L8 14.2l5.6-3.1',
2833
3138
  grip: 'M6 3.4v.2M10 3.4v.2M6 7.9v.2M10 7.9v.2M6 12.4v.2M10 12.4v.2',
3139
+ // down:向下箭头,README 弹层「跳到末尾」用
3140
+ down: 'M8 3v9.4M4 8.6l4 4 4-4',
3141
+ // info:信息圆圈,宿主组件 / 不可安装的说明图标
3142
+ info: 'M8 7.2v4.3M8 4.7v.2M8 14.2A6.2 6.2 0 1 0 8 1.8a6.2 6.2 0 0 0 0 12.4z',
2834
3143
  };
2835
3144
 
2836
3145
  // ── 常用 MCP 预设模板(主流管理器常见的“浏览即添加”能力)────────────
@@ -3342,18 +3651,6 @@ const Pager = ({ page, pageCount, total, onPage, disabled }) => {
3342
3651
  );
3343
3652
  };
3344
3653
 
3345
- // ── 内置目录的 auth 枚举(OPT-24 · 2.3)──────────────────────────────────────────
3346
- // 宿主 lib/mcp-config.js 的 builtinMcpCatalog 透出 auth(枚举 none/apiKey/oauth/
3347
- // oauth-or-pat)+ 有则挂 authNote。**这与市场连接器 tab 的 authMode 不是一回事**:
3348
- // authMode 是上游 connectors 清单里的自由文本,只能原样显示;auth 是枚举,可以映射
3349
- // 成人能读的标签。未知枚举值原样显示,绝不猜。
3350
- const AUTH_KIND_LABELS = { apiKey: 'API Key', oauth: 'OAuth', 'oauth-or-pat': 'OAuth / PAT' };
3351
- // 惰性求值:模块加载时宿主 locale 还没接进来,只有 none 需要翻译。
3352
- const authKindLabel = (kind) => (kind === 'none' ? t('免认证') : AUTH_KIND_LABELS[kind] || kind);
3353
- // 色语沿用 §1.3:oauth 系装完还得去浏览器授权,属「还需注意」→ 橙;apiKey 有既有
3354
- // 「需密钥」徽章兜住,这里只作信息性标注,用默认灰。
3355
- const authBadgeCls = (kind) => (kind === 'oauth' || kind === 'oauth-or-pat' ? 's-warn' : '');
3356
-
3357
3654
  // ── 服务器列表:按传输 / 状态筛选,选中与启停 ──────────────────────────────────
3358
3655
  const ServerList = ({ servers, total, selected, query, onQuery, onSelect, onToggle, busy, transportFilter, statusFilter, onTransportFilter, onStatusFilter, embedded, workspace, emptyHint, loading }) => {
3359
3656
  const count = total === undefined ? servers.length : total;
@@ -5553,7 +5850,7 @@ const CATEGORY_BLURB = {
5553
5850
  get skill() { return t('本地 Skill 清单:来源、是否启用、是否可被模型调用。'); },
5554
5851
  get expert() { return t('已召唤的专家:人设、捆绑技能、当前在场状态,可送还。'); },
5555
5852
  get tailor() { return t('工具使用账本:谁在被调用、谁在白缴上下文税。'); },
5556
- get wb() { return t('市场:连接器、内置 MCP、SkillHub 技能与专家召唤。'); },
5853
+ get wb() { return t('市场:连接器、SkillHub 技能与专家召唤。'); },
5557
5854
  };
5558
5855
  // §1.1 分类 hover 摘要。定位由父层算好(left),这里只负责内容与外观。
5559
5856
  // 只用 pointer-events:none 的只读浮层:不承载交互,因此不需要焦点陷阱,
@@ -6101,6 +6398,265 @@ const onSkillChange = (listener) => {
6101
6398
  return () => { skillChangeListeners.delete(listener); };
6102
6399
  };
6103
6400
 
6401
+ // ── Skill 头像:确定性生成 ───────────────────────────────────────────
6402
+ // 本地 skill 的数据里没有任何图标字段(宿主注册表只给能力位与来源目录),而市场页
6403
+ // 每张卡都有一个圆形头像位。为了同一观感,这里从 skill 名哈希出固定色相,画
6404
+ // 「渐变圆 + 首字母」的生成头像:零网络、同一名永远同色、不同 skill 之间色相可辨。
6405
+ // 只做渲染期纯计算,不发请求也不登记 avatarRegistry —— 那条链是给真图片探活用的。
6406
+ const skillAvatarHue = (name) => {
6407
+ const text = String(name || '');
6408
+ let hash = 5381;
6409
+ for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) >>> 0;
6410
+ return hash % 360;
6411
+ };
6412
+ const SkillAvatar = ({ name, size, title }) => {
6413
+ const hue = skillAvatarHue(name);
6414
+ const letter = String(name || '?').slice(0, 1).toUpperCase() || '?';
6415
+ return h('span', {
6416
+ className: `dsh-mcp-skill-avatar${size ? ` ${size}` : ''}`,
6417
+ style: {
6418
+ background: `linear-gradient(135deg, hsl(${hue} 64% 56%), hsl(${(hue + 38) % 360} 64% 42%))`,
6419
+ },
6420
+ title,
6421
+ 'aria-hidden': true,
6422
+ }, letter);
6423
+ };
6424
+
6425
+ // ── Skill 真图标:目录名反推 + SkillHub 精确补全 ────────────────────────────
6426
+ // 市场卡有真图标,本地 skill 的行数据里没有。真图标的来源有两层,按可信度取用:
6427
+ // ① wbexpert 捆绑技能:安装目录名 `wbexpert__<plugin>__<skill>` 里的 plugin 能在
6428
+ // 专家目录里查到头像(与已召唤页同源)。listSkills 行带 path,这里从 path 尾段解析。
6429
+ // ② 其余技能(SkillHub / clawhub 装的):真图标只在 SkillHub 目录里有——按名字做一次
6430
+ // keyword 查询,**slug 精确相等**才采纳(绝不按模糊命中张冠李戴)。纪律与头像探活
6431
+ // 同一套:模块级缓存(同一名本次会话只查一次)、失败粘住('')、同时在途 ≤3、
6432
+ // 按登记顺序排队。查询是宿主侧 keyword GET(自带 15s 超时),47 个技能 ≈ 市场翻两页。
6433
+ // 两层都没有 → SkillAvatar 生成头像兜底,永不空圈。
6434
+ const SKILL_DIR_EXPERT_RE = /^wbexpert__([a-z0-9_-]+?)__/i;
6435
+ /** path 尾段目录名解析出真图标;解析不出或目录里查不到该 plugin → ''(交给下一层)。 */
6436
+ const skillIconFromDir = (path, expertByPlugin) => {
6437
+ const base = String(path || '').trim().replace(/[\\/]+$/, '');
6438
+ const dir = base.split(/[\\/]/).pop() || '';
6439
+ const match = dir.match(SKILL_DIR_EXPERT_RE);
6440
+ return (match && expertByPlugin.get(match[1])) || '';
6441
+ };
6442
+
6443
+ const skillIconCache = new Map(); // name → url('' = 查过、没有)
6444
+ const skillIconQueued = new Set(); // 已入队(含在途)
6445
+ const skillIconQueue = []; // FIFO,与头像闸门同一口径
6446
+ let skillIconInflight = 0;
6447
+ const skillIconWaiters = new Set(); // (name) => void:结果落地后通知组件重绘
6448
+ const SKILL_ICON_INFLIGHT_MAX = 3;
6449
+
6450
+ const skillIconFinish = (name, url) => {
6451
+ skillIconCache.set(name, String(url || ''));
6452
+ skillIconQueued.delete(name);
6453
+ for (const notify of [...skillIconWaiters]) {
6454
+ try { notify(name); } catch { /* 单个订阅者失败不影响其它 */ }
6455
+ }
6456
+ };
6457
+
6458
+ const skillIconDrain = (call) => {
6459
+ while (skillIconInflight < SKILL_ICON_INFLIGHT_MAX && skillIconQueue.length) {
6460
+ const name = skillIconQueue.shift();
6461
+ skillIconInflight += 1;
6462
+ call('wbSkillCatalog', { keyword: name, pageSize: 5 })
6463
+ .then((res) => {
6464
+ const rows = res && res.ok && Array.isArray(res.skills) ? res.skills : [];
6465
+ const hit = rows.find((row) => row && (row.slug === name || row.canonical === name) && row.iconUrl);
6466
+ skillIconFinish(name, hit ? hit.iconUrl : '');
6467
+ })
6468
+ .catch(() => skillIconFinish(name, ''))
6469
+ .finally(() => {
6470
+ skillIconInflight -= 1;
6471
+ skillIconDrain(call);
6472
+ });
6473
+ }
6474
+ };
6475
+
6476
+ const skillIconEnqueue = (call, name) => {
6477
+ const key = String(name || '').trim();
6478
+ if (!key || skillIconCache.has(key) || skillIconQueued.has(key)) return;
6479
+ skillIconQueued.add(key);
6480
+ skillIconQueue.push(key);
6481
+ skillIconDrain(call);
6482
+ };
6483
+
6484
+ /** 渲染期取真图标:目录反推优先(分组可辨),回落 SkillHub 补全缓存,都没有 → ''。 */
6485
+ const skillIconFor = (s, expertByPlugin) =>
6486
+ skillIconFromDir(s && s.path, expertByPlugin) || skillIconCache.get(String((s && s.name) || '').trim()) || '';
6487
+
6488
+ // 组件侧接线:订阅结果 + 把还没有真图的技能名入队。version 只是重绘信号,
6489
+ // 渲染一律以 skillIconCache 的现值为准(迟到的成功也会被下一帧采纳)。
6490
+ // items = [{ name, path }]:path 在这里就地判定,不经模块级中转(同名技能可能来自
6491
+ // 不同专家,按名字反查 path 会张冠李戴)。
6492
+ const useSkillIcons = (call, items, expertByPlugin) => {
6493
+ const [, setVersion] = useState(0);
6494
+ useEffect(() => {
6495
+ const notify = () => setVersion((v) => v + 1);
6496
+ skillIconWaiters.add(notify);
6497
+ return () => { skillIconWaiters.delete(notify); };
6498
+ }, []);
6499
+ const list = (Array.isArray(items) ? items : [])
6500
+ .map((it) => `${(it && it.name) || ''}\u0001${(it && it.path) || ''}`)
6501
+ .join('\u0000');
6502
+ useEffect(() => {
6503
+ for (const entry of list.split('\u0000')) {
6504
+ if (!entry) continue;
6505
+ const sep = entry.indexOf('\u0001');
6506
+ const name = entry.slice(0, sep);
6507
+ const path = entry.slice(sep + 1);
6508
+ // 目录反推已经拿到的不再查询(wbexpert 捆绑技能不该为它多花一次请求)。
6509
+ if (skillIconFromDir(path, expertByPlugin)) continue;
6510
+ skillIconEnqueue(call, name);
6511
+ }
6512
+ // list 由 items 逐项拼接而来,items 在渲染期重建,这里只关心集合内容变化。
6513
+ }, [call, list, expertByPlugin]);
6514
+ };
6515
+
6516
+ // ── 插件卡片下载量:npm 近 30 天统计,按当前页批量懒取 ──────────────────────
6517
+ // 技能卡的 ↓ 是 SkillHub 目录自带的;社区插件目录(slim.json 的 11 个字段)里没有下载量,
6518
+ // 只能在卡片渲染时按 npm 包名去公开统计接口补。取数在宿主侧(lib/npm-downloads.js:7 天
6519
+ // 落盘缓存、单请求 ≤12 包、429/5xx 冷却 10 分钟),这一层只管「渲染期不联网 + 结果落地后
6520
+ // 重绘」,纪律与 skillIcon* 同一套:
6521
+ // · 模块级缓存,同一个包整个会话只问一次;值为 null = 问过了但没有(下架 / 未被统计 /
6522
+ // 接口挂了)→ 卡片上那枚「↓」干脆不出现。宁缺不假:绝不显示 0 冒充「没人下载」。
6523
+ // · 当前页 50 张卡按卡片顺序入队,同一提交内的入队合并成批(≤12 包/请求)、**在途只 1 批**,
6524
+ // 所以翻一页最多 5 个请求,首屏靠前的先出数;翻页或换筛选只补新出现的包。
6525
+ // · 口径与技能卡不同(那是累计、这是近 30 天),所以 title 里写明窗口。
6526
+ const npmDownloadsCache = new Map(); // pkg → number | null(null = 问过、没有)
6527
+ const npmDownloadsQueued = new Set(); // 已入队(含在途与已定值)
6528
+ const npmDownloadsWaiters = new Set(); // () => void:结果落地后通知组件重绘
6529
+ let npmDownloadsBuffer = []; // 待发送包名,FIFO(= 卡片顺序)
6530
+ let npmDownloadsTimer = null;
6531
+ let npmDownloadsInflight = 0;
6532
+ const NPM_DOWNLOADS_BATCH = 12; // 与宿主侧 MAX_PACKAGES 同口径
6533
+ // 在途必须 ≤1:宿主侧那道节流(并发 2 + 起手间隔 220ms,实测出来的数)是**全局**的,
6534
+ // 面板同时压两个批次就等于把速率翻倍 —— 实测 12 个请求并发打过去 429 占一半以上。
6535
+ const NPM_DOWNLOADS_INFLIGHT_MAX = 1;
6536
+
6537
+ const npmDownloadsSettle = (values, retryable = []) => {
6538
+ for (const pkg of Object.keys(values || {})) {
6539
+ npmDownloadsCache.set(pkg, values[pkg]);
6540
+ npmDownloadsQueued.add(pkg);
6541
+ }
6542
+ // 限流 / 网络抖动 ≠「这个包没有下载量」:把它从已入队集合放出来,下次翻页、换筛选或
6543
+ // 重载面板会再试一次(真确认没有的 missing 才粘住,不反复白打公开接口)。
6544
+ for (const pkg of retryable) npmDownloadsQueued.delete(pkg);
6545
+ for (const notify of [...npmDownloadsWaiters]) {
6546
+ try { notify(); } catch { /* 单个订阅者失败不影响其它 */ }
6547
+ }
6548
+ };
6549
+
6550
+ const npmDownloadsFlush = (call) => {
6551
+ while (npmDownloadsInflight < NPM_DOWNLOADS_INFLIGHT_MAX && npmDownloadsBuffer.length) {
6552
+ const batch = npmDownloadsBuffer.splice(0, NPM_DOWNLOADS_BATCH);
6553
+ npmDownloadsInflight += 1;
6554
+ call('wbNpmDownloads', { packages: batch })
6555
+ .then((res) => {
6556
+ const rows = res && res.packages ? res.packages : {};
6557
+ const values = {};
6558
+ const retryable = [];
6559
+ for (const pkg of batch) {
6560
+ const row = rows[pkg];
6561
+ const n = row && row.ok ? Number(row.downloads) : Number.NaN;
6562
+ if (Number.isFinite(n)) { values[pkg] = n; continue; }
6563
+ // 分两种「没有」:missing = 上游确认这个包没有统计(下架/未收录),粘住不再问;
6564
+ // 其它(限流、超时、接口挂了)本轮画不出来,但允许下次翻页/重载再试一次。
6565
+ values[pkg] = null;
6566
+ if (!(row && row.missing)) retryable.push(pkg);
6567
+ }
6568
+ npmDownloadsSettle(values, retryable);
6569
+ })
6570
+ .catch(() => npmDownloadsSettle(Object.fromEntries(batch.map((pkg) => [pkg, null])), batch))
6571
+ .finally(() => {
6572
+ npmDownloadsInflight -= 1;
6573
+ npmDownloadsFlush(call);
6574
+ });
6575
+ }
6576
+ };
6577
+
6578
+ const npmDownloadsEnqueue = (call, packages) => {
6579
+ let added = 0;
6580
+ for (const raw of Array.isArray(packages) ? packages : []) {
6581
+ const pkg = String(raw || '').trim();
6582
+ if (!pkg || npmDownloadsQueued.has(pkg)) continue;
6583
+ npmDownloadsQueued.add(pkg);
6584
+ npmDownloadsBuffer.push(pkg);
6585
+ added += 1;
6586
+ }
6587
+ if (!added || npmDownloadsTimer !== null) return;
6588
+ // 宏任务合并:一页 50 个 effect 各自入队,到这儿并成批再发,50 个请求就成 5 个。
6589
+ npmDownloadsTimer = setTimeout(() => { npmDownloadsTimer = null; npmDownloadsFlush(call); }, 0);
6590
+ };
6591
+
6592
+ /** 渲染期取下载量:还没问到 / 问过但没有 → null(这一枚不画)。 */
6593
+ const npmDownloadsFor = (pkg) => {
6594
+ const value = npmDownloadsCache.get(String(pkg || '').trim());
6595
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
6596
+ };
6597
+
6598
+ // 组件侧接线:订阅结果 + 把当前页还没有值的包入队。与 useSkillIcons 一样,返回值只是重绘
6599
+ // 信号,渲染一律以 npmDownloadsCache 现值为准(迟到的成功会被下一帧采纳)。
6600
+ const useNpmDownloads = (call, items) => {
6601
+ const [, setVersion] = useState(0);
6602
+ useEffect(() => {
6603
+ const notify = () => setVersion((v) => v + 1);
6604
+ npmDownloadsWaiters.add(notify);
6605
+ return () => { npmDownloadsWaiters.delete(notify); };
6606
+ }, []);
6607
+ const list = (Array.isArray(items) ? items : [])
6608
+ .map((it) => String((it && it.npm) || '').trim())
6609
+ .join('\u0000');
6610
+ useEffect(() => {
6611
+ npmDownloadsEnqueue(call, list.split('\u0000'));
6612
+ // list 是当前页包名拼出来的字符串:只有集合内容变了才重新入队,
6613
+ // 同一页反复重绘(滚动/装完一个)不会多打一次请求。
6614
+ }, [call, list]);
6615
+ };
6616
+
6617
+ // ── 插件卡片底栏那一行事实 ────────────────────────────────────────────────
6618
+ // 三枚(↓近 30 天下载 / ★星数 / 核验日期)在 250px 列下可用宽只有 153–174px,英文又比
6619
+ // 中文长,装不下时以前靠 overflow:hidden 裁掉尾字,读起来像界面坏了。这里改成「整枚收起」:
6620
+ // 离流探针 .dsh-mcp-pfacts-probe 与真行同构、量的是「三枚全画要多宽」,真行(flex:1 1 0)
6621
+ // 的宽度恒等于可用宽,两者一比就知道放不放得下——放不下收起日期那枚(信息量最低、字最长),
6622
+ // 列变宽了再放回来。结论只由「探针宽 vs 行宽」两个几何量决定,不依赖上一次的结论,所以
6623
+ // 不会「收起→行变窄→又放下→又溢出」地抖;面板切走(宽度 0)时直接不下结论。
6624
+ const PluginCardFacts = ({ downloadsText, downloadsTitle, starText, starTitle, dateText, dateTitle }) => {
6625
+ const rowRef = useRef(null);
6626
+ const probeRef = useRef(null);
6627
+ const [dropDate, setDropDate] = useState(false);
6628
+ const chips = (withDate) => {
6629
+ const out = [];
6630
+ if (downloadsText) out.push(h('span', { key: 'dl', className: 'dl', title: downloadsTitle }, '↓' + downloadsText));
6631
+ if (starText) out.push(h('span', { key: 'star', className: 'star', title: starTitle }, h(StarIcon, { size: 10 }), starText));
6632
+ if (withDate && dateText) out.push(h('span', { key: 'when', className: 'when', title: dateTitle }, dateText));
6633
+ return out;
6634
+ };
6635
+ useEffect(() => {
6636
+ const measure = () => {
6637
+ const row = rowRef.current;
6638
+ const probe = probeRef.current;
6639
+ if (!row || !probe) return;
6640
+ const have = row.getBoundingClientRect().width;
6641
+ if (have < 8) return;
6642
+ setDropDate(probe.getBoundingClientRect().width > have + 1);
6643
+ };
6644
+ measure();
6645
+ if (typeof ResizeObserver !== 'function') return undefined;
6646
+ let observer = null;
6647
+ try {
6648
+ observer = new ResizeObserver(measure);
6649
+ if (rowRef.current) observer.observe(rowRef.current);
6650
+ } catch {
6651
+ return undefined;
6652
+ }
6653
+ return () => { if (observer) observer.disconnect(); };
6654
+ }, [downloadsText, starText, dateText]);
6655
+ return h('div', { ref: rowRef, className: 'dsh-mcp-pfacts' },
6656
+ chips(!dropDate),
6657
+ dateText ? h('div', { ref: probeRef, className: 'dsh-mcp-pfacts-probe', 'aria-hidden': true }, chips(true)) : null);
6658
+ };
6659
+
6104
6660
  // ── Skill 看板:列表 + 详情 ──────────────────────────────────────────
6105
6661
  // 数据来自宿主新增的 listSkills / skillDetail(走同一个 mcpManager Remote 命名空间,
6106
6662
  // 复用既有的 call 信封 {ok, ...}),不引入第二条 Remote 通道。
@@ -6139,6 +6695,8 @@ const SkillBoard = ({ call, scope, timer }) => {
6139
6695
  const detailSeq = useRef(0);
6140
6696
  const toastCancel = useRef(null);
6141
6697
  const noticeCancel = useRef(null);
6698
+ // 工具条搜索框:清空按钮点下去自己随即卸载,焦点会掉到 body——clear 后把焦点送回 input。
6699
+ const searchRef = useRef(null);
6142
6700
  // 作用域切换检测(skillpick 只在真换作用域时清)+ skillpick 恢复只做一次的哨兵。
6143
6701
  const mountedScope = useRef(null);
6144
6702
  const seededPick = useRef(false);
@@ -6200,6 +6758,22 @@ const SkillBoard = ({ call, scope, timer }) => {
6200
6758
  // 不挂着时不用补——上面的挂载 effect 每次都会 load(false),切回本页天然是新数据。
6201
6759
  useEffect(() => onSkillChange(() => { load(true); }), [load]);
6202
6760
 
6761
+ // 真图标数据源①:专家目录(模块级缓存,与市场/已召唤页同一条)——wbexpert 捆绑技能
6762
+ // 用安装目录名里的 plugin 反查专家头像。拉不到静默降级:这部分技能走生成头像。
6763
+ const [expertByPlugin, setExpertByPlugin] = useState(() => new Map());
6764
+ useEffect(() => {
6765
+ let alive = true;
6766
+ loadExpertCatalogShared(call).then((data) => {
6767
+ if (!alive) return;
6768
+ const map = new Map();
6769
+ for (const item of (data.experts || [])) {
6770
+ if (item && item.plugin) map.set(item.plugin, item.avatarUrl || '');
6771
+ }
6772
+ setExpertByPlugin(map);
6773
+ }).catch(() => {});
6774
+ return () => { alive = false; };
6775
+ }, [call]);
6776
+
6203
6777
  const queryKey = (query || '').toLowerCase();
6204
6778
  // 分类判定只依赖 name+description、与筛选无关 → 一次 memo 全量映射;
6205
6779
  // 分类轨道计数固定全量(决策 9),不随来源/权限/搜索联动。
@@ -6255,6 +6829,10 @@ const SkillBoard = ({ call, scope, timer }) => {
6255
6829
  }
6256
6830
  return map;
6257
6831
  }, [skills]);
6832
+
6833
+ // 真图标补全(数据源②):把还没有①目录反推的技能名交给 SkillHub 精确匹配。
6834
+ // 必须无条件调用(hook 规矩);入队侧自行跳过已有真图与已缓存/在途的名字。
6835
+ useSkillIcons(call, filtered, expertByPlugin);
6258
6836
 
6259
6837
 
6260
6838
  const openDetail = useCallback(async (name) => {
@@ -6443,9 +7021,20 @@ const SkillBoard = ({ call, scope, timer }) => {
6443
7021
  h('div', { className: 'dsh-mcp-skill-content' },
6444
7022
  // 顶部工具条:搜索 + 网格/列表分段控件(决策 5:不加 Hero,直奔工作区)。
6445
7023
  h('div', { className: 'dsh-mcp-skill-toolbar' },
6446
- h('div', { className: 'dsh-mcp-skill-search' },
7024
+ h('div', { className: 'dsh-mcp-skill-search' + (query ? ' querying' : '') },
6447
7025
  h(Icon, { d: ICONS.search }),
6448
- h('input', { placeholder: t('搜索 skill…'), value: query, onChange: (e) => setQuery(e.target.value), 'aria-label': t('搜索 skill') }),
7026
+ h('input', {
7027
+ ref: searchRef,
7028
+ placeholder: t('搜索 skill…'), value: query,
7029
+ onChange: (e) => setQuery(e.target.value), 'aria-label': t('搜索 skill'),
7030
+ // Escape 清词时吃掉事件(别让上层把弹窗/浮层一起关了);没有词时放行。
7031
+ onKeyDown: (e) => { if (e.key === 'Escape' && query) { e.stopPropagation(); setQuery(''); } },
7032
+ }),
7033
+ query ? h('button', {
7034
+ type: 'button', className: 'clear',
7035
+ title: t('清空搜索'), 'aria-label': t('清空搜索'),
7036
+ onClick: () => { setQuery(''); searchRef.current?.focus(); },
7037
+ }, h(Icon, { d: ICONS.x, size: 10 })) : null,
6449
7038
  ),
6450
7039
  h('div', { className: 'dsh-mcp-skill-seg', role: 'group', 'aria-label': t('视图切换') },
6451
7040
  h('button', {
@@ -6548,6 +7137,11 @@ const SkillBoard = ({ call, scope, timer }) => {
6548
7137
  className: 'dsh-mcp-skill' + (selected === s.name ? ' on' : ''),
6549
7138
  onClick: () => openDetail(s.name),
6550
7139
  },
7140
+ // 列表行行首也给头像:有真图用真图(与市场同源),没有用生成头像,
7141
+ // 两种视图观感一致。
7142
+ skillIconFor(s, expertByPlugin)
7143
+ ? h(Avatar, { url: skillIconFor(s, expertByPlugin), name: s.name, className: 'dsh-mcp-skill-avatar-img sm' })
7144
+ : h(SkillAvatar, { name: s.name, size: 'sm' }),
6551
7145
  h('div', { className: 'dsh-mcp-skill-main' },
6552
7146
  h('div', { className: 'dsh-mcp-skill-name' }, s.name),
6553
7147
  h('div', { className: 'dsh-mcp-skill-sub' },
@@ -6580,7 +7174,12 @@ const SkillBoard = ({ call, scope, timer }) => {
6580
7174
  },
6581
7175
  h('div', { className: 'dsh-mcp-skill-card-inner' },
6582
7176
  h('div', { className: 'dsh-mcp-skill-card-top' },
6583
- h('span', { className: 'dsh-mcp-skill-cat-tile', style: { background: sCat.color }, title: sCat.name }),
7177
+ // 市场同款头像位:wbexpert 捆绑技能反推专家真头像、其余按 SkillHub
7178
+ // slug 精确匹配补真图标(skillIconFor),取不到真图才用生成头像。
7179
+ // 分类语义保留:title 带分类名,foot 里有分类文字。
7180
+ skillIconFor(s, expertByPlugin)
7181
+ ? h(Avatar, { url: skillIconFor(s, expertByPlugin), name: s.name, className: 'dsh-mcp-skill-avatar-img', title: sCat.name })
7182
+ : h(SkillAvatar, { name: s.name, title: sCat.name }),
6584
7183
  h('span', { className: 'dsh-mcp-skill-card-name' }, s.name),
6585
7184
  ),
6586
7185
  s.description
@@ -6635,15 +7234,21 @@ const SkillBoard = ({ call, scope, timer }) => {
6635
7234
  ? h('div', { className: 'dsh-mcp-skill-empty' }, t('从左侧选择一个 skill 查看详情'))
6636
7235
  : null,
6637
7236
  summary ? h('div', { className: 'dsh-mcp-skill-d-head' },
6638
- h('div', null,
6639
- h('h3', { className: 'dsh-mcp-skill-d-title' }, summary.name),
6640
- h('div', { className: 'dsh-mcp-skill-d-badges' },
6641
- summaryCat ? h(Badge, { cls: 't-stdio' }, summaryCat.name) : null,
6642
- summary.modelInvocable ? h(Badge, { cls: 't-http' }, t('模型可调用')) : null,
6643
- summary.userInvocable ? h(Badge, { cls: 't-stdio' }, t('用户可调用')) : null,
7237
+ // 详情头与市场详情弹层同一构图:头像 + 标题块,打开目录按钮保持在最右。
7238
+ h('div', { className: 'dsh-mcp-skill-d-id' },
7239
+ skillIconFor(summary, expertByPlugin)
7240
+ ? h(Avatar, { url: skillIconFor(summary, expertByPlugin), name: summary.name, className: 'dsh-mcp-skill-avatar-img lg' })
7241
+ : h(SkillAvatar, { name: summary.name, size: 'lg' }),
7242
+ h('div', null,
7243
+ h('h3', { className: 'dsh-mcp-skill-d-title' }, summary.name),
7244
+ h('div', { className: 'dsh-mcp-skill-d-badges' },
7245
+ summaryCat ? h(Badge, { cls: 't-stdio' }, summaryCat.name) : null,
7246
+ summary.modelInvocable ? h(Badge, { cls: 't-http' }, t('模型可调用')) : null,
7247
+ summary.userInvocable ? h(Badge, { cls: 't-stdio' }, t('用户可调用')) : null,
7248
+ ),
7249
+ h('p', { className: 'dsh-mcp-skill-d-src' },
7250
+ skillSourceLabel(summary.source) + (summary.provider ? ' · ' + summary.provider : '')),
6644
7251
  ),
6645
- h('p', { className: 'dsh-mcp-skill-d-src' },
6646
- skillSourceLabel(summary.source) + (summary.provider ? ' · ' + summary.provider : '')),
6647
7252
  ),
6648
7253
  summary.path ? h('button', {
6649
7254
  type: 'button',
@@ -6762,7 +7367,9 @@ const WB_CONNECTOR_SORTS = [
6762
7367
  { key: 'name', get label() { return t('名称 A→Z'); } },
6763
7368
  ];
6764
7369
  // T06/Awesome:DSH 社区目录排序(全量本地排序,不受分页影响)。
6765
- // upstream 自带 stars 与 lastVerified,无下载量/评分字段,不臆造。
7370
+ // upstream 自带 stars 与 lastVerified,无评分字段,不臆造。
7371
+ // 下载量从 2.3.x 起有了(npm 公开统计),但它**只给当前页按可见卡片懒取**、且是
7372
+ // 近 30 天窗口:拿一份不完整、随页变化的数据做全量排序会排出个假名次,所以不加这档。
6766
7373
  const AWESOME_SORTS = [
6767
7374
  { key: 'stars', get label() { return t('最多星标'); } },
6768
7375
  { key: 'recent', get label() { return t('最新收录'); } },
@@ -6872,15 +7479,18 @@ const ExpertInstalledBoard = ({ call }) => {
6872
7479
  return () => { alive = false; off(); stopPoll(); };
6873
7480
  }, [call]);
6874
7481
 
6875
- // §4.1 召唤位推荐:只在架上为空时取目录(有专家时不需要,省一次请求)。
7482
+ // §4.1 召唤位推荐 + 头像补全:无条件取共享目录(模块级 10 分钟缓存,与市场页同一条,
7483
+ // 市场开过就是零网络)。为什么不能只在架上为空时取:预设行的 avatarUrl 依赖**宿主侧**
7484
+ // expert-market.js 的新旧——宿主进程没重启时 wbExpertInstalled 不带 avatarUrl,卡面就
7485
+ // 永远是字母。目录是稳定的旧 RPC,卡片头像一律先查目录(plugin → avatarUrl)再回落行字段。
6876
7486
  useEffect(() => {
6877
- if (loading || presets.length) return undefined;
7487
+ if (loading) return undefined;
6878
7488
  let alive = true;
6879
7489
  loadExpertCatalogShared(call).then((data) => {
6880
7490
  if (alive) setCatalog(Array.isArray(data.experts) ? data.experts : []);
6881
7491
  }).catch(() => {});
6882
7492
  return () => { alive = false; };
6883
- }, [call, loading, presets.length]);
7493
+ }, [call, loading]);
6884
7494
 
6885
7495
  // 召唤(推荐位点击):与市场页同一条写链,成功后刷新本页 + 广播给会话头徽章。
6886
7496
  const summon = async (expert) => {
@@ -6942,6 +7552,14 @@ const ExpertInstalledBoard = ({ call }) => {
6942
7552
  // 推荐位:目录里还没召唤的,按目录序取前 6 —— 不排序、不评分,避免自造推荐逻辑。
6943
7553
  const summonedPlugins = new Set(presets.map((row) => row.plugin));
6944
7554
  const recommend = catalog.filter((item) => item && item.plugin && !summonedPlugins.has(item.plugin)).slice(0, 6);
7555
+ // plugin → 头像:预设行字段优先?不——目录优先。预设行的 avatarUrl 依赖宿主侧模块
7556
+ // 新旧(宿主没重启就不带),目录走的是稳定旧 RPC,两处都有时值也相同,取目录只为
7557
+ // 在行字段缺席时仍能出图。查不到(目录没落盘)回落行字段,再回落空 → Avatar 出字母。
7558
+ const catalogAvatar = new Map();
7559
+ for (const item of catalog) {
7560
+ if (item && item.plugin && item.avatarUrl) catalogAvatar.set(item.plugin, item.avatarUrl);
7561
+ }
7562
+ const presetAvatar = (plugin) => catalogAvatar.get(plugin) || '';
6945
7563
  return h('div', null,
6946
7564
  note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
6947
7565
  busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
@@ -6965,6 +7583,12 @@ const ExpertInstalledBoard = ({ call }) => {
6965
7583
  const preset = presets.find((p) => p.plugin === plugin);
6966
7584
  const bound = skillsByPlugin[plugin] || [];
6967
7585
  return h('div', { key: plugin, className: 'dsh-mcp-onsite-body' },
7586
+ // 在场行也给头像:目录优先(稳定旧 RPC),回落预设行字段,与下方卡片一一对应。
7587
+ h(Avatar, {
7588
+ url: preset ? (presetAvatar(plugin) || preset.avatarUrl) : '',
7589
+ name: (rows[0] && rows[0].name) || (preset && preset.nameZh) || plugin,
7590
+ className: 'dsh-mcp-onsite-avatar',
7591
+ }),
6968
7592
  h('div', { className: 'dsh-mcp-onsite-name' }, (rows[0] && rows[0].name) || (preset && preset.presetId) || plugin),
6969
7593
  preset && preset.presetId ? h('div', { className: 'dsh-mcp-onsite-role' }, preset.presetId) : null,
6970
7594
  bound.length
@@ -7002,11 +7626,20 @@ const ExpertInstalledBoard = ({ call }) => {
7002
7626
  const boundSkills = skillsByPlugin[row.plugin] || [];
7003
7627
  const activeList = activeByPlugin.get(row.plugin) || [];
7004
7628
  const confirming = confirmDismiss === row.plugin;
7629
+ // 显示名与召唤菜单同一投影(跟宿主语言):目录缓存名 → 预设行自带名 → plugin 标识名。
7630
+ const presetLabel = usesEnglish()
7631
+ ? (row.nameEn || row.nameZh || row.plugin)
7632
+ : (row.nameZh || row.nameEn || row.plugin);
7005
7633
  return h('div', { key: row.presetId, className: 'dsh-mcp-card wb installed' },
7006
7634
  h('div', { className: 'dsh-mcp-card-top' },
7007
- h('div', null,
7008
- h('div', { className: 'dsh-mcp-card-name' }, row.plugin),
7009
- h('div', { className: 'dsh-mcp-card-vendor' }, row.presetId),
7635
+ // 市场专家卡同款头像位:目录优先(稳定旧 RPC,宿主没重启也能出图),
7636
+ // 回落 wbExpertInstalled 带出的行字段;取不到图时 Avatar 自动回落首字母。
7637
+ h('div', { className: 'dsh-mcp-card-person' },
7638
+ h(Avatar, { url: presetAvatar(row.plugin) || row.avatarUrl, name: presetLabel, className: 'dsh-mcp-card-avatar' }),
7639
+ h('div', null,
7640
+ h('div', { className: 'dsh-mcp-card-name' }, presetLabel),
7641
+ h('div', { className: 'dsh-mcp-card-vendor' }, row.presetId),
7642
+ ),
7010
7643
  ),
7011
7644
  activeList.length
7012
7645
  ? h(Badge, { cls: 's-on' }, t('在场 · {0} 会话', [activeList.length]))
@@ -7044,18 +7677,24 @@ const ExpertInstalledBoard = ({ call }) => {
7044
7677
  )
7045
7678
  : null,
7046
7679
  // §4.1 空白区:推荐位(原页面 80% 是空白,且没有任何「下一步」)。
7680
+ // 排版走 .dsh-mcp-wb-reco:提示行与上方已召唤网格、下方推荐卡网格之间都要留白,
7681
+ // 10.5px 的小字提示紧贴卡片会糊成一片(间距见 CSS 侧同名规则)。
7047
7682
  !loading && recommend.length
7048
- ? h('div', null,
7049
- h('div', { className: 'dsh-mcp-item-sub', style: { marginTop: '14px' } },
7683
+ ? h('div', { className: 'dsh-mcp-wb-reco' },
7684
+ h('div', { className: 'dsh-mcp-item-sub' },
7050
7685
  h('span', null, presets.length ? t('再看看这些(目录前 {0} 位未召唤)', [recommend.length]) : t('可以从这几位开始(目录前 {0} 位)', [recommend.length])),
7051
7686
  h('a', { href: '#', onClick: (e) => { e.preventDefault(); openBoardCategory('wb'); } }, t('去市场看全部')),
7052
7687
  ),
7053
7688
  h('div', { className: 'dsh-mcp-wb-grid' },
7054
7689
  recommend.map((item) => h('div', { key: item.plugin, className: 'dsh-mcp-card wb' },
7055
7690
  h('div', { className: 'dsh-mcp-card-top' },
7056
- h('div', null,
7057
- h('div', { className: 'dsh-mcp-card-name' }, item.name || item.plugin),
7058
- h('div', { className: 'dsh-mcp-card-vendor' }, item.profession || item.category || item.plugin),
7691
+ // 推荐位与市场卡同构(card-person + Avatar),目录行自带 avatarUrl。
7692
+ h('div', { className: 'dsh-mcp-card-person' },
7693
+ h(Avatar, { url: item.avatarUrl, name: item.name || item.plugin, className: 'dsh-mcp-card-avatar' }),
7694
+ h('div', null,
7695
+ h('div', { className: 'dsh-mcp-card-name' }, item.name || item.plugin),
7696
+ h('div', { className: 'dsh-mcp-card-vendor' }, item.profession || item.category || item.plugin),
7697
+ ),
7059
7698
  ),
7060
7699
  ),
7061
7700
  item.description ? h('div', { className: 'dsh-mcp-card-summary' }, item.description) : null,
@@ -7070,7 +7709,11 @@ const ExpertInstalledBoard = ({ call }) => {
7070
7709
  detail
7071
7710
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setDetail(null) },
7072
7711
  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); } } },
7073
- h('h3', null, detail.name ? t('{0}({1})', [detail.name, detail.profession || '']) : detail.plugin),
7712
+ // 详情头与市场专家详情同一构图:头像 + 标题行(wbExpertDetail 展开 catalog 行自带 avatarUrl)。
7713
+ h('div', { className: 'dsh-mcp-wb-modal-head' },
7714
+ h(Avatar, { url: detail.avatarUrl, name: detail.name || detail.plugin, className: 'dsh-mcp-detail-avatar' }),
7715
+ h('h3', null, detail.name ? t('{0}({1})', [detail.name, detail.profession || '']) : detail.plugin),
7716
+ ),
7074
7717
  h('div', null,
7075
7718
  detail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, detail.detailError) : null,
7076
7719
  detail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, detail.description) : null,
@@ -7101,6 +7744,15 @@ const ExpertInstalledBoard = ({ call }) => {
7101
7744
  : null,
7102
7745
  );
7103
7746
  };
7747
+ // 装备清单「去市场」需要把专家搜索词带进专家页;ExpertMarket 的签名被回归测试钉死,
7748
+ // 所以用一个一次性模块级种子,不新增 prop。
7749
+ let expertMarketSeedQuery = '';
7750
+ const seedExpertMarketQuery = (value) => { expertMarketSeedQuery = String(value || '').trim(); };
7751
+ const takeExpertMarketSeedQuery = () => {
7752
+ const value = expertMarketSeedQuery;
7753
+ expertMarketSeedQuery = '';
7754
+ return value;
7755
+ };
7104
7756
  // 专家市场(市场页子标签):浏览全量目录 + 召唤/送还。
7105
7757
  const ExpertMarket = ({ call }) => {
7106
7758
  const [loading, setLoading] = useState(true);
@@ -7110,8 +7762,9 @@ const ExpertMarket = ({ call }) => {
7110
7762
  const [note, setNote] = useState('');
7111
7763
  const [experts, setExperts] = useState([]);
7112
7764
  const [categories, setCategories] = useState([]);
7765
+ const [meta, setMeta] = useState({});
7113
7766
  const [source, setSource] = useState('');
7114
- const [query, setQuery] = useState('');
7767
+ const [query, setQuery] = useState(() => takeExpertMarketSeedQuery());
7115
7768
  const [category, setCategory] = useState('all');
7116
7769
  const [page, setPage] = useState(1);
7117
7770
  const [detail, setDetail] = useState(null);
@@ -7152,7 +7805,15 @@ const ExpertMarket = ({ call }) => {
7152
7805
  const data = await loadExpertCatalogShared(call, force);
7153
7806
  setExperts(data.experts);
7154
7807
  setCategories(data.categories);
7808
+ setMeta(data.meta || {});
7155
7809
  setSource(data.loadedFrom);
7810
+ if (recordMarketSnapshot('expert', data.experts.map((expert) => ({
7811
+ key: expert.id || expert.plugin,
7812
+ name: expert.name,
7813
+ sig: String(expert.updatedAt || ''),
7814
+ })))) {
7815
+ emitMarketPulse();
7816
+ }
7156
7817
  } catch (e) {
7157
7818
  setError(String(e?.message || e));
7158
7819
  } finally {
@@ -7250,12 +7911,22 @@ const ExpertMarket = ({ call }) => {
7250
7911
  return h('div', null,
7251
7912
  h('div', { className: 'dsh-mcp-wb-toolbar' },
7252
7913
  h(WbSearchBox, {
7253
- value: query, placeholder: t('搜索专家(花名 / 职业 / 描述 / 标签)'), disabled: loading,
7914
+ value: query, placeholder: t('搜索专家(花名 / 职业 / 描述 / 标签)'), busy: loading,
7254
7915
  onChange: (value) => { setQuery(value); setPage(1); },
7255
7916
  }),
7256
7917
  h('span', { className: 'dsh-mcp-builtin-count' },
7257
7918
  t('显示 {0} / {1}', [filtered.length, experts.length]) + (searching ? t(' · 全库搜索(忽略分类)') : '') + (source === 'network' ? t(' · 已更新') : source === 'disk' ? t(' · 缓存') : '')),
7258
7919
  ),
7920
+ (meta.authorName || meta.lastUpdated)
7921
+ ? h('div', { className: 'dsh-mcp-catalog-meta' },
7922
+ meta.authorName ? h('span', null, t('目录维护方:{0}', [meta.authorName])) : null,
7923
+ meta.lastUpdated ? h('span', null, t('目录更新于 {0}', [relativeLabel(meta.lastUpdated)])) : null,
7924
+ /^https?:\/\//i.test(String(meta.authorSource || ''))
7925
+ ? h('a', { href: meta.authorSource, target: '_blank', rel: 'noreferrer' }, t('查看来源'))
7926
+ : null,
7927
+ meta.license ? h('span', null, meta.license) : null,
7928
+ )
7929
+ : null,
7259
7930
  // 搜索态置灰不隐藏:位置稳定,用户知道「分类还在,只是这次搜索没按它筛」。
7260
7931
  h('div', { className: 'dsh-mcp-market-chips' + (searching ? ' muted' : ''), title: searching ? t('搜索时自动在全库匹配(忽略分类)') : undefined },
7261
7932
  categoryChips.map((row) => h('button', {
@@ -7284,9 +7955,12 @@ const ExpertMarket = ({ call }) => {
7284
7955
  const staleTeam = actions.update;
7285
7956
  return h('div', { key: item.plugin, className: 'dsh-mcp-card wb' + (summoned ? ' installed' : '') },
7286
7957
  h('div', { className: 'dsh-mcp-card-top' },
7287
- h('div', null,
7288
- h('div', { className: 'dsh-mcp-card-name' }, t('{0}({1})', [item.name, item.profession])),
7289
- h('div', { className: 'dsh-mcp-card-vendor' }, item.categoryName || item.categoryId),
7958
+ h('div', { className: 'dsh-mcp-card-person' },
7959
+ h(Avatar, { url: item.avatarUrl, name: item.name, className: 'dsh-mcp-card-avatar' }),
7960
+ h('div', null,
7961
+ h('div', { className: 'dsh-mcp-card-name' }, t('{0}({1})', [item.name, item.profession])),
7962
+ h('div', { className: 'dsh-mcp-card-vendor' }, item.categoryName || item.categoryId),
7963
+ ),
7290
7964
  ),
7291
7965
  staleTeam
7292
7966
  ? h(Badge, {
@@ -7305,6 +7979,7 @@ const ExpertMarket = ({ call }) => {
7305
7979
  h(Badge, null, item.expertType === 'team' ? t('专家团') : t('专家')),
7306
7980
  item.tags.map((tag) => h(Badge, { key: tag }, tag)),
7307
7981
  installedSkillPlugins.has(item.plugin) ? h(Badge, null, t('含技能')) : null,
7982
+ item.updatedAt ? h(Badge, { title: wbFormatDate(timestampOf(item.updatedAt)) }, t('更新于 {0}', [relativeLabel(item.updatedAt)])) : null,
7308
7983
  ),
7309
7984
  h('div', { className: 'dsh-mcp-wb-card-actions' },
7310
7985
  h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: openingPlugin === item.plugin, 'aria-busy': openingPlugin === item.plugin || undefined, onClick: () => openDetail(item) }, openingPlugin === item.plugin ? t('读取中…') : t('详情')),
@@ -7341,11 +8016,35 @@ const ExpertMarket = ({ call }) => {
7341
8016
  });
7342
8017
  return h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setDetail(null) },
7343
8018
  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); } } },
7344
- h('h3', null, t('{0}({1})', [detail.name, detail.profession])),
7345
- h('div', { className: 'dsh-mcp-card-vendor' }, detail.categoryName || detail.categoryId || ''),
8019
+ h('div', { className: 'dsh-mcp-detail-person' },
8020
+ h(Avatar, { url: detail.avatarUrl, name: detail.name, className: 'dsh-mcp-detail-avatar' }),
8021
+ h('div', null,
8022
+ h('h3', null, t('{0}({1})', [detail.name, detail.profession])),
8023
+ h('div', { className: 'dsh-mcp-card-vendor' }, detail.categoryName || detail.categoryId || ''),
8024
+ ),
8025
+ ),
8026
+ h(PresenceLine, {
8027
+ owner: meta.authorName,
8028
+ updatedAt: detail.updatedAt,
8029
+ note: detail.expertType === 'team' ? t('专家团') : t('专家人设'),
8030
+ className: 'dsh-mcp-presence-detail',
8031
+ }),
8032
+ // 真实出口前置:详情再长也不必先滑到底才能去仓库 / 提 Issue。
8033
+ h(FeedbackLinks, {
8034
+ item: detail, kind: 'expert',
8035
+ homepage: meta.authorSource,
8036
+ sourceLabel: meta.authorName ? t('目录维护方:{0}', [meta.authorName]) : '',
8037
+ }),
7346
8038
  h('div', null,
7347
8039
  detail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, detail.detailError) : null,
7348
8040
  detail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, detail.description) : null,
8041
+ (meta.version || detail.createdAt || detail.updatedAt)
8042
+ ? h('div', { className: 'dsh-mcp-card-tags' },
8043
+ meta.version ? h(Badge, null, 'v' + meta.version) : null,
8044
+ detail.createdAt ? h(Badge, null, t('加入时间 {0}', [wbFormatDate(timestampOf(detail.createdAt))])) : null,
8045
+ detail.updatedAt ? h(Badge, null, t('人设更新于 {0}', [relativeLabel(detail.updatedAt)])) : null,
8046
+ )
8047
+ : null,
7349
8048
  detail.quickPrompts && detail.quickPrompts.length
7350
8049
  ? (() => {
7351
8050
  // OPT-26 · 3.3:quickPrompts 超过 3 条时默认折叠,按钮切换展开/收起。
@@ -7408,7 +8107,7 @@ const ExpertMarket = ({ call }) => {
7408
8107
  // stale 团队行:详情弹层里给出直白解释 + 更新入口(同一条召唤链,upsert 整行重写)。
7409
8108
  // 判定与卡片按钮同源(detailActions 在上方按 expertCardActions 算出)。
7410
8109
  detailActions.update
7411
- ? h('p', { className: 'dsh-mcp-wb-modal-note', style: 'color:var(--dsw-alias-state-warn-primary)' },
8110
+ ? h('p', { className: 'dsh-mcp-wb-modal-note', style: { color: 'var(--dsw-alias-state-warn-primary)' } },
7412
8111
  t('⚠ 这条预设是旧版本召唤写入的:人设缺「DSH 运行时适配」桥接段,用它的新会话主理人只会调不存在的 TeamCreate/Agent,成员子智能体开不出来。点下方「更新预设」重跑召唤即可刷新。'))
7413
8112
  : null,
7414
8113
  ),
@@ -7496,6 +8195,20 @@ const quickMentionText = (kind, item) => {
7496
8195
  const token = String(item.name || '');
7497
8196
  return /^[\w-]+$/.test(token) ? `/${token} ` : t('{0}:', [token]);
7498
8197
  };
8198
+ // 二级菜单横向展开方向:输入框动作区贴右侧,主菜单常被视口夹到右缘;若第二级仍向右开,
8199
+ // 232px 会整块伸出屏幕。以锚点行为基准判断(不用子菜单当前所在侧,避免翻转后来回抖动):
8200
+ // 右侧放不下且左侧更宽裕时向左开;两侧都放不下时也选更宽的一侧,把溢出减到最小。
8201
+ // width 传子菜单实测外宽(含 padding/border);未挂载时按 232 + 12 估算。
8202
+ const quickSubmenuOpensLeft = (row, width = 244) => {
8203
+ if (!row) return false;
8204
+ const rect = row.getBoundingClientRect();
8205
+ const viewportW = window.innerWidth || document.documentElement.clientWidth || 0;
8206
+ const gap = 4 + 8; // 与主菜单的 4px 间距 + 视口 8px 留白
8207
+ const roomRight = viewportW - rect.right - gap;
8208
+ const roomLeft = rect.left - gap;
8209
+ const outerWidth = Math.min(width, Math.max(0, viewportW - 16));
8210
+ return roomRight < outerWidth && roomLeft >= roomRight;
8211
+ };
7499
8212
  const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7500
8213
  useI18nRevision(); // 宿主切语言后重渲染,模块级 getter 文案即时更新
7501
8214
  const [open, setOpen] = useState(false);
@@ -7503,13 +8216,25 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7503
8216
  const [recent, setRecent] = useState(null); // { skill: [], mcp: [], expert: [] }
7504
8217
  const [listError, setListError] = useState('');
7505
8218
  const [copied, setCopied] = useState('');
7506
- // 子菜单翻转:展开后实测子菜单底边是否越过视口下缘,越界则给该行加 flip(向上展开)。
8219
+ // 子菜单翻转:展开后实测几何,纵向越界加 flip(向上展开),横向越界加 hflip(向左展开)。
7507
8220
  // 只在展开/hoverKind 变化时测一次(临时浮层,不监听滚动缩放)。
7508
8221
  const [flipKinds, setFlipKinds] = useState(() => new Set());
8222
+ const [hflipKinds, setHflipKinds] = useState(() => new Set());
7509
8223
  const rowRefs = useRef(new Map());
7510
8224
  const rootRef = useRef(null);
7511
8225
  const closeTimer = useRef(null);
7512
8226
  const cancelClose = () => { if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } };
8227
+ // 展开子菜单前先定横向方向:等挂载后再测会先按默认方向画一帧,而菜单贴右缘时
8228
+ // 那一帧整块在屏幕外。实测宽度的精修仍由下面的 effect 负责。
8229
+ const markSubmenuSide = (kindKey, row) => {
8230
+ const opensLeft = quickSubmenuOpensLeft(row);
8231
+ setHflipKinds((prev) => {
8232
+ if (prev.has(kindKey) === opensLeft) return prev;
8233
+ const next = new Set(prev);
8234
+ if (opensLeft) next.add(kindKey); else next.delete(kindKey);
8235
+ return next;
8236
+ });
8237
+ };
7513
8238
  // scheduleClose 在 JSX 前定义(rootHoverProps 用它);此处不重复声明。
7514
8239
 
7515
8240
  // 菜单打开时拉一次各类清单。清单取全量(不做 6 条截断),交给子菜单自己
@@ -7534,7 +8259,14 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7534
8259
  setRecent({
7535
8260
  skill: skillRows.map((row) => ({ name: row.name, description: row.description || row.whenToUse || '' })),
7536
8261
  mcp: mcpRows.map((row) => ({ name: row.serverName, description: row.url || row.command || row.transport || '' })),
7537
- expert: expertRows.map((row) => ({ plugin: row.plugin, name: row.plugin, presetId: row.presetId })),
8262
+ // 专家显示名由宿主从目录缓存补上(nameZh/nameEn),两套都留在行里:
8263
+ // 菜单开着时宿主切语言,itemLabel 直接按当前 locale 重选,不必重新拉取。
8264
+ expert: expertRows.map((row) => ({
8265
+ plugin: row.plugin,
8266
+ name: row.nameZh || row.nameEn || row.plugin,
8267
+ nameEn: row.nameEn || row.nameZh || row.plugin,
8268
+ presetId: row.presetId,
8269
+ })),
7538
8270
  // 数据层读侧已经滤掉「无名/无成员」的残缺条目,这里再兜一次手改文件的形状:
7539
8271
  // 空套件展开后什么都插不进草稿,留在菜单里只是个点了没反应的死条目。
7540
8272
  suite: suiteRows
@@ -7550,10 +8282,13 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7550
8282
  recentExpert: Array.isArray(kinds.expert) ? kinds.expert : [],
7551
8283
  });
7552
8284
  }, [call]);
8285
+ // 关闭时保留上次清单:再次打开先用缓存内容按最终高度出菜单,后台再刷新,
8286
+ // 避免每次点击都先闪一版 loading 骨架再收缩。首次打开仍会短暂显示骨架,
8287
+ // 但位置已在布局阶段定好,不会出现「高菜单栏闪一下」。
7553
8288
  useEffect(() => {
7554
- if (open && recent === null) loadRecent().catch((e) => setListError(String(e?.message || e)));
7555
- if (!open) setRecent(null);
7556
- }, [open, recent, loadRecent]);
8289
+ if (!open) return;
8290
+ loadRecent().catch((e) => setListError(String(e?.message || e)));
8291
+ }, [open, loadRecent]);
7557
8292
 
7558
8293
  // ── 主菜单 fixed 定位(页面不动的根修)────────────────────────────────
7559
8294
  // 菜单原来 position:absolute 锚在图标上:attachNote/新建按钮让菜单变高时,
@@ -7562,15 +8297,20 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7562
8297
  // 菜单再高也只是视口内的浮层,页面滚动零位移。翻转/关闭判定不受影响。
7563
8298
  const menuRef = useRef(null);
7564
8299
  const [menuPos, setMenuPos] = useState(null);
7565
- useEffect(() => {
8300
+ // 必须用 useLayoutEffect:菜单首帧还没有定位(style 为空)时,position:fixed 会按
8301
+ // 静态位置落在输入框行里,一帧后再跳到实测位置——用户看到的就是「很高的菜单栏闪现」。
8302
+ // 布局阶段测量 + 定位,浏览器一次绘制就已经在正确位置;未定位前菜单保持 visibility:hidden。
8303
+ useLayoutEffect(() => {
7566
8304
  if (!open) { setMenuPos(null); return; }
7567
8305
  const measure = () => {
7568
8306
  const trigger = rootRef.current?.querySelector('.dsh-mcp-quick-trigger');
7569
8307
  if (!trigger) return;
7570
8308
  const rect = trigger.getBoundingClientRect();
7571
- const width = menuRef.current?.offsetWidth || 148;
7572
- // 默认贴 trigger 左缘、向上弹出;右缘越界时右对齐,顶部空间不足时向下弹出。
7573
- const left = Math.min(rect.left, Math.max(4, window.innerWidth - width - 4));
8309
+ const width = menuRef.current?.offsetWidth || 128;
8310
+ // 一级菜单水平居中于引用图标:left = 图标中心 − 菜单半宽。
8311
+ // 贴到视口右缘时夹回视口内,左侧不足 4px 时向右兜底。
8312
+ const centered = rect.left + rect.width / 2 - width / 2;
8313
+ const left = Math.max(4, Math.min(centered, window.innerWidth - width - 4));
7574
8314
  const style = { position: 'fixed', left: Math.round(left) + 'px' };
7575
8315
  const needsUp = rect.top > window.innerHeight / 2;
7576
8316
  if (needsUp) style.bottom = Math.round(window.innerHeight - rect.top + 6) + 'px';
@@ -7586,7 +8326,14 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7586
8326
  // 子菜单翻转测量:hoverKind 变化且子菜单已挂载后,实测几何再决定 flip。
7587
8327
  // 布局在一帧内即可稳定(子菜单定位纯 CSS),rAF 双保险避免读到中间态。
7588
8328
  useEffect(() => {
7589
- if (!open || !hoverKind) return;
8329
+ if (!open) {
8330
+ // 关闭后清空翻转态:下次在别处打开时先按默认方向渲染,再由实测修正,
8331
+ // 避免沿用上一次位置的 flip/hflip 闪一下。
8332
+ setFlipKinds((prev) => (prev.size ? new Set() : prev));
8333
+ setHflipKinds((prev) => (prev.size ? new Set() : prev));
8334
+ return;
8335
+ }
8336
+ if (!hoverKind) return;
7590
8337
  let alive = true;
7591
8338
  requestAnimationFrame(() => requestAnimationFrame(() => {
7592
8339
  if (!alive) return;
@@ -7594,7 +8341,8 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7594
8341
  const sub = row ? row.querySelector('.dsh-mcp-quick-sub') : null;
7595
8342
  if (!row || !sub) return;
7596
8343
  const rect = sub.getBoundingClientRect();
7597
- const overflowBottom = rect.bottom - (window.innerHeight || document.documentElement.clientHeight);
8344
+ const viewportH = window.innerHeight || document.documentElement.clientHeight || 0;
8345
+ const overflowBottom = rect.bottom - viewportH;
7598
8346
  setFlipKinds((prev) => {
7599
8347
  const shouldFlip = overflowBottom > 8;
7600
8348
  if (prev.has(hoverKind) === shouldFlip) return prev;
@@ -7602,6 +8350,15 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7602
8350
  if (shouldFlip) next.add(hoverKind); else next.delete(hoverKind);
7603
8351
  return next;
7604
8352
  });
8353
+ // 横向用子菜单实测外宽精修一次(CSS max-width 在窄窗下会收窄它);
8354
+ // 方向判据与悬停前那次一致,不会因当前所在侧而抖动。
8355
+ const shouldHflip = quickSubmenuOpensLeft(row, sub.offsetWidth || 232);
8356
+ setHflipKinds((prev) => {
8357
+ if (prev.has(hoverKind) === shouldHflip) return prev;
8358
+ const next = new Set(prev);
8359
+ if (shouldHflip) next.add(hoverKind); else next.delete(hoverKind);
8360
+ return next;
8361
+ });
7605
8362
  }));
7606
8363
  return () => { alive = false; };
7607
8364
  }, [open, hoverKind, recent]);
@@ -7690,7 +8447,14 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7690
8447
  if (kind === 'mcp') return orderSubItems(kind, recent.mcp);
7691
8448
  return orderSubItems(kind, recent.expert);
7692
8449
  };
7693
- const itemLabel = (kind, item) => (kind === 'expert' ? item.name || item.plugin : item.name);
8450
+ // 专家名跟随宿主语言:两套名都已随行送到(见 loadRecent),这里只做投影;
8451
+ // 由 useI18nRevision 驱动重渲染,切换语言当帧生效。缺译文时回落另一语言,最后才是 plugin。
8452
+ const itemLabel = (kind, item) => {
8453
+ if (kind !== 'expert') return item.name;
8454
+ return usesEnglish()
8455
+ ? (item.nameEn || item.name || item.plugin)
8456
+ : (item.name || item.nameEn || item.plugin);
8457
+ };
7694
8458
  const itemDesc = (kind, item) => (kind === 'mcp' ? (item.transport ? item.transport : '') : (item.description || '').slice(0, 48));
7695
8459
 
7696
8460
  // ── 挂专家(WorkBuddy 式:随时挂 / 换 / 摘)──────────────────────────────
@@ -7724,7 +8488,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7724
8488
  if (res && res.ok) {
7725
8489
  setAttached({ plugin: item.plugin, name: res.name || item.name || item.plugin, mode: 'persona' });
7726
8490
  // 团队型说明只在 trigger 的 title(悬停)里出现,不进菜单气泡:
7727
- // 这句 60+ 字,菜单 min-width 只有 148px,塞进气泡会折成多行把菜单撑高。
8491
+ // 这句 60+ 字,菜单 min-width 只有 128px,塞进气泡会折成多行把菜单撑高。
7728
8492
  // 成功态本来就有两处可见反馈——专家 chip(名字 + ×)与菜单项的「已挂」徽章。
7729
8493
  setAttachNote('');
7730
8494
  setTeamHint(res.expertType === 'team'
@@ -7820,7 +8584,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7820
8584
  onClick: (e) => { e.stopPropagation(); setOpen((value) => !value); setHoverKind(null); },
7821
8585
  }, h(Icon, { d: ICONS.board, size: 15 })),
7822
8586
  open
7823
- ? h('div', { className: 'dsh-mcp-quick-menu', role: 'menu', 'aria-label': t('快速调用'), ref: menuRef, style: menuPos },
8587
+ ? h('div', { className: 'dsh-mcp-quick-menu', role: 'menu', 'aria-label': t('快速调用'), ref: menuRef, style: menuPos || { position: 'fixed', visibility: 'hidden' } },
7824
8588
  // 只报「已复制」,不复述内容:MCP 那条 mention 有 60+ 字,拼进来会折行撑高菜单
7825
8589
  // (OPT-32)。内容已在剪贴板,用户直接粘贴即可;完整文本留作 title 悬停可查。
7826
8590
  copied ? h('div', { className: 'dsh-mcp-quick-note', title: copied }, t('已复制,粘贴到输入框')) : null,
@@ -7842,10 +8606,10 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7842
8606
  const hasAny = skillRows.length > 0 || suites.length > 0;
7843
8607
  return h('div', {
7844
8608
  key: kind.key,
7845
- className: 'dsh-mcp-quick-row' + (expanded && flipKinds.has(kind.key) ? ' flip' : ''),
8609
+ className: 'dsh-mcp-quick-row' + (expanded && flipKinds.has(kind.key) ? ' flip' : '') + (expanded && hflipKinds.has(kind.key) ? ' hflip' : ''),
7846
8610
  ref: (el) => { if (el && expanded) rowRefs.current.set(kind.key, el); else rowRefs.current.delete(kind.key); },
7847
8611
  // 悬停开合由根节点统一管理(见 rootHoverProps):行内只负责展开子菜单。
7848
- onMouseEnter: () => { cancelClose(); setHoverKind(kind.key); },
8612
+ onMouseEnter: (e) => { cancelClose(); markSubmenuSide(kind.key, e.currentTarget); setHoverKind(kind.key); },
7849
8613
  },
7850
8614
  h('button', {
7851
8615
  type: 'button',
@@ -7856,7 +8620,13 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7856
8620
  // 分类行点击 = 只切换子菜单(绝不跳板)。跳页是上一版缺陷的根因:
7857
8621
  // WorkBuddy 式交互里悬停已展开子菜单,点击再跳管理页属意外导航。
7858
8622
  // 键盘/触屏路径:Enter/点按切换子菜单;管理入口在子菜单底部明确按钮。
7859
- onClick: (e) => { e.stopPropagation(); cancelClose(); setHoverKind((value) => (value === kind.key ? null : kind.key)); },
8623
+ onClick: (e) => {
8624
+ e.stopPropagation(); cancelClose();
8625
+ const willOpen = hoverKind !== kind.key;
8626
+ // 点按 / 键盘路径也要先定方向:贴右缘时先向右画一帧会整块跑到屏幕外。
8627
+ if (willOpen) markSubmenuSide(kind.key, e.currentTarget.closest('.dsh-mcp-quick-row'));
8628
+ setHoverKind(willOpen ? kind.key : null);
8629
+ },
7860
8630
  title: kind.hint,
7861
8631
  },
7862
8632
  h(Icon, { d: ICONS[kind.icon], size: 13 }),
@@ -7914,7 +8684,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7914
8684
  title: t('打开看板的「') + kind.label + t('」分类管理'),
7915
8685
  onClick: (e) => { e.stopPropagation(); openBoardCategory(kind.key === 'expert' ? 'expert' : kind.key); },
7916
8686
  },
7917
- h('span', { className: 'dsh-mcp-quick-item-name' }, t('管理') + kind.label + '…'),
8687
+ h('span', { className: 'dsh-mcp-quick-item-name' }, t('管理{0}…', [kind.label])),
7918
8688
  ),
7919
8689
  // 套件管理入口只在技能子菜单里给(套件就是技能的组合,放在别处会找不到语境)。
7920
8690
  // 走 openSuiteManager():它广播 + 切到 skill 看板,弹窗由 SkillBoard 那边挂出来——
@@ -8063,120 +8833,1172 @@ const wbFormatDate = (ms) => {
8063
8833
  if (!ms) return '';
8064
8834
  try { return new Date(Number(ms)).toISOString().slice(0, 10); } catch { return ''; }
8065
8835
  };
8066
- // ── 市场面板:WorkBuddy 连接器 / 内置 MCP / SkillHub 技能三个子标签 ──────────────
8067
- // 市场搜索框:左内置放大镜、圆角胶囊、聚焦高亮、右一键清空。
8068
- // 三处复用(连接器 / 内置 MCP / 技能):连接器与内置是纯本地过滤,输入即过滤;
8069
- // 技能走服务端搜索,onCommit 由防抖或回车触发(见 WorkBuddyMarket 内注释)。
8070
- const WbSearchBox = ({ value, onChange, placeholder, disabled, onCommit }) =>
8071
- h('div', { className: 'dsh-mcp-wb-search' },
8072
- h('span', { className: 'dsh-mcp-wb-search-ico', 'aria-hidden': true }, h(Icon, { d: ICONS.search, size: 14 })),
8073
- h('input', {
8074
- type: 'search', className: 'dsh-mcp-wb-search-input', placeholder,
8075
- value, disabled, spellCheck: false,
8076
- onChange: (e) => onChange(e.target.value),
8077
- onKeyDown: onCommit
8078
- ? (e) => { if (e.key === 'Enter') { e.preventDefault(); onCommit(); } }
8079
- : undefined,
8080
- }),
8081
- value && !disabled
8082
- ? h('button', {
8083
- type: 'button', className: 'dsh-mcp-wb-search-clear', title: t('清空'),
8084
- 'aria-label': t('清空搜索'),
8085
- onClick: () => { onChange(''); if (onCommit) onCommit(); },
8086
- }, h(Icon, { d: ICONS.x, size: 12 }))
8087
- : null,
8088
- );
8089
- const WorkBuddyMarket = ({ call, pluginManager }) => {
8090
- // ── 共享状态 ──
8091
- const [tab, setTab] = useState('connector'); // connector | skill
8092
- const [loading, setLoading] = useState(true);
8093
- const [error, setError] = useState('');
8094
- const [busy, setBusy] = useState(false);
8095
- const [busyLabel, setBusyLabel] = useState('');
8096
- const [note, setNote] = useState('');
8097
- // ── 连接器 ──
8098
- const [connectorSource, setConnectorSource] = useState('');
8099
- const [connectors, setConnectors] = useState([]);
8100
- const [connectorQuery, setConnectorQuery] = useState('');
8101
- const [connectorOnlyReady, setConnectorOnlyReady] = useState(true);
8102
- // T04/R8:已安装筛选(全量本地筛选,不受分页影响——与技能侧「仅本页」不同)。
8103
- const [connectorOnlyInstalled, setConnectorOnlyInstalled] = useState(false);
8104
- // T05/R10:连接器排序键(全量本地排序)。
8105
- const [connectorSort, setConnectorSort] = useState('default');
8106
- const [connectorPage, setConnectorPage] = useState(1);
8107
- const [connectorDetail, setConnectorDetail] = useState(null);
8108
- // ── 技能 ──
8109
- const [skillPage, setSkillPage] = useState(1);
8110
- const [skillSort, setSkillSort] = useState('downloads');
8111
- const [skillKeyword, setSkillKeyword] = useState('');
8112
- const [skillKeywordInput, setSkillKeywordInput] = useState('');
8113
- const [skills, setSkills] = useState([]);
8114
- const [skillTotal, setSkillTotal] = useState(0);
8115
- // 技能侧自己的加载/错误态:此前与连接器共用 `loading`/`error`,两种请求谁先回来谁就
8116
- // 把对方的态改掉——连接器 1 秒读完就把 loading 置 false,技能还在飞,于是技能页先画
8117
- // 「技能目录暂时是空的 / 可能还没同步下来」;等技能真的超时,又把整页 banner 改成报错。
8118
- // 拆开之后,技能慢只影响技能页自己的 loading/error。
8119
- const [skillLoading, setSkillLoading] = useState(true);
8120
- const [skillError, setSkillError] = useState('');
8121
- const [skillDetail, setSkillDetail] = useState(null);
8122
- const [installedSkills, setInstalledSkills] = useState(new Set());
8123
- // §6.3 已安装筛选:SkillHub 每页 20 条远程分页,而「已装」是本地集合,
8124
- // 所以这层过滤只能作用于**当前页**——必须在标签上写清,否则会被误读成全库筛选。
8125
- // T04/R7:令牌弹窗。连接器 mcp.json 里带 ${...} 占位符时要先让用户填值——
8126
- // 面板只做「用户明确输入」的透传,绝不代填、绝不猜值(X9 边界)。
8127
- // tokenDialog = { item, values: Record<占位符名, string> } | null
8128
- const [tokenDialog, setTokenDialog] = useState(null);
8129
- // R8:OAuth 授权对话框。连接器探测到 401 不再报错,改为弹这里引导浏览器授权。
8130
- // authDialog = { item, serverName, sessionId, authorizeUrl, mode, redirectUri, hint,
8131
- // status, error, callbackUrl, manualToken } | null
8132
- const [authDialog, setAuthDialog] = useState(null);
8133
- const authDialogRef = useRef(null);
8134
- authDialogRef.current = authDialog;
8135
- const authPollRef = useRef(null);
8136
- const authDialogRefNode = useRef(null);
8137
- useFocusTrap(authDialogRefNode, !!authDialog);
8138
- const [skillOnlyInstalled, setSkillOnlyInstalled] = useState(false);
8139
- // OPT-21:两个详情弹层此前都没有 ref(焦点陷阱需要容器节点)。
8140
- const connectorDetailRef = useRef(null);
8141
- const skillDetailRef = useRef(null);
8142
- useFocusTrap(connectorDetailRef, !!connectorDetail);
8143
- useFocusTrap(skillDetailRef, !!skillDetail);
8144
- const WB_PAGE_SIZE = 20;
8145
8836
 
8146
- const loadInstalled = useCallback(async () => {
8147
- const res = await call('wbSkillInstalled');
8148
- if (res?.ok) setInstalledSkills(new Set((res.installed || []).map((row) => row.slug)));
8149
- }, [call]);
8837
+ // ── 社区「人味」信号:真实的人、真实的维护节奏、真实的反馈出口 ────────────────
8838
+ // 市场目录里的条目不该只是「一个可安装的对象」:谁做的、最后一次动是什么时候、
8839
+ // 项目还活着吗、遇到问题该找谁。这些信号全部来自上游既有字段或 GitHub 公开 API,
8840
+ // 不生成任何虚构的作者 / 评价 / 在线人数。
8841
+ const ARMORY_REPO_URL = 'https://github.com/2026heshao/dsh-armory';
8842
+ const parseGithubRepoKey = (value) => {
8843
+ const text = String(value || '').trim();
8844
+ if (!text) return '';
8845
+ const ssh = /^git@github\.com:([^/\s]+)\/([^/\s#]+?)(?:\.git)?$/i.exec(text);
8846
+ if (ssh) return `${ssh[1]}/${ssh[2]}`;
8847
+ const http = /^(?:git\+)?https?:\/\/(?:www\.)?github\.com\/([^/\s#?]+)\/([^/\s#?]+)/i.exec(text);
8848
+ if (http) return `${http[1]}/${http[2].replace(/\.git$/i, '')}`;
8849
+ const bare = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(text);
8850
+ if (bare) return `${bare[1]}/${bare[2].replace(/\.git$/i, '')}`;
8851
+ return '';
8852
+ };
8853
+ // ── 头像取图通道:GitHub 系一律走镜像代理 ───────────────────────────────────
8854
+ // 三条候选通道都在本机实测过(2026-10-24,真实 owner 名单取自 awesome 目录 4203 个):
8855
+ // · 直连 `avatars.githubusercontent.com/<login>`:单发 0.35s 很快,但 6 并发就有
8856
+ // 一半挂到 12 秒;更不能接受的是它对不存在的账号回 **200 + 一张占位图**,而且
8857
+ // 大小写敏感(`perrylink` 失败、`PERRYLINK` 成功)。目录里的 owner 写法五花八门,
8858
+ // 这条会把「头像取不到」变成「显示成别人的脸」,比字母更糟。
8859
+ // · `api.github.com/users/<login>` 换 canonical `/u/<id>`:0.67s 能成,但未认证配额
8860
+ // 只有 60 次/小时(实测 X-RateLimit-Remaining 剩 38),而首页 50 张卡就有 41 个
8861
+ // 不同 owner —— 看完一页配额见底,后面几页全退回字母。
8862
+ // · 镜像代理 `wsrv.nl` 去取 `https://github.com/<login>.png`(重定向由代理在服务端跟,
8863
+ // 本机的挂起路由根本不参与):12 并发全部 200,0.56–2.83s、中位约 1.1s,
8864
+ // 单张 webp 0.3–2.7KB,响应头 `Cache-Control: public, max-age=31536000`
8865
+ // (浏览器自己缓存一年),账号不存在干净回 404 → 落回字母,且**一次都不花配额**。
8866
+ // README 插图早就走同一条代理(`README_IMG_PROXY` / `readmeProxiedSrc`),头像沿用
8867
+ // 同一个取舍。代价说清楚:代理是第三方服务,它会知道面板这一屏渲染了哪些 GitHub
8868
+ // 用户名——面板已经把这批名字发给同一个代理处理 README 图,所以不是新增的暴露面。
8869
+ // 也**不做**「代理失败再回退直连 github.com」:直连那条源要 10 秒级(见下面 avatarRecord
8870
+ // 的实测注释),为一张头像把请求发回那条挂起的路上不值得。
8871
+ //
8872
+ // 但「首选代理挂了怎么办」要有答案。这里照 dshmarket 的 `githubRouteCandidates` /
8873
+ // `OwnerAvatar` 做成**有序路由**:第一条 onError/超时就直接换下一条,全部用完才落字母。
8874
+ // · 首选 `wsrv.nl`,备胎 `images.weserv.nl` —— 实测两者是**同一个后端的不同域名**
8875
+ // (同一份图、同样 200、同样一年强缓存),差别只在浏览器把它们当两个 host、
8876
+ // 各有约 6 条 HTTP/1.1 连接。所以备胎不只是「挂了再试」,也躲得开主域名被挤爆
8877
+ // 的那段时间(本机测过:12 并发走 wsrv 全部 200,但 images.weserv 单张更慢,
8878
+ // 所以它只配当第二条,不配当首选)。
8879
+ // · 另两条常见加速域名对头像地址是坏的:`gh-proxy.com` 回 404、`ghfast.top` 回 403
8880
+ // (都实测过),别再往表里加。
8881
+ const AVATAR_PROXY_BASES = ['https://wsrv.nl/?output=webp&q=80', 'https://images.weserv.nl/?output=webp&q=80'];
8882
+ const AVATAR_PROXY_HOSTS = ['github.com', 'avatars.githubusercontent.com', 'camo.githubusercontent.com'];
8883
+
8884
+ /** 该不该交给代理取:只有 GitHub 系域名需要(本机跟不动它的重定向),别的域名原样。 */
8885
+ const avatarProxyHost = (text) => {
8886
+ try {
8887
+ return AVATAR_PROXY_HOSTS.indexOf(new URL(text).hostname.toLowerCase()) >= 0;
8888
+ } catch {
8889
+ return false;
8890
+ }
8891
+ };
8150
8892
 
8151
- // T04/R8:安装状态账本(三类安装的统一登记,宿主 ~/.dsh/armory/install-state.json)。
8152
- // 与技能侧的 installedSkills 不同:这是**全量**集合,不受分页影响。
8153
- const [installRecords, setInstallRecords] = useState([]);
8154
- const loadInstallState = useCallback(async () => {
8893
+ /**
8894
+ * 头像地址 → **有序候选路由**(数组长度就是允许的尝试次数)。
8895
+ * · 已经是本代理族某条路由的地址:保留它自带的 `w`/`h`,只把域名依次换成族里每一条。
8896
+ * 这样「卡片算出来的地址」和「这里再算一遍」永远是同一个字符串(合并的前提),
8897
+ * 也不会套出第二层代理 —— 套两层就把图取坏了。
8898
+ * · GitHub 系源地址:按 `size`(CSS 像素,夹在 32~192,按 2 倍图取)拼出每一条路由。
8899
+ * · 其他域名 / 脏串:原样一条,交给浏览器自己取,取不到就是字母。
8900
+ * · 空输入返回**空数组**,调用方靠它判「这一位压根没图」。
8901
+ */
8902
+ const avatarRouteCandidates = (src, size = 64) => {
8903
+ const text = String(src || '').trim();
8904
+ if (!text) return [];
8905
+ const wrapped = AVATAR_PROXY_BASES.findIndex((base) => text.startsWith(base));
8906
+ if (wrapped >= 0) {
8907
+ const tail = text.slice(AVATAR_PROXY_BASES[wrapped].length);
8908
+ return AVATAR_PROXY_BASES.map((base) => `${base}${tail}`);
8909
+ }
8910
+ if (!avatarProxyHost(text)) return [text];
8911
+ const side = Math.max(32, Math.min(192, Math.round(Number(size) || 64)));
8912
+ return AVATAR_PROXY_BASES.map((base) => `${base}&w=${side}&h=${side}&url=${encodeURIComponent(text)}`);
8913
+ };
8914
+ /** 首选路由 = 登记表里的键 = 界面上最常见的那个 src;再过一道也不变(幂等)。 */
8915
+ const avatarProxyUrl = (src, size = 64) => avatarRouteCandidates(src, size)[0] || '';
8916
+ const githubAvatarUrl = (repo, size = 64) => {
8917
+ const key = parseGithubRepoKey(repo);
8918
+ return key ? avatarProxyUrl(`https://github.com/${key.split('/')[0]}.png`, size) : '';
8919
+ };
8920
+ // ── 头像:首帧字母,图片探到了再换 ───────────────────────────────────────────
8921
+ //
8922
+ // 为什么不能再把 src 直接交给 <img>(实测,2026-10-24):
8923
+ // · 全部卡片头像的**源**都是 `https://github.com/<login>.png`(awesome 的 slim.json 里
8924
+ // 根本没有 icon/avatar 字段,插件目录也一样);现在这个源只作为代理的 `url=` 参数出现,
8925
+ // 面板自己不再直连 github.com(通道选型见上面 avatarProxyUrl);
8926
+ // · 首页 50 张卡 = 100 个 <img>(卡片一个、它下面那行 PresenceLine 又一个),
8927
+ // 而这 50 条只有 41 个不同 owner —— 一半请求是重复的;
8928
+ // · 直连那条源时,github.com 的 302 回得很快(0.76s),慢在重定向落点:实测跟完整重定向链
8929
+ // 要 14.7 秒才拿到字节,单发 10.7 秒抛 "fetch failed"。浏览器在**报错之前**一直算
8930
+ // 「加载中」,Chromium 对单个 host 又只有约 6 条 HTTP/1.1 连接,100 个请求排队,于是
8931
+ // 整页都在等 10 秒级的挂起;这段时间 CSS 给 <img> 画的 background + border 就是用户
8932
+ // 看到的空灰圈,`onError` 里的 visibility:hidden 根本没机会触发。
8933
+ // 所以这里把「发请求」和「显示」拆开:
8934
+ // 1. 首帧永远渲染字母占位(.fallback),不留空圈 —— 慢的时候也得有内容;
8935
+ // 2. 后台用 new Image() 探一次,AVATAR_TIMEOUT_MS 内成功才换成 <img>
8936
+ // (此时字节已在浏览器缓存里,绘制是即时的);首选代理不成换下一条路由,
8937
+ // 全部用完才判死(见上面的 AVATAR_PROXY_BASES);
8938
+ // 3. 超时或报错就**粘住**记进模块级登记表:同一个 URL 本次会话不再发第二次,
8939
+ // 再进市场这些头像一次请求都不发(粘住的是请求,不是显示——迟到的成功仍然采纳,
8940
+ // 因为「慢」不等于「坏」,而且不额外花一次网络);
8941
+ // 4. 卡片行与 PresenceLine 传的是同一个 URL 字符串,共用同一条登记 →
8942
+ // 一次挂载发两遍同样的请求,自动合并成一遍;
8943
+ // 5. 探测只在「这个头像离视口 200px 以内」时才发起(useNearViewport,抄 dshmarket
8944
+ // 的卡片图口径)—— 视口外的那几十个 owner 根本进不了登记表。
8945
+ // 代价说清楚:每个 owner 至多 AVATAR_PROXY_BASES.length 次请求(一条代理 + 一条备胎),
8946
+ // 全挂之后归零,本次会话不再探;面板列表滚动时视口外的卡片根本进不了登记(见下面
8947
+ // useNearViewport),所以总量还能从 41 再往下压。
8948
+ //
8949
+ // 还要再加一层**并发闸门**(同样是本机实测,不是猜的):wsrv.nl 在这台机上只给
8950
+ // HTTP/1.1(curl 实测 ver=1.1),Chromium 每域约 6 条连接,而代理单张串行
8951
+ // 0.58–2.46s、中位 1.7s;一屏就有 41~57 个不同 owner,浏览器里 57 并发实测
8952
+ // 「1.5 秒内只上屏 3 张、中位 4825ms、9 秒后仍有 30 张在途」—— 全并发打出去等于谁都要
8953
+ // 等 9 秒。更要紧的是:浏览器的 socket 队列排在**上网之前**,而 `new Image()` 的超时
8954
+ // 是从调用起算的,全并发时第 30 张其实还没发出就被我们的 4 秒判了死,而且是**粘住**的死
8955
+ // (本次会话不再重试)—— 把「慢」误杀成「坏」。所以:
8956
+ // · 同时在途的头像请求不超过 AVATAR_INFLIGHT_MAX 条,其余排队(FIFO,入队顺序 =
8957
+ // 挂载顺序 = 文档顺序,也就是从上往下:用户先看到的那几张先拿到带宽);
8958
+ // · 超时从**真正发出请求**那一刻起算,不是从挂载起算 —— 排在队里等名额不算它慢;
8959
+ // · 只取 3 条而不是把 6 条连接占满:README 插图走的是**同一个 wsrv.nl 域名**,
8960
+ // 打开 README 弹层时不能把它的额度挤死。
8961
+ const AVATAR_TIMEOUT_MS = 4000;
8962
+ const AVATAR_REGISTRY_MAX = 300;
8963
+ const AVATAR_INFLIGHT_MAX = 3;
8964
+ /** 首选路由地址 → { status, waiters, timer, dispatched, released, routes, route, resolved } */
8965
+ const avatarRegistry = new Map();
8966
+ /** 已登记、还没轮到发出的 URL(按挂载顺序) */
8967
+ const avatarQueue = [];
8968
+ let avatarInflight = 0;
8969
+ let avatarDraining = false;
8970
+
8971
+ const avatarSettle = (rec, next) => {
8972
+ if (rec.status === 'ok') return;
8973
+ if (rec.status === 'fail' && next !== 'ok') return;
8974
+ rec.status = next;
8975
+ for (const notify of rec.waiters) notify();
8976
+ };
8977
+
8978
+ const avatarClearTimer = (rec) => {
8979
+ if (rec.timer) {
8980
+ clearTimeout(rec.timer);
8981
+ rec.timer = 0;
8982
+ }
8983
+ };
8984
+
8985
+ const avatarSucceeded = (rec, url) => {
8986
+ avatarClearTimer(rec);
8987
+ // resolved 记住**成功那条路由的地址**:备胎出图时界面上要画备胎那条,不能画回主那条。
8988
+ rec.resolved = url;
8989
+ avatarFinish(rec, 'ok');
8990
+ };
8991
+
8992
+ /**
8993
+ * 一条路由失败(onerror 或超时):还有备胎就换下一条,**继续占着同一个在途名额**
8994
+ * —— 名额代表「这个 owner 还没结算」,不是一次 HTTP;备胎用尽才判死并释放名额。
8995
+ * 换路由后重新起表:主域名挂满 4 秒不该把备域名那 4 秒也吃掉。
8996
+ */
8997
+ const avatarAttemptFailed = (rec) => {
8998
+ avatarClearTimer(rec);
8999
+ if (rec.route + 1 < rec.routes.length) {
9000
+ rec.route += 1;
9001
+ avatarDispatch(rec);
9002
+ return;
9003
+ }
9004
+ avatarFinish(rec, 'fail');
9005
+ };
9006
+
9007
+ const avatarDispatch = (rec) => {
9008
+ if (!rec.dispatched) {
9009
+ rec.dispatched = true;
9010
+ avatarInflight += 1;
9011
+ }
9012
+ const url = rec.routes[rec.route];
9013
+ // 计时起点在这里,不在 avatarRecord:排队时间不该计入超时(见上面的并发闸门说明)。
9014
+ rec.timer = setTimeout(() => {
9015
+ rec.timer = 0;
9016
+ avatarAttemptFailed(rec);
9017
+ }, AVATAR_TIMEOUT_MS);
9018
+ try {
9019
+ const image = new Image();
9020
+ image.referrerPolicy = 'no-referrer';
9021
+ image.onload = () => avatarSucceeded(rec, url);
9022
+ image.onerror = () => avatarAttemptFailed(rec);
9023
+ image.src = url;
9024
+ } catch {
9025
+ // 拿不到 Image 构造器(非浏览器宿主 / 内存受限):走完尝试后判失败,界面停在字母上。
9026
+ avatarAttemptFailed(rec);
9027
+ }
9028
+ };
9029
+
9030
+ /** 腾出一个在途名额并立刻补队。每条只允许释放一次,否则计数会跑负、闸门形同虚设。 */
9031
+ const avatarRelease = (rec) => {
9032
+ if (!rec.dispatched || rec.released) return;
9033
+ rec.released = true;
9034
+ avatarInflight -= 1;
9035
+ avatarDrain();
9036
+ };
9037
+
9038
+ const avatarFinish = (rec, next) => {
9039
+ avatarClearTimer(rec);
9040
+ avatarSettle(rec, next);
9041
+ avatarRelease(rec);
9042
+ };
9043
+
9044
+ const avatarDrain = () => {
9045
+ // 重入保护:同步判死那条路径(Image 构造抛异常)会在 dispatch 里再调一次 drain,
9046
+ // 嵌套调用会绕开 while 的计数检查、把在途数打出上限。外层循环每轮都重读计数,
9047
+ // 所以让嵌套那次直接返回、由外层收摊,不会漏发。
9048
+ if (avatarDraining) return;
9049
+ avatarDraining = true;
9050
+ try {
9051
+ while (avatarInflight < AVATAR_INFLIGHT_MAX && avatarQueue.length) {
9052
+ const url = avatarQueue.shift();
9053
+ const rec = avatarRegistry.get(url);
9054
+ // 队头跳过:这条已被别的入口结算,或登记表已因溢出把它换掉了(绝不重复发)。
9055
+ if (!rec || rec.status !== 'loading' || rec.dispatched) continue;
9056
+ avatarDispatch(rec);
9057
+ }
9058
+ } finally {
9059
+ avatarDraining = false;
9060
+ }
9061
+ };
9062
+
9063
+ /**
9064
+ * 取某头像的加载登记,键是**首选路由地址**(卡片与 PresenceLine 算出同一个字符串,
9065
+ * 所以自动共用同一条登记)。只有第一次建立时才入队发请求。
9066
+ * 组件只在 useEffect 里、且只在「离视口够近」时调用,所以首帧一定先出字母。
9067
+ */
9068
+ const avatarRecord = (routes) => {
9069
+ const key = Array.isArray(routes) ? routes[0] : '';
9070
+ if (!key) return { status: 'fail', waiters: new Set(), timer: 0, dispatched: false, released: true, route: 0, routes: [], resolved: '' };
9071
+ const existing = avatarRegistry.get(key);
9072
+ if (existing) return existing;
9073
+ if (avatarRegistry.size >= AVATAR_REGISTRY_MAX) {
9074
+ // 先按插入序腾出一条**已结算**的。绝不优先杀在途或还在排队的:它们的回调 / 队位还认着
9075
+ // 这条记录,杀了之后同 URL 会被重建、再发一遍 —— 正好违反本层唯一的硬约束
9076
+ // (每个 URL 至多一次)。若一条都腾不出来,宁可不再探,也不让登记表随刷过的页数无限长。
9077
+ let freed = false;
9078
+ for (const [registered, rec] of avatarRegistry) {
9079
+ if (rec.status !== 'loading') {
9080
+ avatarRegistry.delete(registered);
9081
+ freed = true;
9082
+ break;
9083
+ }
9084
+ }
9085
+ // 溢出条:dispatched=false + released=true,让它永远不碰在途计数、也不入队。
9086
+ if (!freed) return { status: 'fail', waiters: new Set(), timer: 0, dispatched: false, released: true, route: 0, routes, resolved: '', overflow: true };
9087
+ }
9088
+ const rec = { status: 'loading', waiters: new Set(), timer: 0, dispatched: false, released: false, routes, route: 0, resolved: '' };
9089
+ avatarRegistry.set(key, rec);
9090
+ avatarQueue.push(key);
9091
+ avatarDrain();
9092
+ return rec;
9093
+ };
9094
+
9095
+ /**
9096
+ * 「这一个头像离视口近不近」—— 照 dshmarket 的 `useNearViewport` / `CardShot` 的口径:
9097
+ * **没靠近视口就不发请求**(`rootMargin` 提前 200px,滚到之前就备好)。这条比并发闸门
9098
+ * 更根本:闸门只是排队,视口门是**根本不排队**——一屏十几张卡之外的那几十个 owner
9099
+ * 一次都不请求;面板切走、容器隐藏时同样一次都不请求。
9100
+ * 宿主没有 IntersectionObserver(老 Webview、非浏览器环境)时初值就是 `true`:
9101
+ * 宁可退回「全都探」,也绝不能因为观察不到而让头像永远停在字母上。
9102
+ * 节点走 `useRef` 而不是 `useState`:effect 在提交之后运行,那时 ref 已经挂上真实 DOM,
9103
+ * 不必为了「拿到节点」再多渲染一次(dshmarket 用 state,代价是每个头像挂载都多一趟重绘)。
9104
+ */
9105
+ const AVATAR_VIEWPORT_MARGIN = '200px';
9106
+ const useNearViewport = (rootMargin = AVATAR_VIEWPORT_MARGIN) => {
9107
+ const [near, setNear] = useState(typeof IntersectionObserver === 'undefined');
9108
+ const nodeRef = useRef(null);
9109
+ useEffect(() => {
9110
+ if (near || nodeRef.current === null) return undefined;
9111
+ let observer = null;
8155
9112
  try {
8156
- const res = await call('wbInstallState');
8157
- if (res?.ok) setInstallRecords(Array.isArray(res.items) ? res.items : []);
9113
+ observer = new IntersectionObserver((entries) => {
9114
+ if (entries.some((entry) => entry && entry.isIntersecting)) setNear(true);
9115
+ }, { rootMargin });
9116
+ observer.observe(nodeRef.current);
8158
9117
  } catch {
8159
- // 安装状态是辅助信息,读失败不打断市场面板主流程。
8160
- setInstallRecords([]);
9118
+ // 构造 / 挂载失败(老内核不认 rootMargin 这类参数):当作已在视口,别把头像锁死。
9119
+ setNear(true);
8161
9120
  }
8162
- }, [call]);
8163
- const installedKeySet = (kind) => new Set(
8164
- installRecords.filter((row) => row && row.kind === kind).map((row) => String(row.key || '')),
8165
- );
9121
+ return () => {
9122
+ if (observer) observer.disconnect();
9123
+ };
9124
+ }, [near, rootMargin]);
9125
+ return [nodeRef, near];
9126
+ };
8166
9127
 
8167
- const loadConnectors = useCallback(async () => {
9128
+ /**
9129
+ * 统一的头像位。`url` 空、还没进视口范围、或还没探到 → 字母;探到了 → <img>。
9130
+ * 地址在这一层先过一道 `avatarRouteCandidates`:GitHub 系换成代理小图(包括 wbRepoMeta
9131
+ * 给的 canonical `/u/<id>?v=4`,那是原图尺寸、直连又慢),别的域名原样透传。归一化收在
9132
+ * 这层而不是散到各调用点,十个头像位才不会有人漏掉。默认尺寸:卡片和它下面那行
9133
+ * PresenceLine 传的是同一个 owner,只有两边算出**同一个字符串**才合并成一次请求。
9134
+ * 状态用 `ready = { key, src }` 而不是布尔:切换详情时 key 变了但布尔还留着 true,
9135
+ * 会有一帧拿旧状态去绘新 src,这里靠「ready.key !== 当前 key 即回落字母」挡掉;
9136
+ * src 单独记是因为出图的可能不是首选路由(备胎),要画就画真正成功的那条地址。
9137
+ * hook 一律声明在组件顶层,条件只控制渲染、不控制声明(本仓库的 React #310 规矩)。
9138
+ */
9139
+ const Avatar = ({ url, name, className }) => {
9140
+ const [nodeRef, near] = useNearViewport();
9141
+ const routes = avatarRouteCandidates(url);
9142
+ const key = routes[0] || '';
9143
+ const letter = String(name || '?').slice(0, 1).toUpperCase() || '?';
9144
+ const [ready, setReady] = useState(null);
9145
+ useEffect(() => {
9146
+ if (!key || !near) return undefined;
9147
+ const rec = avatarRecord(routes);
9148
+ if (rec.status === 'ok') {
9149
+ setReady({ key, src: rec.resolved || key });
9150
+ return undefined;
9151
+ }
9152
+ const done = () => {
9153
+ if (rec.status === 'ok') setReady({ key, src: rec.resolved || key });
9154
+ else if (rec.status === 'fail') setReady(null);
9155
+ };
9156
+ rec.waiters.add(done);
9157
+ return () => {
9158
+ rec.waiters.delete(done);
9159
+ };
9160
+ }, [key, near]);
9161
+ if (!key || !near || !ready || ready.key !== key) {
9162
+ return h('span', { ref: nodeRef, className: `${className} fallback`, 'aria-hidden': true }, letter);
9163
+ }
9164
+ return h('img', {
9165
+ ref: nodeRef,
9166
+ className,
9167
+ src: ready.src,
9168
+ alt: '',
9169
+ // 字节已经在浏览器缓存里(探到了才画这张图),所以**不写** `loading="lazy"`:
9170
+ // 那等于对一张必定要画的图说「晚点再取」,是自相矛盾的写法。
9171
+ // decoding / fetchPriority 是给少见情况兜底:缓存被挤掉、浏览器真要回源时,
9172
+ // 别抢别的请求的优先级、也别同步解码卡住主线程(一次可能有好几张同时换成图)。
9173
+ decoding: 'async',
9174
+ fetchPriority: 'low',
9175
+ referrerPolicy: 'no-referrer',
9176
+ // 缓存被挤掉、图片中途失效这类少见情况:退回字母,不留破图。
9177
+ onError: () => setReady(null),
9178
+ });
9179
+ };
9180
+ const timestampOf = (value) => {
9181
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
9182
+ const parsed = Date.parse(String(value || ''));
9183
+ return Number.isFinite(parsed) ? parsed : 0;
9184
+ };
9185
+ const relativeLabel = (value) => {
9186
+ const at = timestampOf(value);
9187
+ return at ? fmtLast(at) : '';
9188
+ };
9189
+ const communityStatus = (meta) => {
9190
+ if (!meta) return null;
9191
+ if (meta.archived === true) return { cls: 's-warn dot', label: t('已归档') };
9192
+ const at = timestampOf(meta.pushedAt || meta.updatedAt);
9193
+ if (!at) return null;
9194
+ const days = Math.floor((Date.now() - at) / 86_400_000);
9195
+ if (days <= 30) return { cls: 's-on dot', label: t('近 30 天有提交') };
9196
+ if (days <= 180) return { cls: '', label: t('最近更新 {0}', [relativeLabel(at)]) };
9197
+ return { cls: 's-warn dot', label: t('超过半年未更新') };
9198
+ };
9199
+ // 卡片级的轻量「人」行:不发网络请求,只用目录里已经带回来的发布方 / 头像 / 时间。
9200
+ const PresenceLine = ({ owner, avatar, updatedAt, href, note, className }) => {
9201
+ const at = timestampOf(updatedAt);
9202
+ const meta = at ? t('更新于 {0}', [relativeLabel(at)]) : (note || '');
9203
+ const name = String(owner || '').trim();
9204
+ if (!name && !meta) return null;
9205
+ return h('div', { className: 'dsh-mcp-presence-inline' + (className ? ' ' + className : '') },
9206
+ // 头像与发布方都没有时仍然什么都不画(这条分支是 PresenceLine 的既有契约);
9207
+ // 只要有其一,就交给 Avatar:url 为空它会直接出字母,和原来「没图就字母」一致,
9208
+ // 而 url 非空时首帧也是字母,直到后台探到图片才换 —— 这正是合并请求后不重复发的那条路。
9209
+ avatar || name
9210
+ ? h(Avatar, { url: avatar, name, className: 'dsh-mcp-presence-inline-avatar' })
9211
+ : null,
9212
+ name
9213
+ ? h('span', { className: 'dsh-mcp-presence-inline-owner' },
9214
+ href ? h('a', { href, target: '_blank', rel: 'noreferrer' }, name) : name)
9215
+ : null,
9216
+ meta ? h('span', { className: 'dsh-mcp-presence-inline-note' }, meta) : null,
9217
+ );
9218
+ };
9219
+ // ── 市场连接器卡片头像 ──────────────────────────────────────────────────
9220
+ // 连接器没有 GitHub 仓库可考,图标本体在官方分发包 ZIP 的 icons/<id>.<ext> 里,
9221
+ // 宿主按需出 data URL(wbConnectorIcon)。与 Avatar(远程 URL 探活)分开:
9222
+ // data URL 不走网络,占探活闸门的并发名额没有意义;但「进视口才取、取到就缓存」
9223
+ // 的纪律一致 —— 客户端按 id 缓存,卡片、详情弹层、翻页重挂载都只发一次 RPC;
9224
+ // 探过且没有的记 null,同样不再发。RPC 失败不记缓存,下次进视口还能重试。
9225
+ const connectorIconCache = new Map();
9226
+ const connectorIconPending = new Map();
9227
+ const ConnectorAvatar = ({ call, connectorId, name, className }) => {
9228
+ const [nodeRef, near] = useNearViewport();
9229
+ const key = String(connectorId || '').trim();
9230
+ const letter = String(name || key || '?').slice(0, 1).toUpperCase() || '?';
9231
+ const [src, setSrc] = useState(() => connectorIconCache.get(key) || '');
9232
+ useEffect(() => {
9233
+ setSrc(connectorIconCache.get(key) || '');
9234
+ if (!key || !near || connectorIconCache.has(key)) return undefined;
9235
+ let alive = true;
9236
+ let pending = connectorIconPending.get(key);
9237
+ if (!pending) {
9238
+ pending = call('wbConnectorIcon', { connectorId: key })
9239
+ .then((res) => {
9240
+ // 两层 ok:false 语义不同,缓存策略也不同:
9241
+ // · 信封层的 ok:false 带 error(方法 404 / 宿主抛错 / 服务未连接)——是
9242
+ // **暂时**的(宿主还没重载等),不记缓存,下次进视口重试;
9243
+ // · 宿主业务层的 ok:false 不带 error(目录里就是没图标)——是**最终**的,
9244
+ // 记 null,同一会话不再发。
9245
+ if (res?.ok) {
9246
+ const dataUrl = String(res.dataUrl || '');
9247
+ connectorIconCache.set(key, dataUrl || null);
9248
+ return dataUrl;
9249
+ }
9250
+ if (!res?.error) connectorIconCache.set(key, null);
9251
+ return '';
9252
+ })
9253
+ .catch(() => '')
9254
+ .finally(() => { connectorIconPending.delete(key); });
9255
+ connectorIconPending.set(key, pending);
9256
+ }
9257
+ pending.then((dataUrl) => { if (alive && dataUrl) setSrc(dataUrl); });
9258
+ return () => { alive = false; };
9259
+ }, [call, key, near]);
9260
+ if (!src) {
9261
+ return h('span', { ref: nodeRef, className: `${className} fallback`, 'aria-hidden': true }, letter);
9262
+ }
9263
+ return h('img', {
9264
+ ref: nodeRef,
9265
+ className,
9266
+ src,
9267
+ alt: '',
9268
+ decoding: 'async',
9269
+ referrerPolicy: 'no-referrer',
9270
+ // data URL 一般不会中途失效;真坏了退回字母,不留破图。
9271
+ onError: () => setSrc(''),
9272
+ });
9273
+ };
9274
+ // 详情级的维护信号:打开详情时按需查一次 GitHub(宿主侧 6 小时缓存)。
9275
+ // 单条仓库查不到不阻塞详情——只把这一行收掉,正文照常可看。
9276
+ const CommunityPresence = ({ call, repo, owner, avatar, updatedAt, compact }) => {
9277
+ const key = parseGithubRepoKey(repo);
9278
+ const [state, setState] = useState({ loading: Boolean(key), meta: null, error: '' });
9279
+ useEffect(() => {
9280
+ if (!key || typeof call !== 'function') {
9281
+ setState({ loading: false, meta: null, error: '' });
9282
+ return undefined;
9283
+ }
9284
+ let alive = true;
9285
+ setState({ loading: true, meta: null, error: '' });
9286
+ call('wbRepoMeta', { repos: [key] }).then((res) => {
9287
+ if (!alive) return;
9288
+ const row = res && res.repos ? res.repos[key] : null;
9289
+ setState({
9290
+ loading: false,
9291
+ meta: row && row.ok === true ? row : null,
9292
+ error: row && row.ok !== true ? String(row.error || '') : '',
9293
+ });
9294
+ }).catch((error) => {
9295
+ if (alive) setState({ loading: false, meta: null, error: String(error?.message || error || '') });
9296
+ });
9297
+ return () => { alive = false; };
9298
+ }, [call, key]);
9299
+ const meta = state.meta;
9300
+ const ownerName = String(meta?.owner || owner || (key ? key.split('/')[0] : '')).trim();
9301
+ const ownerUrl = meta?.ownerUrl || (key ? `https://github.com/${key.split('/')[0]}` : '');
9302
+ const avatarUrl = meta?.ownerAvatar || avatar || (key ? githubAvatarUrl(key) : '');
9303
+ const status = communityStatus(meta);
9304
+ const pushedAt = timestampOf(meta?.pushedAt);
9305
+ const fromUpstream = (compact ? ' dsh-mcp-presence-compact' : '');
9306
+ return h('div', { className: 'dsh-mcp-presence' + fromUpstream },
9307
+ h(Avatar, { url: avatarUrl, name: ownerName, className: 'dsh-mcp-presence-avatar' }),
9308
+ h('div', { className: 'dsh-mcp-presence-body' },
9309
+ h('div', { className: 'dsh-mcp-presence-line' },
9310
+ ownerName
9311
+ ? (ownerUrl
9312
+ ? h('a', { className: 'dsh-mcp-presence-owner', href: ownerUrl, target: '_blank', rel: 'noreferrer', title: t('打开维护者主页') }, ownerName)
9313
+ : h('span', { className: 'dsh-mcp-presence-owner' }, ownerName))
9314
+ : h('span', { className: 'dsh-mcp-presence-owner' }, t('来源未标注')),
9315
+ status ? h(Badge, { cls: status.cls }, status.label) : null,
9316
+ state.loading ? h('span', { className: 'dsh-mcp-presence-loading' }, t('读取维护动态…')) : null,
9317
+ ),
9318
+ h('div', { className: 'dsh-mcp-presence-meta' },
9319
+ meta
9320
+ ? [
9321
+ pushedAt ? h('span', { key: 'pushed' }, t('最近提交 {0}', [relativeLabel(pushedAt)])) : null,
9322
+ h('span', { key: 'issues' }, t('{0} 个开放 issue', [fmtInt(meta.openIssues)])),
9323
+ h('span', { key: 'stars' }, '★ ' + wbFormatCount(meta.stars)),
9324
+ meta.license ? h('span', { key: 'license' }, meta.license) : null,
9325
+ meta.language ? h('span', { key: 'lang' }, meta.language) : null,
9326
+ ]
9327
+ : h('span', null,
9328
+ updatedAt
9329
+ ? t('更新于 {0}', [relativeLabel(updatedAt)])
9330
+ : state.error
9331
+ ? t('维护动态读取失败:{0}', [state.error])
9332
+ : key
9333
+ ? t('维护动态读取中…')
9334
+ : t('这个条目没有公开仓库')),
9335
+ ),
9336
+ ),
9337
+ );
9338
+ };
9339
+ const feedbackKindLabel = (kind) =>
9340
+ kind === 'skill' ? t('技能') : kind === 'expert' ? t('专家') : kind === 'plugin' ? t('插件') : kind === 'connector' ? t('连接器') : t('条目');
9341
+ const feedbackTemplate = (item, kind, repo, homepage) => {
9342
+ const name = String(item?.name || item?.id || item?.plugin || item?.slug || '').trim() || t('未命名条目');
9343
+ const version = String(item?.version || '').trim();
9344
+ const source = String(repo || item?.repo || item?.repository || homepage || item?.homepage || item?.upstreamUrl || '').trim();
9345
+ const env = typeof navigator !== 'undefined' ? String(navigator.userAgent || '') : '';
9346
+ return [
9347
+ '### ' + t('条目:{0}', [name]),
9348
+ '- ' + t('类型:{0}', [feedbackKindLabel(kind)]),
9349
+ source ? '- ' + t('来源:{0}', [source]) : '',
9350
+ version ? '- ' + t('版本:{0}', [version]) : '',
9351
+ '',
9352
+ '### ' + t('发生了什么'),
9353
+ '',
9354
+ '',
9355
+ '### ' + t('环境'),
9356
+ '- ' + t('系统:{0}', [env]),
9357
+ ].filter(Boolean).join('\n');
9358
+ };
9359
+ // 真实社区出口:GitHub 仓库 / Issue / 复制模板 / 给军械库提建议。
9360
+ // 刻意不做站内论坛、不做点赞收藏——那些只会把「有没有人」变成一种布景。
9361
+ // 所有链接都在新标签页打开,正文先给用户看,点了才提交。
9362
+ const FeedbackLinks = ({ item, kind, repo, homepage, sourceLabel }) => {
9363
+ const [copied, setCopied] = useState(false);
9364
+ const name = String(item?.name || item?.id || item?.plugin || item?.slug || '').trim() || t('未命名条目');
9365
+ const repoKey = parseGithubRepoKey(repo || item?.repo || item?.repository);
9366
+ const repoUrl = repoKey ? `https://github.com/${repoKey}` : '';
9367
+ const home = String(homepage || item?.homepage || item?.upstreamUrl || '').trim();
9368
+ const homeUrl = /^https?:\/\//i.test(home) && home !== repoUrl ? home : '';
9369
+ const template = feedbackTemplate(item, kind, repoUrl || homeUrl, homeUrl);
9370
+ const title = t('{0} · {1} 的反馈', [feedbackKindLabel(kind), name]);
9371
+ const issueUrl = ARMORY_REPO_URL + '/issues/new?title=' + encodeURIComponent(title) + '&body=' + encodeURIComponent(template);
9372
+ return h('div', { className: 'dsh-mcp-feedback' },
9373
+ h('div', { className: 'dsh-mcp-feedback-head' },
9374
+ h('b', null, t('找到真实的人')),
9375
+ sourceLabel ? h('span', { className: 'dsh-mcp-feedback-source' }, sourceLabel) : null,
9376
+ ),
9377
+ h('div', { className: 'dsh-mcp-feedback-actions' },
9378
+ repoUrl ? h('a', { className: 'dsh-mcp-btn', href: repoUrl, target: '_blank', rel: 'noreferrer' }, t('去仓库')) : null,
9379
+ repoUrl ? h('a', { className: 'dsh-mcp-btn', href: repoUrl + '/issues', target: '_blank', rel: 'noreferrer' }, t('看讨论 / 提 Issue')) : null,
9380
+ homeUrl ? h('a', { className: 'dsh-mcp-btn', href: homeUrl, target: '_blank', rel: 'noreferrer' }, t('来源主页')) : null,
9381
+ h('a', { className: 'dsh-mcp-btn', href: issueUrl, target: '_blank', rel: 'noreferrer' }, t('向军械库反馈')),
9382
+ h('button', {
9383
+ type: 'button', className: 'dsh-mcp-btn',
9384
+ onClick: async () => { setCopied(await copyText(template)); },
9385
+ }, copied ? t('已复制模板') : t('复制反馈模板')),
9386
+ ),
9387
+ h('p', { className: 'dsh-mcp-feedback-note' },
9388
+ t('反馈会打开 GitHub 新标签页;内容先由你过目,确认后才提交。')),
9389
+ );
9390
+ };
9391
+
9392
+ // ── 顶栏「点 Star」:把用户送到 GitHub 那个真正的 Star 按钮前面 ─────────────
9393
+ // 为什么放在分类栏右端而不是市场页里:这是整个插件的门面位,五个页签都看得到。
9394
+ // 「给项目点个 Star」本来就不该藏在某一个子页的详情里。
9395
+ //
9396
+ // 边界(刻意的,别改成"真一键"):GitHub 不支持匿名 star——PUT /user/starred 必须带
9397
+ // 用户自己的凭据。插件不做站内账号、不存 token(见 docs/design.md 的边界一节),
9398
+ // 所以这里只诚实做两件事:显示上游**真实**的 star 数,和一键打开仓库页让用户按
9399
+ // GitHub 自己的 Star。不写本地「已 star」状态:那是一条没人能验证的布景,
9400
+ // 而且会误导用户以为已经点过了。
9401
+ //
9402
+ // 数字走宿主侧 wbRepoMeta(6 小时缓存 + ETag + 限流回落),客户端不自己打 api.github.com。
9403
+ // 读不到就不显示数字,按钮本身照常可用——它不该因为一个计数失败而消失。
9404
+ const ARMORY_REPO_KEY = parseGithubRepoKey(ARMORY_REPO_URL);
9405
+ const RepoStarChip = ({ call }) => {
9406
+ const [stars, setStars] = useState(null);
9407
+ // 点击爆裂动画靠 class 开关驱动;burstKey 让「连点」也能重放(同 class 不会重启动画)。
9408
+ const [burstKey, setBurstKey] = useState(0);
9409
+ useEffect(() => {
9410
+ if (typeof call !== 'function' || !ARMORY_REPO_KEY) return undefined;
9411
+ let alive = true;
9412
+ call('wbRepoMeta', { repos: [ARMORY_REPO_KEY] }).then((res) => {
9413
+ const row = alive && res && res.repos ? res.repos[ARMORY_REPO_KEY] : null;
9414
+ if (row && row.ok === true && typeof row.stars === 'number') setStars(row.stars);
9415
+ }).catch(() => { /* 保留 null:宁可不显示数字,也不显示一个假的 */ });
9416
+ return () => { alive = false; };
9417
+ }, [call]);
9418
+ useEffect(() => {
9419
+ if (!burstKey) return undefined;
9420
+ const timer = setTimeout(() => setBurstKey(0), 620);
9421
+ return () => clearTimeout(timer);
9422
+ }, [burstKey]);
9423
+ return h('a', {
9424
+ className: 'dsh-mcp-star' + (burstKey ? ' burst' : ''),
9425
+ href: ARMORY_REPO_URL,
9426
+ target: '_blank',
9427
+ rel: 'noreferrer',
9428
+ title: t('在 GitHub 打开 dsh-armory 仓库,点右上角的 Star'),
9429
+ 'aria-label': t('给 dsh-armory 点 Star(新标签页打开 GitHub 仓库)'),
9430
+ onClick: () => setBurstKey((n) => n + 1),
9431
+ },
9432
+ h('span', { className: 'dsh-mcp-star-spark a', 'aria-hidden': true }),
9433
+ h('span', { className: 'dsh-mcp-star-spark b', 'aria-hidden': true }),
9434
+ h('svg', { className: 'dsh-mcp-star-svg', viewBox: '0 0 16 16', 'aria-hidden': true },
9435
+ h('path', { d: 'M8 1l1.7 4.65 4.96.19-3.9 3.14 1.37 4.8L8 10.6l-4.13 3.18 1.37-4.8-3.9-3.14 4.96-.19z' })),
9436
+ h('span', { className: 'dsh-mcp-star-label' }, t('点 Star')),
9437
+ // 数字没读到就整段不出现:空位比「★ —」诚实。
9438
+ stars === null ? null : h('span', { className: 'dsh-mcp-star-count' }, wbFormatCount(stars)),
9439
+ );
9440
+ };
9441
+
9442
+ // ── 装备清单:把本机配置导出成可分享的 JSON,导入只做差异对比 ──────────────
9443
+ // 与「最近动静」同一原则:不搭后端、不做账号、不自动装。清单只含身份字段,
9444
+ // 敏感值在宿主侧就被投影掉(lib/loadout.js 的白名单),客户端拿不到也不该存。
9445
+ const LOADOUT_KIND_ORDER = ['mcp', 'skill', 'expert', 'plugin'];
9446
+ const loadoutKindLabel = (kind) =>
9447
+ kind === 'mcp' ? t('MCP') : kind === 'skill' ? t('技能') : kind === 'expert' ? t('专家') : kind === 'plugin' ? t('插件') : t('条目');
9448
+ const loadoutSourceLabel = (source) => {
9449
+ const value = String(source || '').trim();
9450
+ if (!value) return '';
9451
+ if (value.startsWith('builtin:')) return t('内置 · {0}', [value.slice(8)]);
9452
+ if (value.startsWith('connector:')) return t('连接器 · {0}', [value.slice(10)]);
9453
+ if (value === 'manual') return t('手动配置');
9454
+ if (value === 'workbuddy-expert') return t('WorkBuddy 专家中心');
9455
+ if (value === 'skillhub' || value === 'workbuddy-skills') return t('SkillHub');
9456
+ if (value === 'codebuddy-plugins-official') return t('CodeBuddy 官方');
9457
+ if (value === 'awesome') return t('DSH 社区');
9458
+ if (value === 'local') return t('本机 / 自带');
9459
+ return value;
9460
+ };
9461
+ const loadoutStatusMeta = (status) =>
9462
+ status === 'update'
9463
+ ? { cls: 's-warn', label: t('可更新') }
9464
+ : status === 'missing'
9465
+ ? { cls: 's-warn', label: t('本机没有') }
9466
+ : { cls: 's-on', label: t('已安装') };
9467
+ const loadoutCountText = (loadout) => {
9468
+ const counts = loadout && loadout.counts ? loadout.counts : {};
9469
+ const parts = LOADOUT_KIND_ORDER
9470
+ .filter((kind) => Number(counts[kind]) > 0)
9471
+ .map((kind) => `${loadoutKindLabel(kind)} ${counts[kind]}`);
9472
+ return parts.length ? parts.join(' · ') : t('清单为空。');
9473
+ };
9474
+ const loadoutFileNameOf = (name) => {
9475
+ const base = String(name || '').replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '');
9476
+ return `${base || 'dsh-loadout'}.json`;
9477
+ };
9478
+ const downloadLoadoutFile = (filename, text) => {
9479
+ try {
9480
+ const blob = new Blob([text], { type: 'application/json;charset=utf-8' });
9481
+ const url = URL.createObjectURL(blob);
9482
+ const anchor = document.createElement('a');
9483
+ anchor.href = url;
9484
+ anchor.download = filename;
9485
+ document.body.appendChild(anchor);
9486
+ anchor.click();
9487
+ anchor.remove();
9488
+ setTimeout(() => URL.revokeObjectURL(url), 0);
9489
+ return true;
9490
+ } catch {
9491
+ return false;
9492
+ }
9493
+ };
9494
+
9495
+ const LoadoutModal = ({ call, onClose, onLocate }) => {
9496
+ const dialogRef = useRef(null);
9497
+ useFocusTrap(dialogRef, true);
9498
+ const [mode, setMode] = useState('export');
9499
+ const [name, setName] = useState('');
9500
+ const [loadout, setLoadout] = useState(null);
9501
+ const [text, setText] = useState('');
9502
+ const [busy, setBusy] = useState(false);
9503
+ const [error, setError] = useState('');
9504
+ const [note, setNote] = useState('');
9505
+ const [copied, setCopied] = useState('');
9506
+ const [importText, setImportText] = useState('');
9507
+ const [diff, setDiff] = useState(null);
9508
+
9509
+ const generate = useCallback(async (customName) => {
9510
+ setBusy(true); setError(''); setNote('');
9511
+ try {
9512
+ const res = await call('wbLoadoutExport', { name: String(customName ?? '').trim() });
9513
+ if (!res?.ok || !res.loadout) throw new Error(res?.error || t('导出失败'));
9514
+ setLoadout(res.loadout);
9515
+ setText(JSON.stringify(res.loadout, null, 2));
9516
+ if (!String(customName ?? name ?? '').trim()) setName(res.loadout.name || '');
9517
+ return res.loadout;
9518
+ } catch (e) {
9519
+ setError(t('生成失败:{0}', [String(e?.message || e)]));
9520
+ return null;
9521
+ } finally {
9522
+ setBusy(false);
9523
+ }
9524
+ }, [call, name]);
9525
+
9526
+ useEffect(() => { generate(''); }, []);
9527
+
9528
+ const markCopied = (tag) => {
9529
+ setCopied(tag);
9530
+ setTimeout(() => setCopied(''), 1600);
9531
+ };
9532
+ const copyJson = async () => {
9533
+ if (!text) return;
9534
+ if (await copyText(text)) {
9535
+ setNote(t('已复制 JSON'));
9536
+ markCopied('json');
9537
+ }
9538
+ };
9539
+ const download = () => {
9540
+ if (!text) return;
9541
+ if (downloadLoadoutFile(loadoutFileNameOf(loadout?.name || name), text)) setNote(t('已开始下载'));
9542
+ else setNote(t('浏览器不允许下载,请改用复制 JSON'));
9543
+ };
9544
+ const copyName = async (value) => {
9545
+ if (await copyText(String(value || ''))) markCopied('name:' + String(value || ''));
9546
+ };
9547
+ const readFile = async (event) => {
9548
+ const file = event.target.files && event.target.files[0];
9549
+ event.target.value = '';
9550
+ if (!file) return;
9551
+ if (file.size > 256 * 1024) {
9552
+ setError(t('文件过大(上限 256KB)'));
9553
+ return;
9554
+ }
9555
+ try {
9556
+ setImportText(await file.text());
9557
+ setDiff(null);
9558
+ setError('');
9559
+ } catch (e) {
9560
+ setError(t('文件读取失败:{0}', [String(e?.message || e)]));
9561
+ }
9562
+ };
9563
+ const compare = async () => {
9564
+ const raw = importText.trim();
9565
+ if (!raw) {
9566
+ setError(t('先粘贴一份清单,或选择 .json 文件。'));
9567
+ return;
9568
+ }
9569
+ setBusy(true); setError(''); setNote(''); setDiff(null);
9570
+ try {
9571
+ let parsed;
9572
+ try {
9573
+ parsed = JSON.parse(raw);
9574
+ } catch (e) {
9575
+ throw new Error(t('不是有效的 JSON:{0}', [String(e?.message || e)]));
9576
+ }
9577
+ const res = await call('wbLoadoutDiff', { loadout: parsed });
9578
+ if (!res?.ok) throw new Error(res?.error || t('对比失败'));
9579
+ setDiff(res);
9580
+ } catch (e) {
9581
+ setError(String(e?.message || e));
9582
+ } finally {
9583
+ setBusy(false);
9584
+ }
9585
+ };
9586
+
9587
+ return h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && onClose() },
9588
+ h('div', {
9589
+ ref: dialogRef, className: 'dsh-mcp-modal dsh-mcp-loadout-modal', role: 'dialog', 'aria-modal': true,
9590
+ 'aria-label': t('装备清单'), tabIndex: -1,
9591
+ onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); onClose(); } },
9592
+ },
9593
+ h('h3', null, t('装备清单')),
9594
+ h('div', { className: 'dsh-mcp-loadout-tabs', role: 'tablist' },
9595
+ h('button', {
9596
+ type: 'button', role: 'tab', 'aria-selected': mode === 'export',
9597
+ className: 'dsh-mcp-loadout-tab', onClick: () => setMode('export'),
9598
+ }, t('导出')),
9599
+ h('button', {
9600
+ type: 'button', role: 'tab', 'aria-selected': mode === 'import',
9601
+ className: 'dsh-mcp-loadout-tab', onClick: () => setMode('import'),
9602
+ }, t('导入对比')),
9603
+ ),
9604
+ mode === 'export'
9605
+ ? h('div', { className: 'dsh-mcp-loadout-pane' },
9606
+ h('p', { className: 'dsh-mcp-loadout-hint' },
9607
+ t('把本机的 MCP、技能、专家与插件导出成一份可分享的清单;导入只做对比,不会自动安装。')),
9608
+ h('label', { className: 'dsh-mcp-loadout-name' },
9609
+ h('span', null, t('清单名称')),
9610
+ h('input', {
9611
+ value: name, maxLength: 120, disabled: busy,
9612
+ placeholder: t('我的 DSH 装备清单'),
9613
+ onChange: (e) => setName(e.target.value),
9614
+ }),
9615
+ h(Btn, { onClick: () => generate(name), disabled: busy || !name.trim(), children: busy ? t('读取本机配置…') : t('重新生成') }),
9616
+ ),
9617
+ loadout
9618
+ ? h('div', { className: 'dsh-mcp-loadout-result' },
9619
+ h('div', { className: 'dsh-mcp-loadout-summary' },
9620
+ h('span', null, loadoutCountText(loadout)),
9621
+ loadout.truncated ? h('span', { className: 'dsh-mcp-loadout-warn' }, t('超过 200 条,只导出前 200 条。')) : null,
9622
+ ),
9623
+ h('pre', { className: 'dsh-mcp-loadout-json' }, text),
9624
+ h('div', { className: 'dsh-mcp-loadout-actions' },
9625
+ h(Btn, { onClick: copyJson, disabled: !text, children: copied === 'json' ? t('已复制') : t('复制 JSON') }),
9626
+ h(Btn, { onClick: download, disabled: !text, children: t('下载 .json') }),
9627
+ h(Btn, { onClick: () => setMode('import'), children: t('导入对比') }),
9628
+ ),
9629
+ )
9630
+ : h('div', { className: 'dsh-mcp-wb-empty' }, busy ? t('读取本机配置…') : t('还没有生成清单。')),
9631
+ h('p', { className: 'dsh-mcp-loadout-note' },
9632
+ t('这份清单只含名称、类型、来源与版本;不会写入密钥、header 或环境变量的值。')),
9633
+ )
9634
+ : h('div', { className: 'dsh-mcp-loadout-pane' },
9635
+ h('p', { className: 'dsh-mcp-loadout-hint' },
9636
+ t('把朋友发来的清单贴在这里,或选择文件。导入只做对比,不会自动安装。')),
9637
+ h('textarea', {
9638
+ className: 'dsh-mcp-loadout-input', value: importText, rows: 8, spellCheck: false,
9639
+ placeholder: t('粘贴清单 JSON…'),
9640
+ onChange: (e) => { setImportText(e.target.value); setDiff(null); setError(''); },
9641
+ }),
9642
+ h('div', { className: 'dsh-mcp-loadout-actions' },
9643
+ h('label', { className: 'dsh-mcp-btn dsh-mcp-loadout-file' },
9644
+ t('选择 .json 文件'),
9645
+ h('input', { type: 'file', accept: '.json,application/json', hidden: true, onChange: readFile }),
9646
+ ),
9647
+ h(Btn, { onClick: compare, disabled: busy || !importText.trim(), children: busy ? t('对比中…') : t('对比本机') }),
9648
+ importText ? h('button', {
9649
+ type: 'button', className: 'dsh-mcp-btn',
9650
+ onClick: () => { setImportText(''); setDiff(null); setError(''); },
9651
+ }, t('清空')) : null,
9652
+ ),
9653
+ diff
9654
+ ? h('div', { className: 'dsh-mcp-loadout-result' },
9655
+ h('div', { className: 'dsh-mcp-loadout-summary' },
9656
+ h('span', null, t('共 {0} 项 · 已安装 {1} · 可更新 {2} · 本机没有 {3}', [
9657
+ diff.counts?.total || 0, diff.counts?.installed || 0, diff.counts?.update || 0, diff.counts?.missing || 0,
9658
+ ])),
9659
+ diff.loadout?.name ? h('span', { className: 'dsh-mcp-loadout-from' }, t('清单来自 {0}', [diff.loadout.name])) : null,
9660
+ ),
9661
+ h('div', { className: 'dsh-mcp-loadout-rows' },
9662
+ (diff.rows || []).map((row) => {
9663
+ const meta = loadoutStatusMeta(row.status);
9664
+ const nameKey = 'name:' + String(row.name || '');
9665
+ return h('div', { key: String(row.kind) + ':' + String(row.name), className: 'dsh-mcp-loadout-row' },
9666
+ h('div', { className: 'dsh-mcp-loadout-row-main' },
9667
+ h('div', { className: 'dsh-mcp-loadout-row-title' },
9668
+ h('span', { className: 'dsh-mcp-loadout-kind' }, loadoutKindLabel(row.kind)),
9669
+ h('b', null, row.name),
9670
+ h(Badge, { cls: meta.cls }, meta.label),
9671
+ ),
9672
+ h('div', { className: 'dsh-mcp-loadout-row-meta' },
9673
+ row.source ? h('span', null, t('来源:{0}', [loadoutSourceLabel(row.source)])) : null,
9674
+ row.version ? h('span', null, t('版本:{0}', [row.version])) : null,
9675
+ row.localVersion && row.localVersion !== row.version ? h('span', null, t('本机:{0}', [row.localVersion])) : null,
9676
+ ),
9677
+ ),
9678
+ h('div', { className: 'dsh-mcp-loadout-row-actions' },
9679
+ row.status !== 'installed' ? h('button', {
9680
+ type: 'button', className: 'dsh-mcp-btn',
9681
+ onClick: () => onLocate(row),
9682
+ }, t('去市场')) : null,
9683
+ h('button', {
9684
+ type: 'button', className: 'dsh-mcp-btn',
9685
+ onClick: () => copyName(row.name),
9686
+ }, copied === nameKey ? t('已复制名称') : t('复制名称')),
9687
+ ),
9688
+ );
9689
+ }),
9690
+ ),
9691
+ )
9692
+ : null,
9693
+ h('p', { className: 'dsh-mcp-loadout-note' },
9694
+ t('导入只做对比;点「去市场」逐项安装,不会自动写入任何配置。')),
9695
+ ),
9696
+ error ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, error) : null,
9697
+ note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
9698
+ h('div', { className: 'dsh-mcp-modal-actions' },
9699
+ h(Btn, { onClick: onClose, disabled: busy, children: t('关闭') }),
9700
+ ),
9701
+ ),
9702
+ );
9703
+ };
9704
+
9705
+ // ── 最近动静:本机快照对比,不联网、不统计、不伪造「在线人数」───────────────
9706
+ // 语义边界(必须诚实):
9707
+ // - 只对比「这台机器上次成功读取目录」与「这一次读取」的差异;
9708
+ // - 只有**同一份清单**的两次读取才互为基线(basis,见 recordMarketSnapshot):翻页切片、
9709
+ // 带搜索词的结果、不同排序的首页是三个不同的集合,拿来互比只会造出假动静;
9710
+ // - 只记录新增 / 移除 / 版本或核验状态变化,不做消息流,也不假装实时;
9711
+ // - 没有变化就直说没有;事件 7 天过期,用户可一键清除。
9712
+ const MARKET_PULSE_KEY = KEY_PREFIX + '.marketPulse.v2';
9713
+ // v2 的由来(2026-10-24 用户报「切换页面重新进入时重复读取」):v1 的技能基线取的是
9714
+ // SkillHub「下载量(本页排序)」首页,而这一档根本不给上游发 sortBy(见 wb-market.js 的
9715
+ // SKILLHUB_REMOTE_SORTS)——无 ORDER BY 的 LIMIT 20 每次返回的是 19 万库里随机 20 条
9716
+ // (实测:同一请求隔 2.5 秒重合 0/20,而 sortBy=installs 重合 20/20)。于是每进一次市场
9717
+ // 就记一对「新增 20 / 移除 20」。旧键里攒下来的全是这种假动静,没有一条可信到值得挑出来
9718
+ // 保留,所以读到即整份丢弃、不搬值。
9719
+ const LEGACY_MARKET_PULSE_KEY = KEY_PREFIX + '.marketPulse.v1';
9720
+ // 两次读取的键重合度低于此值 ⇒ 读到的不是同一份清单(上游换了默认排序、目录整批改写、
9721
+ // 或一次被截断的部分响应)。这种差异报出来就是「全增全删」的噪音,闭嘴重新起底更诚实。
9722
+ const MARKET_PULSE_MIN_OVERLAP = 0.1;
9723
+ const MARKET_PULSE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
9724
+ const MARKET_PULSE_MAX_EVENTS = 8;
9725
+ const MARKET_PULSE_SOURCES = {
9726
+ awesome: { get label() { return t('DSH 社区'); }, tab: 'plugin' },
9727
+ expert: { get label() { return t('专家中心'); }, tab: 'expert' },
9728
+ skill: { get label() { return t('SkillHub'); }, tab: 'skill' },
9729
+ connector: { get label() { return t('连接器市场'); }, tab: 'connector' },
9730
+ };
9731
+ const emptyMarketPulse = () => ({ version: 2, snapshots: {}, events: [] });
9732
+ const readMarketPulse = () => {
9733
+ try {
9734
+ // 旧键一次性丢弃(removeItem 幂等;隐私模式下和下面的 getItem 一起被同一个 catch 兜住)。
9735
+ localStorage.removeItem(LEGACY_MARKET_PULSE_KEY);
9736
+ const raw = JSON.parse(localStorage.getItem(MARKET_PULSE_KEY) || 'null');
9737
+ if (!raw || raw.version !== 2 || typeof raw.snapshots !== 'object' || !Array.isArray(raw.events)) return emptyMarketPulse();
9738
+ const now = Date.now();
9739
+ // CodeBuddy 插件源已移除:旧快照与「最近动静」里 source='plugin' 的记录一并丢弃,
9740
+ // 否则会留下一条点不进去、也没有市场对应的历史事件。
9741
+ const snapshots = { ...raw.snapshots };
9742
+ delete snapshots.plugin;
9743
+ return {
9744
+ version: 2,
9745
+ snapshots,
9746
+ events: raw.events.filter((event) => event && event.source !== 'plugin' && now - Number(event.at || 0) <= MARKET_PULSE_TTL_MS).slice(0, MARKET_PULSE_MAX_EVENTS),
9747
+ };
9748
+ } catch { return emptyMarketPulse(); }
9749
+ };
9750
+ const writeMarketPulse = (state) => {
9751
+ try { localStorage.setItem(MARKET_PULSE_KEY, JSON.stringify(state)); } catch { /* 配额 / 隐私模式:静默降级 */ }
9752
+ };
9753
+ const pushPulseEvent = (state, source, kind, keys, current, previous) => {
9754
+ const names = keys.map((key) => current[key]?.n || previous[key]?.n || key).slice(0, 3);
9755
+ state.events.unshift({
9756
+ id: source + ':' + kind + ':' + Date.now() + ':' + Math.random().toString(36).slice(2, 6),
9757
+ source, kind, count: keys.length, names, at: Date.now(),
9758
+ });
9759
+ state.events = state.events.slice(0, MARKET_PULSE_MAX_EVENTS);
9760
+ };
9761
+ /**
9762
+ * 记录一次目录快照并返回是否有变化。
9763
+ * rows: [{ key, name, sig }];sig 是「版本 + 核验状态」这类稳定签名,不用来比较热门度
9764
+ * (星数每天波动,属于噪音,不当作「动静」)。
9765
+ * options.basis: 这份清单的「可比身份」,默认 'full'(整份目录)。只有 basis 相同的两次
9766
+ * 读取才互为基线;不同就静默换基线——不报错、也不造事件。局部视图必须把视图身份写进
9767
+ * basis(技能侧传 'skill:installs' 这类,见 loadSkills)。
9768
+ */
9769
+ const recordMarketSnapshot = (source, rows, options = {}) => {
9770
+ if (!source || !Array.isArray(rows) || !rows.length) return false;
9771
+ const basis = String(options.basis || 'full');
9772
+ const state = readMarketPulse();
9773
+ const current = {};
9774
+ for (const row of rows) {
9775
+ const key = String(row && row.key || '').trim();
9776
+ if (!key) continue;
9777
+ current[key] = { s: String(row.sig || ''), n: String(row.name || key) };
9778
+ }
9779
+ if (!Object.keys(current).length) return false;
9780
+ const last = state.snapshots[source];
9781
+ const previous = last && last.items ? last.items : null;
9782
+ state.snapshots[source] = { at: Date.now(), basis, items: current };
9783
+ // 三条「不比」的路径:没有基线、基线不是同一份清单、两份清单重合度过低。
9784
+ // 都只写新基线、不产事件——宁可少报,也不报假的。
9785
+ if (!previous || last.basis !== basis) {
9786
+ writeMarketPulse(state);
9787
+ return false;
9788
+ }
9789
+ const currentKeys = Object.keys(current);
9790
+ const previousKeys = Object.keys(previous);
9791
+ const shared = currentKeys.reduce((n, key) => n + (key in previous ? 1 : 0), 0);
9792
+ if (shared / Math.max(currentKeys.length, previousKeys.length) < MARKET_PULSE_MIN_OVERLAP) {
9793
+ writeMarketPulse(state);
9794
+ return false;
9795
+ }
9796
+ const added = currentKeys.filter((key) => !(key in previous));
9797
+ const removed = previousKeys.filter((key) => !(key in current));
9798
+ const updated = currentKeys.filter((key) => key in previous && current[key].s !== previous[key].s);
9799
+ if (!added.length && !removed.length && !updated.length) {
9800
+ writeMarketPulse(state);
9801
+ return false;
9802
+ }
9803
+ if (added.length) pushPulseEvent(state, source, 'added', added, current, previous);
9804
+ if (removed.length) pushPulseEvent(state, source, 'removed', removed, current, previous);
9805
+ if (updated.length) pushPulseEvent(state, source, 'updated', updated, current, previous);
9806
+ writeMarketPulse(state);
9807
+ return true;
9808
+ };
9809
+ const clearMarketPulse = () => {
9810
+ try { localStorage.removeItem(MARKET_PULSE_KEY); } catch { /* 隐私模式 */ }
9811
+ };
9812
+ // 子组件(专家市场)写完备忘录后,通知面板重读一次;模块级单监听,组件卸载即摘除。
9813
+ let marketPulseListener = null;
9814
+ const setMarketPulseListener = (listener) => { marketPulseListener = typeof listener === 'function' ? listener : null; };
9815
+ const emitMarketPulse = () => { try { marketPulseListener?.(); } catch { /* 单个监听失败不影响业务 */ } };
9816
+ const MarketPulse = ({ state, onJump, onClear }) => {
9817
+ const events = Array.isArray(state?.events) ? state.events : [];
9818
+ const snapshotAts = Object.values(state?.snapshots || {}).map((row) => Number(row?.at || 0)).filter(Boolean);
9819
+ const lastAt = snapshotAts.length ? Math.max(...snapshotAts) : 0;
9820
+ if (!events.length) {
9821
+ return h('div', { className: 'dsh-mcp-pulse quiet' },
9822
+ h('span', { className: 'dsh-mcp-pulse-title' }, t('最近动静')),
9823
+ h('span', { className: 'dsh-mcp-pulse-quiet' },
9824
+ lastAt
9825
+ ? t('暂无新变化 · 上次对比 {0} · 只在本机对比快照', [relativeLabel(lastAt)])
9826
+ : t('已开始记录市场动态;下次打开时这里会列出真实变化')),
9827
+ );
9828
+ }
9829
+ return h('div', { className: 'dsh-mcp-pulse' },
9830
+ h('div', { className: 'dsh-mcp-pulse-head' },
9831
+ h('b', { className: 'dsh-mcp-pulse-title' }, t('最近动静')),
9832
+ h('span', { className: 'dsh-mcp-pulse-note' }, t('自上次打开市场以来,目录里真实发生的变化(本机快照对比)')),
9833
+ h('button', { type: 'button', className: 'dsh-mcp-pulse-clear', onClick: onClear }, t('清除')),
9834
+ ),
9835
+ h('ul', { className: 'dsh-mcp-pulse-list' }, events.map((event) => {
9836
+ const meta = MARKET_PULSE_SOURCES[event.source] || null;
9837
+ const sourceLabel = meta ? meta.label : event.source;
9838
+ const text = event.kind === 'added'
9839
+ ? t('{0} 新增 {1} 条', [sourceLabel, event.count])
9840
+ : event.kind === 'removed'
9841
+ ? t('{0} 移除 {1} 条', [sourceLabel, event.count])
9842
+ : t('{0} 更新 {1} 条', [sourceLabel, event.count]);
9843
+ const sample = event.names && event.names.length ? ' · ' + event.names.join(', ') + (event.count > event.names.length ? t(' 等') : '') : '';
9844
+ return h('li', { key: event.id },
9845
+ h('button', {
9846
+ type: 'button', className: 'dsh-mcp-pulse-item', title: t('跳到对应市场'),
9847
+ onClick: () => { if (meta && typeof onJump === 'function') onJump(meta); },
9848
+ },
9849
+ h('span', { className: 'dsh-mcp-pulse-kind ' + event.kind }, text),
9850
+ h('span', { className: 'dsh-mcp-pulse-sample' }, sample),
9851
+ ),
9852
+ h('span', { className: 'dsh-mcp-pulse-time' }, relativeLabel(event.at)),
9853
+ );
9854
+ })),
9855
+ );
9856
+ };
9857
+
9858
+ // ── 市场面板:WorkBuddy 连接器 / SkillHub 技能 / 插件 / 专家子标签 ──────────────
9859
+ // 市场搜索框:左内置放大镜、圆角胶囊、聚焦高亮、右一键清空。
9860
+ // 两处复用(连接器 / 插件):两者都是纯本地过滤,输入即过滤;
9861
+ // 技能走服务端搜索,onCommit 由防抖或回车触发(见 WorkBuddyMarket 内注释)。
9862
+ // busy 只做视觉提示,**绝不 disabled 输入框**:disabled 会让浏览器立刻收走焦点,
9863
+ // 技能搜索防抖生效那一刻(含按 Delete 删字后的停顿)输入框被禁用 → 光标丢失,
9864
+ // 用户得重新点一次才能继续输入。加载态改由放大镜呼吸动画表达(见 .busy CSS)。
9865
+ const WbSearchBox = ({ value, onChange, placeholder, busy, onCommit }) =>
9866
+ h('div', { className: 'dsh-mcp-wb-search' + (busy ? ' busy' : '') },
9867
+ h('span', { className: 'dsh-mcp-wb-search-ico', 'aria-hidden': true }, h(Icon, { d: ICONS.search, size: 14 })),
9868
+ h('input', {
9869
+ type: 'search', className: 'dsh-mcp-wb-search-input', placeholder,
9870
+ value, spellCheck: false, 'aria-busy': busy || undefined,
9871
+ onChange: (e) => onChange(e.target.value),
9872
+ onKeyDown: onCommit
9873
+ ? (e) => { if (e.key === 'Enter') { e.preventDefault(); onCommit(); } }
9874
+ : undefined,
9875
+ }),
9876
+ value
9877
+ ? h('button', {
9878
+ type: 'button', className: 'dsh-mcp-wb-search-clear', title: t('清空'),
9879
+ 'aria-label': t('清空搜索'),
9880
+ onClick: () => { onChange(''); if (onCommit) onCommit(); },
9881
+ }, h(Icon, { d: ICONS.x, size: 12 }))
9882
+ : null,
9883
+ );
9884
+ const WorkBuddyMarket = ({ call, pluginManager }) => {
9885
+ // ── 共享状态 ──
9886
+ const [tab, setTab] = useState('connector'); // connector | skill
9887
+ const [loading, setLoading] = useState(true);
9888
+ const [error, setError] = useState('');
9889
+ const [busy, setBusy] = useState(false);
9890
+ const [busyLabel, setBusyLabel] = useState('');
9891
+ const [note, setNote] = useState('');
9892
+ // 「最近动静」只读本机快照:读写失败静默降级,绝不阻塞市场主流程。
9893
+ const [pulse, setPulse] = useState(() => readMarketPulse());
9894
+ // 装备清单:打开时现读本机,不在面板挂载时就做汇总(省一次 IPC 与目录读取)。
9895
+ const [loadoutOpen, setLoadoutOpen] = useState(false);
9896
+ // 专家页搜索种子的一次性 epoch:即使当前已停在专家页,也能用 key 重挂并带上搜索词。
9897
+ const [expertEpoch, setExpertEpoch] = useState(0);
9898
+ // 专家子页签徽标:市场挂载后延迟一拍预取专家目录,只取条数。目录读失败静默——
9899
+ // 徽标先不显示,绝不拖垮市场主流程;用户进过一次专家页后磁盘缓存常驻,之后零网络。
9900
+ const [expertTotal, setExpertTotal] = useState(null);
9901
+ const refreshPulse = useCallback(() => setPulse(readMarketPulse()), []);
9902
+ useEffect(() => {
9903
+ setMarketPulseListener(refreshPulse);
9904
+ return () => setMarketPulseListener(null);
9905
+ }, [refreshPulse]);
9906
+ const [connectorSource, setConnectorSource] = useState('');
9907
+ const [connectors, setConnectors] = useState([]);
9908
+ const [connectorQuery, setConnectorQuery] = useState('');
9909
+ const [connectorOnlyReady, setConnectorOnlyReady] = useState(true);
9910
+ // T04/R8:已安装筛选(全量本地筛选,不受分页影响——与技能侧「仅本页」不同)。
9911
+ const [connectorOnlyInstalled, setConnectorOnlyInstalled] = useState(false);
9912
+ // T05/R10:连接器排序键(全量本地排序)。
9913
+ const [connectorSort, setConnectorSort] = useState('default');
9914
+ const [connectorPage, setConnectorPage] = useState(1);
9915
+ const [connectorDetail, setConnectorDetail] = useState(null);
9916
+ // ── 技能 ──
9917
+ const [skillPage, setSkillPage] = useState(1);
9918
+ const [skillSort, setSkillSort] = useState('downloads');
9919
+ const [skillKeyword, setSkillKeyword] = useState('');
9920
+ const [skillKeywordInput, setSkillKeywordInput] = useState('');
9921
+ const [skills, setSkills] = useState([]);
9922
+ const [skillTotal, setSkillTotal] = useState(0);
9923
+ // 子页签徽标用的「全库」总量:只在无关键词的响应里更新。搜索命中数不进来,
9924
+ // 徽标与连接器 / 插件徽标同语义——市场规模,不随当前筛选变。
9925
+ const [skillMarketTotal, setSkillMarketTotal] = useState(0);
9926
+ // 技能侧自己的加载/错误态:此前与连接器共用 `loading`/`error`,两种请求谁先回来谁就
9927
+ // 把对方的态改掉——连接器 1 秒读完就把 loading 置 false,技能还在飞,于是技能页先画
9928
+ // 「技能目录暂时是空的 / 可能还没同步下来」;等技能真的超时,又把整页 banner 改成报错。
9929
+ // 拆开之后,技能慢只影响技能页自己的 loading/error。
9930
+ const [skillLoading, setSkillLoading] = useState(true);
9931
+ const [skillError, setSkillError] = useState('');
9932
+ const [skillDetail, setSkillDetail] = useState(null);
9933
+ const [installedSkills, setInstalledSkills] = useState(new Set());
9934
+ // §6.3 已安装筛选:SkillHub 每页 20 条远程分页,而「已装」是本地集合,
9935
+ // 所以这层过滤只能作用于**当前页**——必须在标签上写清,否则会被误读成全库筛选。
9936
+ // T04/R7:令牌弹窗。连接器 mcp.json 里带 ${...} 占位符时要先让用户填值——
9937
+ // 面板只做「用户明确输入」的透传,绝不代填、绝不猜值(X9 边界)。
9938
+ // tokenDialog = { item, values: Record<占位符名, string> } | null
9939
+ const [tokenDialog, setTokenDialog] = useState(null);
9940
+ // R8:OAuth 授权对话框。连接器探测到 401 不再报错,改为弹这里引导浏览器授权。
9941
+ // authDialog = { item, serverName, sessionId, authorizeUrl, mode, redirectUri, hint,
9942
+ // status, error, callbackUrl, manualToken } | null
9943
+ const [authDialog, setAuthDialog] = useState(null);
9944
+ const authDialogRef = useRef(null);
9945
+ authDialogRef.current = authDialog;
9946
+ const authPollRef = useRef(null);
9947
+ const authDialogRefNode = useRef(null);
9948
+ useFocusTrap(authDialogRefNode, !!authDialog);
9949
+ const [skillOnlyInstalled, setSkillOnlyInstalled] = useState(false);
9950
+ // 「已安装」集合的键是 namespace/slug 双键,不是 slug 单键:SkillHub 上同 slug 不同
9951
+ // namespace 的技能一大把(find-skills 实测三家在架)。单键会让没装过的同名卡片假显示
9952
+ // 「已安装」,在假卡片上点卸载又必撞「本机没有这个市场技能的安装记录」——用户报的
9953
+ // 「假安装」截图正是这一对症状。wbSkillInstalled 行的 namespace/slug 从目录名解析
9954
+ // (与安装时同一套 sanitize 口径),与卡片两字段天然一一对应。
9955
+ const skillInstalledKey = (row) => row.namespace + '/' + row.slug;
9956
+ // OPT-21:两个详情弹层此前都没有 ref(焦点陷阱需要容器节点)。
9957
+ const connectorDetailRef = useRef(null);
9958
+ const skillDetailRef = useRef(null);
9959
+ useFocusTrap(connectorDetailRef, !!connectorDetail);
9960
+ useFocusTrap(skillDetailRef, !!skillDetail);
9961
+ const WB_PAGE_SIZE = 20;
9962
+
9963
+ const loadInstalled = useCallback(async () => {
9964
+ const res = await call('wbSkillInstalled');
9965
+ if (res?.ok) setInstalledSkills(new Set((res.installed || []).map((row) => skillInstalledKey(row))));
9966
+ }, [call]);
9967
+
9968
+ // T04/R8:安装状态账本(三类安装的统一登记,宿主 ~/.dsh/armory/install-state.json)。
9969
+ // 与技能侧的 installedSkills 不同:这是**全量**集合,不受分页影响。
9970
+ const [installRecords, setInstallRecords] = useState([]);
9971
+ const loadInstallState = useCallback(async () => {
9972
+ try {
9973
+ const res = await call('wbInstallState');
9974
+ if (res?.ok) setInstallRecords(Array.isArray(res.items) ? res.items : []);
9975
+ } catch {
9976
+ // 安装状态是辅助信息,读失败不打断市场面板主流程。
9977
+ setInstallRecords([]);
9978
+ }
9979
+ }, [call]);
9980
+ const installedKeySet = (kind) => new Set(
9981
+ installRecords.filter((row) => row && row.kind === kind).map((row) => String(row.key || '')),
9982
+ );
9983
+
9984
+ const loadConnectors = useCallback(async () => {
8168
9985
  setLoading(true); setError(''); setNote('');
8169
9986
  try {
8170
9987
  const res = await call('wbConnectorCatalog');
8171
9988
  if (!res?.ok) throw new Error(res?.error || t('读取连接器目录失败'));
8172
- setConnectors(res.connectors || []);
9989
+ const rows = res.connectors || [];
9990
+ setConnectors(rows);
8173
9991
  setConnectorSource(res.loadedFrom || '');
9992
+ // 目录快照只记「版本 + 可装性」这种稳定签名:星数 / 下载量波动不是「动静」。
9993
+ if (recordMarketSnapshot('connector', rows.map((row) => ({
9994
+ key: row.id, name: row.name, sig: String(row.version || '') + '|' + (row.installable ? '1' : '0'),
9995
+ })))) refreshPulse();
8174
9996
  } catch (e) {
8175
9997
  setError(String(e?.message || e));
8176
9998
  } finally {
8177
9999
  setLoading(false);
8178
10000
  }
8179
- }, [call]);
10001
+ }, [call, refreshPulse]);
8180
10002
 
8181
10003
  // 技能列表请求序号:搜索防抖与翻页会连发多个请求,慢的那个回来时不能盖掉新的结果。
8182
10004
  const skillSeq = useRef(0);
@@ -8187,8 +10009,23 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8187
10009
  const res = await call('wbSkillCatalog', { page, sort, keyword, pageSize: WB_PAGE_SIZE });
8188
10010
  if (seq !== skillSeq.current) return; // 过期响应,直接丢弃
8189
10011
  if (!res?.ok) throw new Error(res?.error || t('读取技能目录失败'));
8190
- setSkills(res.skills || []);
10012
+ const rows = res.skills || [];
10013
+ setSkills(rows);
8191
10014
  setSkillTotal(res.total || 0);
10015
+ if (!String(keyword || '').trim()) setSkillMarketTotal(res.total || 0);
10016
+ // 「最近动静」:技能是远程分页 + 服务端搜索,任何一次响应都只是 19 万库里的一个切片,
10017
+ // 切片之间互比等于自欺。只有**服务端全局排序**(最多安装 / 最多星标 / 综合评分)的
10018
+ // 无关键词首页才是稳定集合,才配当基线,并且基线按排序分档存(installs 的 Top20 和
10019
+ // stars 的 Top20 是两个集合,互比又会造出「全增全删」)。
10020
+ // 默认那档「下载量(本页排序)」不给上游发 sortBy,上游无 ORDER BY 的 LIMIT 20
10021
+ // 每次返回随机 20 条(2026-10-24 实测重合 0/20)——v1 就是把它当基线,才每次进市场
10022
+ // 刷出一对「新增 20 / 移除 20」,即用户报的「切换页面重新进入时重复读取」。
10023
+ const skillPulseBasis = page === 1 && !String(keyword || '').trim() && sort !== 'downloads'
10024
+ ? 'skill:' + sort
10025
+ : '';
10026
+ if (skillPulseBasis && recordMarketSnapshot('skill', rows.map((row) => ({
10027
+ key: row.namespace + '/' + row.slug, name: row.name, sig: String(row.version || ''),
10028
+ })), { basis: skillPulseBasis })) refreshPulse();
8192
10029
  } catch (e) {
8193
10030
  if (seq === skillSeq.current) setSkillError(String(e?.message || e));
8194
10031
  } finally {
@@ -8311,7 +10148,27 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8311
10148
  setBusy(true); setBusyLabel(t('正在启动授权…'));
8312
10149
  try {
8313
10150
  const res = await call('wbOAuthBegin', { connectorId: item.id, serverName });
8314
- if (!res?.ok) throw new Error(res?.error || t('无法启动授权流程'));
10151
+ if (!res?.ok) {
10152
+ // 授权页这条路走不通(服务器不做 OAuth 发现、动态注册被拒、要求客户端认证)时,
10153
+ // 不能只甩一行红字就把用户关在门外:手填令牌那条兜底路还在,把弹层开出来摊给他。
10154
+ if (res?.manualTokenOnly) {
10155
+ setAuthDialog({
10156
+ item,
10157
+ serverName: res.serverName || serverName || item.name,
10158
+ sessionId: '',
10159
+ authorizeUrl: '',
10160
+ mode: 'manual-token',
10161
+ redirectUri: '',
10162
+ hint: '',
10163
+ status: 'manual',
10164
+ error: res.error || t('该服务器无法自动打开授权页。'),
10165
+ callbackUrl: '',
10166
+ manualToken: '',
10167
+ });
10168
+ return;
10169
+ }
10170
+ throw new Error(res?.error || t('无法启动授权流程'));
10171
+ }
8315
10172
  setAuthDialog({
8316
10173
  item,
8317
10174
  serverName: res.serverName || serverName || item.name,
@@ -8325,6 +10182,16 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8325
10182
  callbackUrl: '',
8326
10183
  manualToken: '',
8327
10184
  });
10185
+ // 宿主第一条打开通路(shell / rundll32)失败时立即补一发 wbOAuthOpen 再试一次:
10186
+ // 多数失败是瞬时抖动,同一 URL 重开往往就成功了;补开成功就撤掉"没能唤起浏览器"
10187
+ // 的提示回到纯等待态,仍失败则弹层里的链接与「重新打开浏览器」按钮还在。
10188
+ if (!res.opened && res.authorizeUrl) {
10189
+ call('wbOAuthOpen', { url: res.authorizeUrl }).then((openRes) => {
10190
+ if (openRes?.opened && authDialogRef.current?.sessionId === res.sessionId) {
10191
+ setAuthDialog((cur) => (cur && cur.sessionId === res.sessionId ? { ...cur, error: '' } : cur));
10192
+ }
10193
+ }).catch(() => {});
10194
+ }
8328
10195
  if (res.sessionId) {
8329
10196
  stopAuthPoll();
8330
10197
  authPollRef.current = setTimeout(() => { pollAuthOnce(res.sessionId); }, 1200);
@@ -8419,7 +10286,16 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8419
10286
  }
8420
10287
  installConnector(item);
8421
10288
  };
10289
+ const skillInstallInFlight = useRef(new Set());
8422
10290
  const installSkill = async (skill) => {
10291
+ // 同一技能的上一次安装还在飞时直接挡回:宿主侧已有按目录的互斥锁兜底串行,
10292
+ // 这里再挡一层是为了不给用户制造「点了两次、两次都报成功」的错觉。
10293
+ const flightKey = skillInstalledKey(skill);
10294
+ if (skillInstallInFlight.current.has(flightKey)) {
10295
+ setNote(t('该技能正在安装中,请等当前任务结束'));
10296
+ return;
10297
+ }
10298
+ skillInstallInFlight.current.add(flightKey);
8423
10299
  setBusy(true); setBusyLabel(t('正在下载并校验 {0}…', [skill.name])); setError('');
8424
10300
  try {
8425
10301
  const res = await call('wbSkillInstall', { slug: skill.slug, namespace: skill.namespace, name: skill.name });
@@ -8434,6 +10310,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8434
10310
  } catch (e) {
8435
10311
  setError(String(e?.message || e));
8436
10312
  } finally {
10313
+ skillInstallInFlight.current.delete(flightKey);
8437
10314
  setBusy(false); setBusyLabel('');
8438
10315
  }
8439
10316
  };
@@ -8465,105 +10342,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8465
10342
  }
8466
10343
  };
8467
10344
 
8468
- // ── 内置 MCP 目录(第三 tab):与全局 MCP 市场同一份目录,直接可装 ──
8469
- const [builtins, setBuiltins] = useState([]);
8470
- const [builtinQuery, setBuiltinQuery] = useState('');
8471
- const [builtinCategory, setBuiltinCategory] = useState('all');
8472
- const [builtinOnlyReady, setBuiltinOnlyReady] = useState(false);
8473
- const [builtinsLoading, setBuiltinsLoading] = useState(false);
8474
- const [builtinError, setBuiltinError] = useState('');
8475
- const loadBuiltins = useCallback(async () => {
8476
- setBuiltinsLoading(true); setBuiltinError('');
8477
- try {
8478
- const res = await call('builtins');
8479
- if (!res?.ok) throw new Error(res?.error || t('读取 MCP 目录失败'));
8480
- setBuiltins(res.builtins || []);
8481
- } catch (e) {
8482
- setBuiltinError(String(e?.message || e));
8483
- } finally {
8484
- setBuiltinsLoading(false);
8485
- }
8486
- }, [call]);
8487
- useEffect(() => {
8488
- if (tab === 'builtin' && !builtins.length && !builtinsLoading) loadBuiltins();
8489
- }, [tab, builtins.length, builtinsLoading, loadBuiltins]);
8490
- const builtinNeedsKey = (item) => Array.isArray(item.envKeys) && item.envKeys.length > 0;
8491
- const builtinNeedsConfig = (item) => Array.isArray(item.configKeys) && item.configKeys.length > 0;
8492
- const builtinNeedsArgs = (item) => Array.isArray(item.placeholders) && item.placeholders.length > 0;
8493
- const builtinReady = (item) => !builtinNeedsKey(item) && !builtinNeedsConfig(item) && !builtinNeedsArgs(item);
8494
- // P1:目录随 RPC 同时送中英字段,这里按当前 locale 投影一次。只在 47 条上跑,
8495
- // 不做 useMemo——locale 变化时 useI18nRevision 已强制重渲染,缓存反而会留下旧语言。
8496
- const localizedBuiltins = builtins.map((item) => localizeBuiltinItem(item));
8497
- const builtinCategories = (() => {
8498
- const counts = new Map();
8499
- for (const item of localizedBuiltins) {
8500
- const key = item.category || 'other';
8501
- const entry = counts.get(key) || { label: item.categoryLabel || key, count: 0 };
8502
- entry.count += 1;
8503
- counts.set(key, entry);
8504
- }
8505
- return [...counts.entries()].map(([key, entry]) => ({ key, ...entry }));
8506
- })();
8507
- const builtinQueryKey = builtinQuery.trim().toLowerCase();
8508
- // ── T03/R5:codebuddy 插件市场 ──
8509
- // 目录一次全量(官方 zip 里的 external_plugins 清单,无分页概念),本地过滤。
8510
- // 安装走官方 pluginManager.installBundle(spec, {}) 严格双参(少传第二个会被 wire 拒绝,
8511
- // 见本文件 runUpgrade 的踩坑注释);装完宿主进程不热载新包,必须重启 DSH 才生效——
8512
- // 提示条 state 常驻(不写 localStorage,本次会话内不消失)。
8513
- const [plugins, setPlugins] = useState([]);
8514
- const [pluginsLoading, setPluginsLoading] = useState(false);
8515
- const [pluginsError, setPluginsError] = useState('');
8516
- const [pluginQuery, setPluginQuery] = useState('');
8517
- // T04/R8:插件「只看已安装」(全量本地筛选)。
8518
- const [pluginOnlyInstalled, setPluginOnlyInstalled] = useState(false);
8519
- const [pluginDetail, setPluginDetail] = useState(null);
10345
+ // 插件安装走宿主的 pluginManager.installBundle(spec, {}) 严格双参(少传第二个会被
10346
+ // wire 拒绝,见本文件 runUpgrade 的踩坑注释);装完宿主进程不热载新包,必须重启 DSH
10347
+ // 才生效——提示条 state 常驻(不写 localStorage,本次会话内不消失)。
8520
10348
  const [restartNotice, setRestartNotice] = useState('');
8521
- // T05/R9:插件市场更新提示。**状态名不能叫 updateHint**——那个名字已被本文件
8522
- // L3288 的 dsh-armory 自身版本更新提示占用。只按 reason 分支,不读 error 正文。
8523
- const [pluginUpdateHint, setPluginUpdateHint] = useState(null);
8524
- const pluginDetailRef = useRef(null);
8525
- useFocusTrap(pluginDetailRef, !!pluginDetail);
8526
- const loadPlugins = useCallback(async () => {
8527
- setPluginsLoading(true); setPluginsError('');
8528
- try {
8529
- const res = await call('wbPluginCatalog');
8530
- if (!res?.ok) throw new Error(res?.error || t('读取插件目录失败'));
8531
- setPlugins(res.plugins || []);
8532
- } catch (e) {
8533
- setPluginsError(String(e?.message || e));
8534
- } finally {
8535
- setPluginsLoading(false);
8536
- }
8537
- }, [call]);
8538
- // T05/R9:插件市场更新探测——**懒触发、只跑一次**(X4 边界:绝不常驻定时器)。
8539
- // 只在「已进过 plugin tab 且目录已加载」时探一次;reason 分支见下方提示条。
8540
- const checkPluginUpdate = useCallback(async () => {
8541
- try {
8542
- const res = await call('wbPluginUpdateCheck');
8543
- setPluginUpdateHint(res || null);
8544
- } catch (e) {
8545
- // 探测失败不打扰用户(辅助信息),保留 null 即不渲染提示条。
8546
- setPluginUpdateHint(null);
8547
- }
8548
- }, [call]);
8549
10349
  // T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins)状态机与数据接入。
8550
- // pluginSource 持久化 localStorage(先例 UPDATE_HINT_STATE_KEY);默认 awesome(用户拍板 2026-10-20:插件页初始显示 DSH 社区;显式切过来源的用户按其持久化值)。
8551
- // 来源切换控件在插件工具行(select 两态),写入 localStorage 持久化。
8552
- // 注意:必须在下方两个依赖数组含 pluginSource 的 effect 之前声明——依赖数组在渲染期求值,
8553
- // const 声明若在其后,渲染即抛 ReferenceError: Cannot access 'pluginSource' before initialization(TDZ)。
8554
- const [pluginSource, setPluginSource] = useState(() => {
8555
- try { return localStorage.getItem('dsh-armory.pluginSource') === 'codebuddy' ? 'codebuddy' : 'awesome'; } catch { return 'awesome'; }
8556
- });
8557
- const pluginUpdateChecked = useRef(false);
8558
- useEffect(() => {
8559
- if (tab === 'plugin' && pluginSource === 'codebuddy' && !plugins.length && !pluginsLoading) loadPlugins();
8560
- }, [tab, pluginSource, plugins.length, pluginsLoading, loadPlugins]);
8561
- useEffect(() => {
8562
- // 只在目录加载完成后探一次;pluginUpdateChecked 保证组件存续期内不重复。
8563
- if (tab !== 'plugin' || pluginSource !== 'codebuddy' || pluginsLoading || !plugins.length || pluginUpdateChecked.current) return;
8564
- pluginUpdateChecked.current = true;
8565
- checkPluginUpdate();
8566
- }, [tab, pluginSource, pluginsLoading, plugins.length, checkPluginUpdate]);
10350
+ // 插件页只有这一个来源;首屏快照见下方 awesomeSnapshotRef。
8567
10351
  // 首屏秒开:首次挂载就把上次的快照铺上,用户进插件页立刻看到目录而不是一句「正在加载」。
8568
10352
  // 快照只在挂载时读一次(JSON.parse 一两 MB 不能每次 render 都干);真实目录随后覆盖。
8569
10353
  const awesomeSnapshotRef = useRef(undefined);
@@ -8582,7 +10366,10 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8582
10366
  const [awesomeSort, setAwesomeSort] = useState('stars');
8583
10367
  const [awesomePage, setAwesomePage] = useState(1);
8584
10368
  const [awesomeOnlyInstalled, setAwesomeOnlyInstalled] = useState(false);
8585
- const loadAwesomeCatalog = useCallback(async (refresh = false) => {
10369
+ // 静默预取失败时用来判断「有没有快照可留」:有快照就不弹错误条。
10370
+ const awesomeLenRef = useRef(awesomePlugins.length);
10371
+ useEffect(() => { awesomeLenRef.current = awesomePlugins.length; }, [awesomePlugins.length]);
10372
+ const loadAwesomeCatalog = useCallback(async (refresh = false, silent = false) => {
8586
10373
  setAwesomeLoading(true); setAwesomeError('');
8587
10374
  try {
8588
10375
  const res = await call('wbAwesomeCatalog', refresh ? { refresh: true } : {});
@@ -8592,10 +10379,17 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8592
10379
  const marketplace = res.marketplace || 'awesome-dsh-plugins';
8593
10380
  const catalog = (res.plugins || []).map((item) => ({ ...item, marketplace }));
8594
10381
  setAwesomePlugins(catalog);
10382
+ // 「最近动静」:只对比新增 / 移除 / 版本或核验状态变化。星数不进签名(每天波动属噪音)。
10383
+ if (recordMarketSnapshot('awesome', catalog.map((item) => ({
10384
+ key: item.name,
10385
+ name: item.name,
10386
+ sig: String(item.version || '') + '|' + String(item.verifiedAgainst || '') + '|' + String(item.status || ''),
10387
+ })))) refreshPulse();
8595
10388
  // 顺手把这次的目录留成下次的首屏快照(写失败静默,不影响本次渲染)。
8596
10389
  writeAwesomeSnapshot({ plugins: catalog, marketplace, fetchedAt: res.fetchedAt });
8597
10390
  } catch (e) {
8598
- setAwesomeError(String(e?.message || e));
10391
+ // 静默预取失败且已有快照 → 保留旧目录;没有快照才让错误条接管。
10392
+ if (!silent || awesomeLenRef.current === 0) setAwesomeError(String(e?.message || e));
8599
10393
  } finally {
8600
10394
  setAwesomeLoading(false);
8601
10395
  }
@@ -8653,39 +10447,51 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8653
10447
  }
8654
10448
  setReadmeView({ item, loading: false, ok: true, markdown: res.markdown || '', base: res.base || '', error: '', source: res.source || '', reason: res.reason || '', truncated: res.truncated === true });
8655
10449
  }, [call]);
8656
- // 自动加载改成「每次挂载只发一次」:旧条件是 `awesomeLoading || awesomePlugins.length` 就跳过,
8657
- // 一旦首屏已经有快照,真实目录就永远不会再拉——快照便成了死数据,必须去掉那个门槛。
10450
+ // 目录预取:市场面板一挂载就在后台把 DSH 社区目录拉好。以前必须切到插件页才发请求,
10451
+ // 用户看到的是「空白 → loading → 列表」;现在切过去通常已经就绪(另有 localStorage
10452
+ // 快照垫底)。延迟一拍,避免和 MCP 首屏争带宽;只跑一次,失败由列表里的错误条 / 空态
10453
+ // 提供手动重试。
8658
10454
  const awesomeAutoLoaded = useRef(false);
8659
10455
  useEffect(() => {
8660
- if (tab !== 'plugin' || pluginSource !== 'awesome' || awesomeAutoLoaded.current) return;
10456
+ if (awesomeAutoLoaded.current) return;
10457
+ const timer = setTimeout(() => {
10458
+ if (awesomeAutoLoaded.current) return;
10459
+ awesomeAutoLoaded.current = true;
10460
+ loadAwesomeCatalog(false, true);
10461
+ }, 400);
10462
+ return () => clearTimeout(timer);
10463
+ }, [loadAwesomeCatalog]);
10464
+ // 用户已经在插件页时不等预取延迟:立即发(旧行为保留,预取只负责「还没切过来」)。
10465
+ useEffect(() => {
10466
+ if (tab !== 'plugin' || awesomeAutoLoaded.current) return;
8661
10467
  awesomeAutoLoaded.current = true;
8662
- loadAwesomeCatalog();
8663
- }, [tab, pluginSource, loadAwesomeCatalog]);
10468
+ loadAwesomeCatalog(false, true);
10469
+ }, [tab, loadAwesomeCatalog]);
10470
+ // 专家目录预取(只为子页签徽标取条数):与插件目录预取同一节奏,延迟一拍避免和
10471
+ // MCP 首屏争带宽。loadExpertCatalogShared 带模块级 TTL 缓存,专家页挂载时复用
10472
+ // 同一份,不会二连网络;组件卸载后回包只作废,不写状态。
10473
+ useEffect(() => {
10474
+ let alive = true;
10475
+ const timer = setTimeout(() => {
10476
+ loadExpertCatalogShared(call).then((data) => {
10477
+ if (alive) setExpertTotal((data.experts || []).length || null);
10478
+ }).catch(() => { /* 徽标读不到就先不显示 */ });
10479
+ }, 400);
10480
+ return () => { alive = false; clearTimeout(timer); };
10481
+ }, [call]);
8664
10482
  useEffect(() => {
8665
10483
  // 目录加载完成后探一次;awesomeUpdateChecked 保证组件存续期内不重复;已有提示不再探。
8666
- if (tab !== 'plugin' || pluginSource !== 'awesome' || awesomeLoading || !awesomePlugins.length || awesomeError || awesomeUpdateHint) return;
10484
+ if (tab !== 'plugin' || awesomeLoading || !awesomePlugins.length || awesomeError || awesomeUpdateHint) return;
8667
10485
  if (awesomeUpdateChecked.current) return;
8668
10486
  awesomeUpdateChecked.current = true;
8669
10487
  checkAwesomeUpdate();
8670
- }, [tab, pluginSource, awesomeLoading, awesomePlugins.length, awesomeError, awesomeUpdateHint, checkAwesomeUpdate]);
10488
+ }, [tab, awesomeLoading, awesomePlugins.length, awesomeError, awesomeUpdateHint, checkAwesomeUpdate]);
8671
10489
  useEffect(() => { setAwesomePage(1); }, [awesomeQuery, awesomeCategory, awesomeTags, awesomeSort]);
8672
- // UNCLEAR#1 的取值策略:plugin.json 的 repository 是安装 spec 的唯一可推导来源——
8673
- // 1. git/https 的 GitHub 仓库 URL → `github:<owner>/<repo>#<ref>`(官方 installBundle
8674
- // 的 pnpm add 语义支持该 spec);
8675
- // 2. 其余字符串(裸 npm 名、含版本号等)原样当作 spec 交给宿主;
8676
- // 3. 没有 repository → null = 不可推导 → UI 只展示、不渲染安装按钮(绝不猜名字)。
10490
+ // DSH 社区条目的主安装路径是 npm 包名:installBundle 直接接受 `npm:<name>` 形态的
10491
+ // spec。没有 npm 的条目在 UI 层已灰置(「暂不支持安装」),走不到这里。
8677
10492
  const pluginInstallSpec = (plugin) => {
8678
- // T05/R12:DSH 社区条目(awesome-dsh-plugins)的主安装路径是 npm 包名。
8679
- // installBundle 直接受 npm:<name> 形态的 spec;没有 npm 的社区条目在 UI 层灰置,走不到这里。
8680
- if (plugin?.npm) return `npm:${String(plugin.npm).trim()}`;
8681
- const repo = String(plugin?.repository || '').trim();
8682
- if (!repo) return null;
8683
- const gitHub = repo.match(/^https?:\/\/github\.com\/([^/]+)\/([^/#?]+)(?:[/#?]|\b)/i);
8684
- if (gitHub) {
8685
- const ref = (repo.split('#')[1] || '').trim();
8686
- return `github:${gitHub[1]}/${gitHub[2].replace(/\.git$/, '')}${ref ? '#' + ref : ''}`;
8687
- }
8688
- return repo;
10493
+ const npm = String(plugin?.npm || '').trim();
10494
+ return npm ? `npm:${npm}` : null;
8689
10495
  };
8690
10496
  const installPlugin = async (plugin) => {
8691
10497
  if (!pluginManager || typeof pluginManager[UPGRADE_METHOD] !== 'function') {
@@ -8693,7 +10499,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8693
10499
  return;
8694
10500
  }
8695
10501
  const spec = pluginInstallSpec(plugin);
8696
- if (!spec) { setError(t('该插件未提供可推导的安装来源(repository),暂不能安装')); return; }
10502
+ if (!spec) { setError(t('该条目未提供 npm 包名,暂不支持安装')); return; }
8697
10503
  setBusy(true); setBusyLabel(t('正在安装插件 {0}…', [plugin.name])); setError('');
8698
10504
  // 顶部浮层与组件 state 并行记账:面板被卸载(切标签 / 切窗口)后浮层仍在,用户不会误以为被取消。
8699
10505
  installToastStart(plugin.name);
@@ -8713,7 +10519,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8713
10519
  key: plugin.name,
8714
10520
  name: plugin.name,
8715
10521
  version: plugin.version || '',
8716
- source: { kind: 'marketplace', marketplace: plugin.marketplace || 'codebuddy-plugins-official' },
10522
+ source: { kind: 'marketplace', marketplace: plugin.marketplace || 'awesome-dsh-plugins' },
8717
10523
  });
8718
10524
  if (!rec?.ok) setNote(t('插件已安装,但安装状态登记失败:{0}', [rec?.error || t('未知错误')]));
8719
10525
  } catch (e) {
@@ -8722,7 +10528,6 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8722
10528
  await loadInstallState();
8723
10529
  // 快照里若还有这条旧目录数据,标记已安装由 installedPluginKeys 负责,无需改动。
8724
10530
  setRestartNotice(t('插件 {0} 安装成功,需重启 DSH 宿主后生效。', [plugin.name]));
8725
- setPluginDetail(null);
8726
10531
  installToastFinish(plugin.name, 'ok');
8727
10532
  } catch (e) {
8728
10533
  setError(String(e?.message || e));
@@ -8731,13 +10536,6 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8731
10536
  setBusy(false); setBusyLabel('');
8732
10537
  }
8733
10538
  };
8734
- const pluginQueryKey = pluginQuery.trim().toLowerCase();
8735
- const filteredPlugins = plugins.filter((item) => {
8736
- // T04/R8:只看已安装——插件账本 key 是 plugin.name。
8737
- if (pluginOnlyInstalled && !installedPluginKeys.has(String(item.name || ''))) return false;
8738
- if (!pluginQueryKey) return true;
8739
- return [item.name, item.description, item.author].filter(Boolean).join(' ').toLowerCase().includes(pluginQueryKey);
8740
- });
8741
10539
  // T06/Awesome:社区目录筛选 / 排序 / 分页(全量本地,不受分页影响;tags 多选为「或」语义)。
8742
10540
  const awesomeQueryKey = awesomeQuery.trim().toLowerCase();
8743
10541
  const filteredAwesome = awesomePlugins.filter((item) => {
@@ -8757,32 +10555,57 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8757
10555
  const awesomePageCount = Math.max(1, Math.ceil(sortedAwesome.length / AWESOME_PAGE_SIZE));
8758
10556
  const safeAwesomePage = Math.min(awesomePage, awesomePageCount);
8759
10557
  const visibleAwesome = sortedAwesome.slice((safeAwesomePage - 1) * AWESOME_PAGE_SIZE, safeAwesomePage * AWESOME_PAGE_SIZE);
8760
- const filteredBuiltins = localizedBuiltins.filter((item) => {
8761
- if (builtinCategory !== 'all' && (item.category || 'other') !== builtinCategory) return false;
8762
- if (builtinOnlyReady && !builtinReady(item)) return false;
8763
- if (!builtinQueryKey) return true;
8764
- return [item.label, item.id, item.name, item.summary, item.vendor, item.access].filter(Boolean).join(' ').toLowerCase().includes(builtinQueryKey);
8765
- });
8766
- const installBuiltin = async (item) => {
8767
- setBusy(true); setBusyLabel(t('正在安装 {0}…', [item.label])); setError('');
8768
- try {
8769
- const res = await call('installBuiltins', { ids: [item.id] });
8770
- if (!res?.ok) throw new Error(res?.error || t('安装失败'));
8771
- setNote(t('{0}:{1}', [item.label, res.note || t('已安装')]));
8772
- await loadBuiltins();
8773
- } catch (e) {
8774
- setError(String(e?.message || e));
8775
- } finally {
8776
- setBusy(false); setBusyLabel('');
10558
+ // 下载量只给「当前这一页」排队:一页 50 张 → 最多 5 个批量请求(宿主侧再缓存 7 天)。
10559
+ // 放在这里(不是每张卡片里)是因为 hook 不能在 .map 里调,而页级批量已经足够省。
10560
+ useNpmDownloads(call, visibleAwesome);
10561
+
10562
+ // 装备清单的「去市场」:只做定位,不替用户点安装。各市场搜索词互不相通,
10563
+ // 所以按 kind / source 落到对应子页并写入该页的搜索框。
10564
+ const locateLoadoutRow = async (row) => {
10565
+ const name = String(row?.name || '').trim();
10566
+ const source = String(row?.source || '');
10567
+ setLoadoutOpen(false);
10568
+ if (row?.kind === 'mcp') {
10569
+ if (source.startsWith('connector:')) {
10570
+ setTab('connector');
10571
+ setConnectorQuery(name);
10572
+ setConnectorOnlyReady(false);
10573
+ setConnectorPage(1);
10574
+ setNote(t('已定位到连接器市场:{0}', [name]));
10575
+ } else {
10576
+ // 非 connector 来源(手动配置或历史 builtin 导入)不在任何市场里:
10577
+ // 复制名称让用户去 MCP 页自己加,绝不替用户写配置。
10578
+ await copyText(name);
10579
+ setNote(t('这是手动配置的 MCP,名称已复制;可到 MCP 页自行添加。'));
10580
+ }
10581
+ return;
8777
10582
  }
10583
+ if (row?.kind === 'skill') {
10584
+ setTab('skill');
10585
+ setSkillPage(1);
10586
+ setSkillKeywordInput(name);
10587
+ setSkillKeyword(name);
10588
+ setNote(t('已定位到技能市场:{0}', [name]));
10589
+ return;
10590
+ }
10591
+ if (row?.kind === 'expert') {
10592
+ seedExpertMarketQuery(name);
10593
+ setExpertEpoch((value) => value + 1);
10594
+ setTab('expert');
10595
+ setNote(t('已定位到专家市场:{0}', [name]));
10596
+ return;
10597
+ }
10598
+ setAwesomeQuery(name);
10599
+ setTab('plugin');
10600
+ setNote(t('已定位到插件市场:{0}', [name]));
8778
10601
  };
8779
10602
 
8780
10603
  const connectorSearch = h(WbSearchBox, {
8781
- value: connectorQuery, placeholder: t('搜索连接器(名称 / 说明 / ID)'), disabled: loading,
10604
+ value: connectorQuery, placeholder: t('搜索连接器(名称 / 说明 / ID)'), busy: loading,
8782
10605
  onChange: (value) => { setConnectorQuery(value); setConnectorPage(1); },
8783
10606
  });
8784
10607
  const skillSearch = h(WbSearchBox, {
8785
- value: skillKeywordInput, placeholder: t('搜索 SkillHub 技能(19 万+)'), disabled: skillLoading,
10608
+ value: skillKeywordInput, placeholder: t('搜索 SkillHub 技能(19 万+)'), busy: skillLoading,
8786
10609
  onChange: (value) => setSkillKeywordInput(value),
8787
10610
  // 回车跳过防抖立即生效(防抖本身也会触发,双保险但幂等)。
8788
10611
  onCommit: () => { setSkillPage(1); setSkillKeyword(skillKeywordInput); },
@@ -8796,39 +10619,43 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8796
10619
  // 已安装状态只在本地知道,所以这层过滤只作用于本页——计数文案里写清楚,
8797
10620
  // 免得用户以为「只看已安装」能翻遍全库。
8798
10621
  const shownSkills = skillOnlyInstalled
8799
- ? skills.filter((skill) => installedSkills.has(skill.slug))
10622
+ ? skills.filter((skill) => installedSkills.has(skillInstalledKey(skill)))
8800
10623
  : skills;
8801
- const installedOnPage = skills.filter((skill) => installedSkills.has(skill.slug)).length;
10624
+ const installedOnPage = skills.filter((skill) => installedSkills.has(skillInstalledKey(skill))).length;
8802
10625
 
8803
10626
  // §6.1 子页签:原来用与顶栏 `.dsh-mcp-cat` 同款的胶囊按钮,两排长得一样,
8804
10627
  // 用户分不清「换整页」还是「换市场内部子页」。改成下划线式页签 + 面包屑,
8805
- // 视觉上与顶栏彻底分开;「内置 MCP」拉不到数据时(0)降级成灰字,不占页签位。
10628
+ // 视觉上与顶栏彻底分开。面包屑并入页签行最右侧(不再独占一行),随当前子页更新。
8806
10629
  const MARKET_TABS = [
8807
10630
  { key: 'connector', label: t('连接器'), count: connectors.length || null },
8808
- { key: 'builtin', label: t('内置 MCP'), count: builtins.length || null },
8809
- { key: 'skill', label: t('技能'), count: null },
8810
- // T03/R5:codebuddy 插件市场子页签。count 在目录加载后才有(未载时灰字入口,同内置 MCP)。
8811
- { key: 'plugin', label: t('插件'), count: plugins.length || null },
8812
- { key: 'expert', label: t('专家'), count: null },
10631
+ // 技能库 19 万+,徽标走 wbFormatCount 缩写(193k);数字只在无关键词响应里更新,
10632
+ // 搜索命中数不进来——与连接器 / 插件徽标同语义:市场规模,不随筛选变。
10633
+ { key: 'skill', label: t('技能'), count: skillMarketTotal ? wbFormatCount(skillMarketTotal) : null },
10634
+ // 插件市场子页签。count 在目录加载后才有(未载时灰字入口,同技能)。
10635
+ { key: 'plugin', label: t('插件'), count: awesomePlugins.length || null },
10636
+ // 专家徽标同款:挂载后延迟预取目录条数,读到才显示。
10637
+ { key: 'expert', label: t('专家'), count: expertTotal },
8813
10638
  ];
8814
10639
  const marketTabLabel = (MARKET_TABS.find((row) => row.key === tab) || MARKET_TABS[0]).label;
8815
10640
  return h('div', { className: 'dsh-mcp-wb' },
8816
- h('div', { className: 'dsh-mcp-mk-crumb' },
8817
- h('span', { className: 'dsh-mcp-mk-crumb-root' }, t('市场')),
8818
- h('span', { className: 'dsh-mcp-mk-crumb-sep', 'aria-hidden': 'true' }, '/'),
8819
- h('b', null, marketTabLabel),
8820
- ),
8821
10641
  h('div', { className: 'dsh-mcp-mk-subtabs', role: 'tablist', 'aria-label': t('WorkBuddy 市场') },
8822
- MARKET_TABS.map((row) => (row.key === 'builtin' && !row.count
8823
- // 内置 MCP 目录首次进入才有数据,未加载时给一个可点的灰字入口而非空页签。
8824
- ? h('button', {
8825
- key: row.key, type: 'button', className: 'dsh-mcp-mk-subtabs-idle',
8826
- onClick: () => setTab(row.key), title: t('读取内置 MCP 目录'),
8827
- }, row.label)
8828
- : h('button', {
8829
- key: row.key, type: 'button', role: 'tab', 'aria-selected': tab === row.key,
8830
- className: 'dsh-mcp-mk-subtab', onClick: () => setTab(row.key),
8831
- }, row.label, row.count ? h('i', null, row.count) : null))),
10642
+ MARKET_TABS.map((row) => h('button', {
10643
+ key: row.key, type: 'button', role: 'tab', 'aria-selected': tab === row.key,
10644
+ className: 'dsh-mcp-mk-subtab', onClick: () => setTab(row.key),
10645
+ }, row.label, row.count ? h('i', null, row.count) : null)),
10646
+ // 右侧:装备清单入口 + 面包屑(面包屑 aria-hidden:当前位置已由选中页签表达)。
10647
+ h('div', { className: 'dsh-mcp-mk-topright' },
10648
+ h('button', {
10649
+ type: 'button', className: 'dsh-mcp-loadout-open',
10650
+ title: t('导出本机配置,或导入朋友的清单做差异对比'),
10651
+ onClick: () => setLoadoutOpen(true),
10652
+ }, t('装备清单')),
10653
+ h('div', { className: 'dsh-mcp-mk-crumb', 'aria-hidden': 'true' },
10654
+ h('span', { className: 'dsh-mcp-mk-crumb-root' }, t('市场')),
10655
+ h('span', { className: 'dsh-mcp-mk-crumb-sep' }, '/'),
10656
+ h('b', null, marketTabLabel),
10657
+ ),
10658
+ ),
8832
10659
  ),
8833
10660
  // §6.5 免责声明:原来整段灰字常驻在页签下方,每次进市场都吃掉两行高度。
8834
10661
  // 收敛为一行摘要 + details 展开全文,默认折叠。
@@ -8838,11 +10665,19 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8838
10665
  t('连接器来自 WorkBuddy 官方分发(static.workbuddy.cn);技能来自 SkillHub 公开 API;专家来自 WorkBuddy 专家中心分发。') +
8839
10666
  t('第三方接口的字段可能随上游改版变化,安装前请在详情里核对传输类型与凭据要求。')),
8840
10667
  ),
10668
+ // 「最近动静」:本机快照对比,只报真实的目录变化;没有变化就说没有。
10669
+ h(MarketPulse, {
10670
+ state: pulse,
10671
+ onClear: () => { clearMarketPulse(); setPulse(emptyMarketPulse()); },
10672
+ onJump: (meta) => {
10673
+ if (meta.tab) setTab(meta.tab);
10674
+ },
10675
+ }),
8841
10676
  tab === 'connector'
8842
10677
  ? h('div', { className: 'dsh-mcp-wb-toolbar' },
8843
10678
  connectorSearch,
8844
10679
  h('label', { className: 'dsh-mcp-check', title: t('默认只显示可直接安装的条目;取消勾选可看到「仅展示」的不兼容条目') },
8845
- h('input', { type: 'checkbox', checked: connectorOnlyReady, disabled: loading, onChange: (e) => { setConnectorOnlyReady(e.target.checked); setConnectorPage(1); } }),
10680
+ h('input', { type: 'checkbox', checked: connectorOnlyReady, onChange: (e) => { setConnectorOnlyReady(e.target.checked); setConnectorPage(1); } }),
8846
10681
  t('只看可直接安装')),
8847
10682
  // T04/R8:已安装筛选是全量本地筛选(跨页生效),不随分页变化。
8848
10683
  h('label', { className: 'dsh-mcp-check', title: t('在全量 {0} 条目录里筛出已安装的连接器(跨页生效,与技能侧「仅本页」不同)', [connectors.length]) },
@@ -8889,63 +10724,27 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8889
10724
  : null,
8890
10725
  )
8891
10726
  : null,
8892
- tab === 'builtin'
10727
+ tab === 'plugin'
8893
10728
  ? h('div', { className: 'dsh-mcp-wb-toolbar' },
8894
- h(WbSearchBox, {
8895
- value: builtinQuery, placeholder: t('搜索服务、用途或提供方…'),
8896
- onChange: (value) => setBuiltinQuery(value),
8897
- }),
8898
- h('label', { className: 'dsh-mcp-check' },
8899
- h('input', { type: 'checkbox', checked: builtinOnlyReady, onChange: (e) => setBuiltinOnlyReady(e.target.checked) }),
8900
- t('只要免密钥')),
8901
- h('select', {
8902
- className: 'dsh-mcp-input dsh-mcp-wb-sort', value: builtinCategory,
8903
- onChange: (e) => setBuiltinCategory(e.target.value),
8904
- }, h('option', { key: 'all', value: 'all' }, t('全部分类')),
8905
- builtinCategories.map((cat) => h('option', { key: cat.key, value: cat.key }, cat.label + t('(') + cat.count + t(')')))),
8906
- h('span', { className: 'dsh-mcp-builtin-count' }, t('显示 {0} / {1}', [filteredBuiltins.length, builtins.length])),
10729
+ h(WbSearchBox, { value: awesomeQuery, placeholder: t('搜索插件(名称 / 描述 / 作者)'), onChange: (value) => setAwesomeQuery(value) }),
10730
+ ...AWESOME_CATEGORIES.map((cat) => h('button', { key: cat.key, type: 'button', className: 'dsh-mcp-tag-chip' + (awesomeCategory === cat.key ? ' on' : ''), 'aria-pressed': awesomeCategory === cat.key, onClick: () => setAwesomeCategory(cat.key) }, cat.label)),
10731
+ ...AWESOME_TAGS.map((tag) => h('button', { key: tag, type: 'button', className: 'dsh-mcp-tag-chip' + (awesomeTags.includes(tag) ? ' on' : ''), 'aria-pressed': awesomeTags.includes(tag), onClick: () => setAwesomeTags(awesomeTags.includes(tag) ? awesomeTags.filter((row) => row !== tag) : [...awesomeTags, tag]) }, tag)),
10732
+ h('select', { className: 'dsh-mcp-wb-sort', value: awesomeSort, onChange: (e) => setAwesomeSort(e.target.value) }, AWESOME_SORTS.map((option) => h('option', { key: option.key, value: option.key }, option.label))),
10733
+ h('label', { className: 'dsh-mcp-check', title: t('在全量 {0} 条插件里筛出已安装的(跨页生效)', [awesomePlugins.length]) }, h('input', { type: 'checkbox', checked: awesomeOnlyInstalled, onChange: (e) => setAwesomeOnlyInstalled(e.target.checked) }), t('只看已安装')),
10734
+ h('span', { className: 'dsh-mcp-builtin-count' }, t('显示 {0} / {1}', [visibleAwesome.length, filteredAwesome.length])),
8907
10735
  )
8908
- : tab === 'plugin'
8909
- ? h('div', { className: 'dsh-mcp-wb-toolbar' },
8910
- h('select', { className: 'dsh-mcp-wb-sort', value: pluginSource, onChange: (e) => { const next = e.target.value === 'awesome' ? 'awesome' : 'codebuddy'; setPluginSource(next); try { localStorage.setItem('dsh-armory.pluginSource', next); } catch { /* localStorage 不可用时仅本会话生效 */ } } },
8911
- h('option', { value: 'codebuddy' }, t('CodeBuddy 官方')),
8912
- h('option', { value: 'awesome' }, t('DSH 社区')),
8913
- ),
8914
- ...(pluginSource === 'codebuddy' ? [
8915
- h(WbSearchBox, {
8916
- value: pluginQuery, placeholder: t('搜索插件(名称 / 描述 / 作者)'),
8917
- onChange: (value) => setPluginQuery(value),
8918
- }),
8919
- // T04/R8:插件的已安装筛选同样是全量本地筛选。
8920
- h('label', { className: 'dsh-mcp-check', title: t('在全量 {0} 条插件里筛出已安装的(跨页生效)', [plugins.length]) },
8921
- h('input', { type: 'checkbox', checked: pluginOnlyInstalled, disabled: pluginsLoading, onChange: (e) => setPluginOnlyInstalled(e.target.checked) }),
8922
- t('只看已安装')),
8923
- pluginOnlyInstalled
8924
- ? h('span', { className: 'dsh-mcp-builtin-count' }, t('已安装 {0} / {1}', [installedPluginKeys.size, plugins.length]))
8925
- : null,
8926
- h('span', { className: 'dsh-mcp-builtin-count' }, t('显示 {0} / {1}', [filteredPlugins.length, plugins.length])),
8927
- ] : []),
8928
- ...(pluginSource === 'awesome' ? [
8929
- h(WbSearchBox, { value: awesomeQuery, placeholder: t('搜索插件(名称 / 描述 / 作者)'), onChange: (value) => setAwesomeQuery(value) }),
8930
- ...AWESOME_CATEGORIES.map((cat) => h('button', { key: cat.key, type: 'button', className: 'dsh-mcp-tag-chip' + (awesomeCategory === cat.key ? ' on' : ''), 'aria-pressed': awesomeCategory === cat.key, onClick: () => setAwesomeCategory(cat.key) }, cat.label)),
8931
- ...AWESOME_TAGS.map((tag) => h('button', { key: tag, type: 'button', className: 'dsh-mcp-tag-chip' + (awesomeTags.includes(tag) ? ' on' : ''), 'aria-pressed': awesomeTags.includes(tag), onClick: () => setAwesomeTags(awesomeTags.includes(tag) ? awesomeTags.filter((row) => row !== tag) : [...awesomeTags, tag]) }, tag)),
8932
- h('select', { className: 'dsh-mcp-wb-sort', value: awesomeSort, onChange: (e) => setAwesomeSort(e.target.value) }, AWESOME_SORTS.map((option) => h('option', { key: option.key, value: option.key }, option.label))),
8933
- h('label', { className: 'dsh-mcp-check', title: t('在全量 {0} 条插件里筛出已安装的(跨页生效)', [awesomePlugins.length]) }, h('input', { type: 'checkbox', checked: awesomeOnlyInstalled, onChange: (e) => setAwesomeOnlyInstalled(e.target.checked) }), t('只看已安装')),
8934
- h('span', { className: 'dsh-mcp-builtin-count' }, t('显示 {0} / {1}', [visibleAwesome.length, filteredAwesome.length])),
8935
- ] : []),
8936
- )
8937
10736
  : tab === 'expert'
8938
10737
  ? null
8939
10738
  : tab === 'skill'
8940
10739
  ? h('div', { className: 'dsh-mcp-wb-toolbar' },
8941
10740
  skillSearch,
8942
10741
  h('select', {
8943
- className: 'dsh-mcp-input dsh-mcp-wb-sort', value: skillSort, disabled: skillLoading,
10742
+ className: 'dsh-mcp-input dsh-mcp-wb-sort', value: skillSort,
8944
10743
  title: t('SkillHub 已不再提供全站下载量排序;这一项只在当前页内按下载量排序。'),
8945
10744
  onChange: (e) => { setSkillSort(e.target.value); setSkillPage(1); },
8946
10745
  }, WB_SKILLHUB_SORTS.map((option) => h('option', { key: option.key, value: option.key }, option.label))),
8947
10746
  h('label', { className: 'dsh-mcp-check', title: t('仅在本页 {0} 条里筛出已安装的(SkillHub 是远程分页,无法跨页筛选)', [skills.length]) },
8948
- h('input', { type: 'checkbox', checked: skillOnlyInstalled, disabled: skillLoading, onChange: (e) => setSkillOnlyInstalled(e.target.checked) }),
10747
+ h('input', { type: 'checkbox', checked: skillOnlyInstalled, onChange: (e) => setSkillOnlyInstalled(e.target.checked) }),
8949
10748
  t('只看已安装')),
8950
10749
  skillOnlyInstalled
8951
10750
  ? h('span', { className: 'dsh-mcp-builtin-count' }, t('本页已安装 {0} / {1}', [installedOnPage, skills.length]))
@@ -8957,7 +10756,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8957
10756
  busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
8958
10757
  error ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, error) : null,
8959
10758
  // 技能加载失败只落在这一行(带重试),不再借用整页 banner:一个上游接口挂了
8960
- // 不该让「连接器 / 内置 / 插件 / 专家」页签看起来都坏了。
10759
+ // 不该让「连接器 / 技能 / 插件 / 专家」页签看起来都坏了。
8961
10760
  tab === 'skill' && skillError
8962
10761
  ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, skillError, ' ',
8963
10762
  h('button', {
@@ -9021,7 +10820,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
9021
10820
  // 完整原因仍留在 blockedReason,hover 卡片或角标都能看到。
9022
10821
  item.installable ? null : h('span', { className: 'dsh-mcp-mk-corner', title: item.blockedReason || t('不兼容') }, t('不兼容')),
9023
10822
  h('div', { className: 'dsh-mcp-card-top' },
9024
- h('div', null, h('div', { className: 'dsh-mcp-card-name' }, item.name)),
10823
+ // 头像来自官方分发包 ZIP 的 icons/<id>(ConnectorAvatar 进视口才取),
10824
+ // 布局与技能卡同款 person 行:头像 + 名字并排,徽标仍靠右。
10825
+ h('div', { className: 'dsh-mcp-card-person' },
10826
+ h(ConnectorAvatar, { call, connectorId: item.id, name: item.name, className: 'dsh-mcp-card-avatar' }),
10827
+ h('div', null, h('div', { className: 'dsh-mcp-card-name' }, item.name)),
10828
+ ),
9025
10829
  // T04/R8:已安装的条目优先显示「已安装」,不再提示可安装。
9026
10830
  installedConnectorKeys.has(String(item.id || ''))
9027
10831
  ? h(Badge, { cls: 's-on' }, t('已安装'))
@@ -9059,94 +10863,33 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
9059
10863
  tab === 'connector' && connectorPageCount > 1
9060
10864
  ? h(Pager, { page: safeConnectorPage, pageCount: connectorPageCount, total: sortedConnectors.length, onPage: setConnectorPage })
9061
10865
  : null,
9062
- tab === 'builtin' && builtinError
9063
- ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, builtinError, ' ',
9064
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: loadBuiltins, disabled: builtinsLoading }, t('重试')))
9065
- : null,
9066
- tab === 'builtin' && !builtinError && !filteredBuiltins.length
9067
- ? (builtinsLoading
9068
- ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取 MCP 目录…'))
9069
- // §1.4 空状态四件套。这里是最典型的「裸空白」:目录 0 条时原来只有一句
9070
- // 「没有匹配的服务」,用户既不知道是筛选造成的还是目录真的空,也不知道点哪。
9071
- // 主操作按成因分流——有筛选就清筛选(一次点击立刻有结果),没筛选才重读目录。
9072
- : h(EmptyState, {
9073
- icon: 'store',
9074
- title: builtins.length ? t('当前筛选下没有服务') : t('MCP 目录暂时是空的'),
9075
- hint: builtins.length
9076
- ? t('目录里有 {0} 个服务,都被关键词、分类或「只要免密钥」筛掉了。', [builtins.length])
9077
- : t('目录由宿主侧维护,可能还没同步下来;重读一次通常就好。'),
9078
- actions: builtins.length
9079
- ? [
9080
- { label: t('清除全部筛选'), icon: 'x', onClick: () => { setBuiltinQuery(''); setBuiltinCategory('all'); setBuiltinOnlyReady(false); } },
9081
- { label: t('重新读取目录'), icon: 'refresh', onClick: () => { loadBuiltins(); } },
9082
- ]
9083
- : [{ label: t('重新读取目录'), icon: 'refresh', onClick: () => { loadBuiltins(); } }],
9084
- }))
9085
- : null,
9086
- tab === 'builtin' && filteredBuiltins.length
9087
- ? h('div', { className: 'dsh-mcp-wb-grid' },
9088
- filteredBuiltins.map((item) => {
9089
- const http = item.transport === 'streamable-http';
9090
- return h('div', { key: item.id, className: 'dsh-mcp-card wb' + (item.installed ? ' installed' : '') },
9091
- h('div', { className: 'dsh-mcp-card-top' },
9092
- h('div', null,
9093
- h('div', { className: 'dsh-mcp-card-name' }, item.label),
9094
- item.vendor ? h('div', { className: 'dsh-mcp-card-vendor' }, item.vendor) : null,
9095
- ),
9096
- item.installed ? h(Badge, { cls: 's-on' }, t('已配置')) : null,
9097
- ),
9098
- h('div', { className: 'dsh-mcp-card-summary' }, item.summary),
9099
- h('div', { className: 'dsh-mcp-card-tags' },
9100
- h(Badge, { cls: http ? 't-http' : 't-stdio' }, http ? 'HTTP' : 'stdio'),
9101
- item.categoryLabel ? h(Badge, null, item.categoryLabel) : null,
9102
- item.installed
9103
- ? null
9104
- : builtinNeedsKey(item)
9105
- ? h(Badge, null, t('需密钥'))
9106
- : builtinNeedsConfig(item)
9107
- ? h(Badge, null, t('需连接信息'))
9108
- : h(Badge, { cls: 's-off' }, t('免密钥')),
9109
- // §1.3 色语:缺参数是「还需注意」不是「已故障」,用橙不用红——
9110
- // 红色的名额留给真正的失败与破坏性操作,否则满屏红之后就没人在意红了。
9111
- builtinNeedsArgs(item) ? h(Badge, { cls: 's-warn' }, t('需填参数')) : null,
9112
- // OPT-24 · 2.3:auth 是「装完之后还要做什么」的关键信息——oauth 条目装完
9113
- // 并不等于能用,得去浏览器授权。none 不出徽章(「免密钥」已覆盖这层语义)。
9114
- item.auth && item.auth !== 'none' ? h(Badge, { cls: authBadgeCls(item.auth) }, authKindLabel(item.auth)) : null,
9115
- ),
9116
- h('div', { className: 'dsh-mcp-card-access' }, item.access),
9117
- // authNote 只在宿主给了才显示(oauth 类条目才有),说明授权流程怎么走。
9118
- item.auth && item.auth !== 'none' && item.authNote ? h('div', { className: 'dsh-mcp-card-note' }, item.authNote) : null,
9119
- builtinNeedsKey(item) || builtinNeedsConfig(item)
9120
- ? h('div', { className: 'dsh-mcp-card-vars' },
9121
- builtinNeedsKey(item) ? h('span', null, t('密钥:') + item.envKeys.join(t('、'))) : null,
9122
- builtinNeedsConfig(item) ? h('span', null, t('连接:') + item.configKeys.join(t('、'))) : null,
9123
- )
9124
- : null,
9125
- h('div', { className: 'dsh-mcp-wb-card-actions' },
9126
- item.installed
9127
- ? null
9128
- : h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => installBuiltin(item) }, t('安装')),
9129
- item.homepage
9130
- ? h('a', { className: 'dsh-mcp-btn', href: item.homepage, target: '_blank', rel: 'noreferrer', title: t('打开官方文档') }, t('文档'))
9131
- : null,
9132
- ),
9133
- );
9134
- }),
9135
- )
9136
- : null,
9137
10866
  tab === 'skill' && shownSkills.length
9138
10867
  ? h('div', { className: 'dsh-mcp-wb-grid' },
9139
10868
  shownSkills.map((skill) => {
9140
- const installed = installedSkills.has(skill.slug);
10869
+ const installed = installedSkills.has(skillInstalledKey(skill));
9141
10870
  return h('div', { key: skill.namespace + '/' + skill.slug, className: 'dsh-mcp-card wb' },
9142
10871
  h('div', { className: 'dsh-mcp-card-top' },
9143
- h('div', null,
9144
- h('div', { className: 'dsh-mcp-card-name' }, skill.name),
9145
- h('div', { className: 'dsh-mcp-card-vendor' }, skill.canonical || skill.namespace),
10872
+ h('div', { className: 'dsh-mcp-card-person' },
10873
+ h(Avatar, {
10874
+ url: skill.iconUrl || githubAvatarUrl(skill.homepage),
10875
+ name: skill.ownerName || skill.namespace || skill.name,
10876
+ className: 'dsh-mcp-card-avatar',
10877
+ }),
10878
+ h('div', null,
10879
+ h('div', { className: 'dsh-mcp-card-name' }, skill.name),
10880
+ h('div', { className: 'dsh-mcp-card-vendor' }, skill.canonical || skill.namespace),
10881
+ ),
9146
10882
  ),
9147
10883
  installed ? h(Badge, { cls: 's-on' }, t('已安装')) : null,
9148
10884
  ),
9149
10885
  h('div', { className: 'dsh-mcp-card-summary' }, skill.description || skill.name),
10886
+ h(PresenceLine, {
10887
+ owner: skill.ownerName || skill.namespace,
10888
+ avatar: skill.iconUrl || githubAvatarUrl(skill.homepage),
10889
+ updatedAt: skill.updatedAt,
10890
+ href: /^https?:\/\//i.test(String(skill.homepage || '')) ? skill.homepage : '',
10891
+ className: 'dsh-mcp-presence-card',
10892
+ }),
9150
10893
  h('div', { className: 'dsh-mcp-card-tags' },
9151
10894
  h(Badge, null, `↓${wbFormatCount(skill.downloads)}`),
9152
10895
  skill.installs ? h(Badge, null, t('装 {0}', [wbFormatCount(skill.installs)])) : null,
@@ -9168,135 +10911,118 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
9168
10911
  tab === 'skill' && skillPageCount > 1
9169
10912
  ? h(Pager, { page: skillPage, pageCount: skillPageCount, total: skillTotal, onPage: setSkillPage, disabled: skillLoading })
9170
10913
  : null,
9171
- // ── T03/R5:codebuddy 插件市场 ──
9172
- // 重启提示条:常驻本 tab 顶部(组件卸载即消失,不写 localStorage)——
9173
- // installBundle 装完包,宿主进程不热载,不重启就以为装失败了。
10914
+ // ── 插件安装后的重启提示 ──
10915
+ // 常驻本 tab 顶部(组件卸载即消失,不写 localStorage)——installBundle 装完包,
10916
+ // 宿主进程不热载,不重启就以为装失败了。
9174
10917
  tab === 'plugin' && restartNotice
9175
10918
  ? h('div', { className: 'dsh-mcp-wb-note restart', role: 'status' },
9176
10919
  restartNotice,
9177
10920
  h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => setRestartNotice('') }, t('知道了')),
9178
10921
  )
9179
10922
  : null,
9180
- // T05/R9:插件市场更新提示条。只按 reason 分支(设计文档:UI 只看 reason,不看 error)。
9181
- // no-source / probe-failed / up-to-date → 不提示;no-cache / update-available → 提示。
9182
- // probe-failed 不自动重试(X4),用户可手动点「重新探测」。
9183
- tab === 'plugin' && pluginSource === 'codebuddy' && pluginUpdateHint && (pluginUpdateHint.reason === 'update-available' || pluginUpdateHint.reason === 'no-cache')
9184
- ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' },
9185
- pluginUpdateHint.reason === 'update-available'
9186
- ? t('插件市场有新版本可更新(本地缓存 {0})。点「重新加载」可拉取最新目录。', [pluginUpdateHint.localEtag || t('未知错误')])
9187
- : t('本地还没有插件市场缓存,点「重新加载」可首次拉取目录。'),
9188
- h('button', {
9189
- type: 'button', className: 'dsh-mcp-btn', disabled: pluginsLoading,
9190
- onClick: async () => { setPluginUpdateHint(null); await loadPlugins(); },
9191
- }, t('重新加载')),
9192
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => setPluginUpdateHint(null) }, t('知道了')),
9193
- )
9194
- : null,
9195
- tab === 'plugin' && pluginSource === 'codebuddy' && pluginUpdateHint && pluginUpdateHint.reason === 'probe-failed'
9196
- ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' },
9197
- t('插件市场更新探测失败(不影响已安装插件)。'),
9198
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: pluginsLoading, onClick: () => { pluginUpdateChecked.current = false; checkPluginUpdate(); } }, t('重新探测')),
9199
- )
9200
- : null,
9201
- tab === 'plugin' && pluginSource === 'codebuddy' && pluginsError
9202
- ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, pluginsError, ' ',
9203
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: loadPlugins, disabled: pluginsLoading }, t('重试')))
9204
- : null,
9205
- tab === 'plugin' && pluginSource === 'codebuddy' && pluginsLoading && !plugins.length
9206
- ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取插件目录…'))
9207
- : null,
9208
- tab === 'plugin' && pluginSource === 'codebuddy' && !pluginsError && !pluginsLoading && !plugins.length
9209
- ? h(EmptyState, {
9210
- icon: 'store',
9211
- title: t('插件目录暂时是空的'),
9212
- hint: t('目录来自 codebuddy 官方市场 ZIP 分发,可能还没同步下来;重读一次通常就好。'),
9213
- actions: [{ label: t('重新读取目录'), icon: 'refresh', onClick: loadPlugins }],
9214
- })
9215
- : null,
9216
- tab === 'plugin' && pluginSource === 'codebuddy' && plugins.length && !filteredPlugins.length
9217
- ? h(EmptyState, {
9218
- icon: 'store',
9219
- tight: true,
9220
- title: t('没有匹配的插件'),
9221
- hint: t('换个关键词试试,或清空搜索回到完整目录。'),
9222
- actions: [{ label: t('清空搜索'), icon: 'x', onClick: () => setPluginQuery('') }],
9223
- })
9224
- : null,
9225
- tab === 'plugin' && pluginSource === 'codebuddy' && filteredPlugins.length
9226
- ? h('div', { className: 'dsh-mcp-wb-grid' },
9227
- filteredPlugins.map((item) => {
9228
- const spec = pluginInstallSpec(item);
9229
- return h('div', { key: item.name, className: 'dsh-mcp-card wb' },
9230
- h('div', { className: 'dsh-mcp-card-top' },
9231
- h('div', null,
9232
- h('div', { className: 'dsh-mcp-card-name' }, item.name),
9233
- item.author ? h('div', { className: 'dsh-mcp-card-vendor' }, item.author) : null,
9234
- ),
9235
- // T04/R8:已安装徽章(账本 key 是 plugin.name)。
9236
- installedPluginKeys.has(String(item.name || ''))
9237
- ? h(Badge, { cls: 's-on' }, t('已安装'))
9238
- : null,
9239
- item.version ? h(Badge, null, 'v' + item.version) : null,
9240
- ),
9241
- h('div', { className: 'dsh-mcp-card-summary' }, item.description || item.name),
9242
- h('div', { className: 'dsh-mcp-card-tags' },
9243
- item.license ? h(Badge, null, item.license) : null,
9244
- // R6:dependencies 只展示(缺 = 无依赖不出徽章),绝不自动安装。
9245
- (item.dependencies || []).length ? h(Badge, { title: item.dependencies.map((d) => d.name).join(', ') }, t('依赖 {0}', [item.dependencies.length])) : null,
9246
- ),
9247
- h('div', { className: 'dsh-mcp-wb-card-actions' },
9248
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: async () => {
9249
- setPluginDetail({ ...item, loading: true });
9250
- try {
9251
- const res = await call('wbPluginDetail', { name: item.name });
9252
- setPluginDetail(res?.ok ? { ...res.plugin, loading: false } : { ...item, loading: false, detailError: res?.error || t('读取详情失败') });
9253
- } catch (e) {
9254
- setPluginDetail({ ...item, loading: false, detailError: String(e?.message || e) });
9255
- }
9256
- } }, t('详情')),
9257
- // UNCLEAR#1:repository 可推导才渲染安装按钮;不可推导 → 只展示。
9258
- spec
9259
- ? h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy || isInstallRunning(item.name), onClick: () => installPlugin(item) }, t('安装'))
9260
- : h('span', { className: 'dsh-mcp-wb-blocked', title: t('该插件未提供可推导的安装来源(repository),暂不能安装') }, t('仅展示')),
9261
- ),
9262
- );
9263
- }),
9264
- )
9265
- : null,
9266
10923
  // ── T06/Awesome:DSH 社区(awesome-dsh-plugins)目录 ──
9267
- tab === 'plugin' && pluginSource === 'awesome' && awesomeStale ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, t('目录缓存已过期(更新源暂时不可达,显示的是本地缓存)。'), h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: () => { writeAwesomeProbeAt(0); awesomeUpdateChecked.current = false; loadAwesomeCatalog(true); } }, t('重新加载'))) : null,
9268
- tab === 'plugin' && pluginSource === 'awesome' && awesomeUpdateHint && (awesomeUpdateHint.reason === 'update-available' || awesomeUpdateHint.reason === 'no-cache') ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, awesomeUpdateHint.reason === 'update-available' ? t('DSH 社区目录有新版本(本地缓存 {0})。点「重新加载」可拉取最新目录。', [awesomeUpdateHint.localEtag || t('未知错误')]) : t('本地还没有 DSH 社区目录缓存,点「重新加载」可首次拉取目录。'), h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: async () => { setAwesomeUpdateHint(null); writeAwesomeProbeAt(0); awesomeUpdateChecked.current = false; await loadAwesomeCatalog(true); } }, t('重新加载')), h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => setAwesomeUpdateHint(null) }, t('知道了'))) : null,
10924
+ tab === 'plugin' && awesomeStale ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, t('目录缓存已过期(更新源暂时不可达,显示的是本地缓存)。'), h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: () => { writeAwesomeProbeAt(0); awesomeUpdateChecked.current = false; loadAwesomeCatalog(true); } }, t('重新加载'))) : null,
10925
+ tab === 'plugin' && awesomeUpdateHint && (awesomeUpdateHint.reason === 'update-available' || awesomeUpdateHint.reason === 'no-cache') ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, awesomeUpdateHint.reason === 'update-available' ? t('DSH 社区目录有新版本(本地缓存 {0})。点「重新加载」可拉取最新目录。', [awesomeUpdateHint.localEtag || t('未知错误')]) : t('本地还没有 DSH 社区目录缓存,点「重新加载」可首次拉取目录。'), h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: async () => { setAwesomeUpdateHint(null); writeAwesomeProbeAt(0); awesomeUpdateChecked.current = false; await loadAwesomeCatalog(true); } }, t('重新加载')), h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => setAwesomeUpdateHint(null) }, t('知道了'))) : null,
9269
10926
 
9270
- tab === 'plugin' && pluginSource === 'awesome' && awesomeError ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, awesomeError, h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: () => loadAwesomeCatalog() }, t('重试'))) : null,
9271
- tab === 'plugin' && pluginSource === 'awesome' && awesomeLoading && !awesomePlugins.length ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在加载 DSH 社区目录…')) : null,
9272
- tab === 'plugin' && pluginSource === 'awesome' && !awesomeError && !awesomeLoading && !awesomePlugins.length ? h(EmptyState, { icon: 'store', title: t('DSH 社区目录暂时是空的'), hint: t('目录来自 awesome-dsh-plugins 社区清单(jsdelivr CDN 分发),拉取失败可重试。'), actions: [{ label: t('重新读取目录'), icon: 'refresh', onClick: () => loadAwesomeCatalog() }] }) : null,
9273
- tab === 'plugin' && pluginSource === 'awesome' && awesomePlugins.length && !visibleAwesome.length ? h(EmptyState, { tight: true, title: t('没有匹配的社区插件'), hint: t('换个关键词,或清空分类与标签筛选。'), actions: [{ label: t('清空筛选'), icon: 'x', onClick: () => { setAwesomeQuery(''); setAwesomeCategory('all'); setAwesomeTags([]); } }] }) : null,
9274
- tab === 'plugin' && pluginSource === 'awesome' && visibleAwesome.length ? h('div', { className: 'dsh-mcp-wb-grid' }, visibleAwesome.map((item) => h('div', { key: item.name, className: 'dsh-mcp-card wb dsh-mcp-card-clickable', role: 'button', tabIndex: 0, title: t('查看 README 介绍与插图'), onClick: () => loadAwesomeReadme(item), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); loadAwesomeReadme(item); } } },
9275
- h('div', { className: 'dsh-mcp-card-top' },
9276
- h('div', null,
9277
- h('div', { className: 'dsh-mcp-card-name' }, item.name),
9278
- item.repo ? h('div', { className: 'dsh-mcp-card-vendor', title: item.repo }, String(item.repo).replace(/^https?:\/\/github\.com\//i, '')) : null),
9279
- installedPluginKeys.has(String(item.name || '')) ? h(Badge, { cls: 's-on' }, t('已安装')) : null,
9280
- item.official ? h(Badge, { cls: 's-on' }, t('官方')) : null,
9281
- item.verifiedAgainst ? h(Badge, { title: item.lastVerified ? String(item.lastVerified) : undefined }, '✓ verified (dsh ' + item.verifiedAgainst + ')') : null),
9282
- h('div', { className: 'dsh-mcp-card-summary' }, item.description || item.name),
9283
- h('div', { className: 'dsh-mcp-card-tags' },
9284
- item.npm ? h(Badge, { title: item.npm }, 'npm') : null,
9285
- item.stars ? h(Badge, null, '★ ' + item.stars) : null,
9286
- item.category && item.category !== 'plugin' ? h(Badge, null, item.category) : null,
9287
- (item.tags || []).map((tag) => h('span', { key: tag, className: 'dsh-mcp-tag-chip on' }, tag))),
9288
- h('div', { className: 'dsh-mcp-wb-card-actions', onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation() },
9289
- item.npm ? (String(item.npm).startsWith('@deepseek-ai/') ? h('span', { className: 'dsh-mcp-wb-blocked', title: t('DSH 宿主官方组件随宿主本体分发,无法也不需要从市场安装') }, t('宿主组件')) : h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy || isInstallRunning(item.name), onClick: () => installPlugin(item) }, t('安装'))) : h('span', { className: 'dsh-mcp-wb-blocked', title: t('该条目未提供 npm 包名,暂不支持安装') }, t('暂不支持安装')))))) : null,
9290
- tab === 'plugin' && pluginSource === 'awesome' && awesomePageCount > 1 ? h(Pager, { page: safeAwesomePage, pageCount: awesomePageCount, total: sortedAwesome.length, onPage: setAwesomePage }) : null,
10927
+ tab === 'plugin' && awesomeError ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, awesomeError, h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: () => loadAwesomeCatalog() }, t('重试'))) : null,
10928
+ tab === 'plugin' && awesomeLoading && !awesomePlugins.length ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在加载 DSH 社区目录…')) : null,
10929
+ tab === 'plugin' && !awesomeError && !awesomeLoading && !awesomePlugins.length ? h(EmptyState, { icon: 'store', title: t('DSH 社区目录暂时是空的'), hint: t('目录来自 awesome-dsh-plugins 社区清单(jsdelivr CDN 分发),拉取失败可重试。'), actions: [{ label: t('重新读取目录'), icon: 'refresh', onClick: () => loadAwesomeCatalog() }] }) : null,
10930
+ tab === 'plugin' && awesomePlugins.length && !visibleAwesome.length ? h(EmptyState, { tight: true, title: t('没有匹配的社区插件'), hint: t('换个关键词,或清空分类与标签筛选。'), actions: [{ label: t('清空筛选'), icon: 'x', onClick: () => { setAwesomeQuery(''); setAwesomeCategory('all'); setAwesomeTags([]); } }] }) : null,
10931
+ tab === 'plugin' && visibleAwesome.length ? h('div', { className: 'dsh-mcp-wb-grid' }, visibleAwesome.map((item) => {
10932
+ const repoKey = parseGithubRepoKey(item.repo);
10933
+ const repoLabel = item.repo ? String(item.repo).replace(/^https?:\/\/github\.com\//i, '') : (item.npm ? String(item.npm) : '');
10934
+ const metaTitle = item.repo ? String(item.repo) : (item.npm ? String(item.npm) : '');
10935
+ const verifiedLabel = item.verifiedAgainst ? t('已验证 · dsh {0}', [item.verifiedAgainst]) : '';
10936
+ // 42.3% 的社区条目 npm 包名 ≠ 显示名,装的时候只能用包名,以前卡面上完全不显示。
10937
+ // 它排第一,且是标签行里唯一可收缩的一枚(CSS .dsh-mcp-pchip.mono 是 flex:0 1 auto):
10938
+ // 放不下时它自己省略号截断,后面的分类/标签不会被挤成半截;完整包名进 title 兜底。
10939
+ const npmPackage = item.npm && String(item.npm) !== String(item.name || '') ? String(item.npm) : '';
10940
+ const chips = [];
10941
+ if (npmPackage) chips.push(npmPackage);
10942
+ if (item.category && item.category !== 'plugin' && !chips.includes(item.category)) chips.push(item.category);
10943
+ for (const tag of item.tags || []) if (tag && !chips.includes(tag)) chips.push(tag);
10944
+ const shownChips = chips.slice(0, 3);
10945
+ const extraChips = chips.length - shownChips.length;
10946
+ const stars = Number(item.stars) || 0;
10947
+ const verifiedDate = item.lastVerified ? wbFormatDate(timestampOf(item.lastVerified)).slice(5) : '';
10948
+ // 底栏三枚事实,对齐技能卡的「↓N ★N 日期」,但卡高一 px 都不长:
10949
+ // ↓ 近 30 天 npm 下载量——目录里没这字段,按包名去 npm 公开统计接口补;问不到
10950
+ // (下架 / 未被统计)就整枚不画,绝不显示 0 冒充「没人下载」。
10951
+ // ★ 仓库星数,目录自带。
10952
+ // 核验 MM-DD 是**目录批次核验日期**,不是插件自身的更新时间(6430 条里 37.5%
10953
+ // 同一批核验);所以带「核验」二字与说明 title,宁可不写也不伪造「更新于」。
10954
+ // 「dsh 版本」不再占这一行,退回右上角已验证徽标的 title(装不装看的是已验证)。
10955
+ const downloads = npmDownloadsFor(item.npm);
10956
+ const dateFact = verifiedDate ? t('核验 {0}', [verifiedDate]) : '';
10957
+ const sealTip = verifiedLabel ? verifiedLabel + (verifiedDate ? ' · ' + t('核验 {0}', [verifiedDate]) : '') : '';
10958
+ const installed = installedPluginKeys.has(String(item.name || ''));
10959
+ const hostComponent = item.npm && String(item.npm).startsWith('@deepseek-ai/');
10960
+ return h('div', { key: item.name, className: 'dsh-mcp-pcard', role: 'button', tabIndex: 0, title: t('查看 README 介绍与插图'), 'aria-label': t('查看 {0} 的 README', [item.name]), onClick: () => loadAwesomeReadme(item), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); loadAwesomeReadme(item); } } },
10961
+ h('div', { className: 'dsh-mcp-phead' },
10962
+ h('span', { className: 'dsh-mcp-pavatar', 'aria-hidden': true },
10963
+ String(item.name || '?').slice(0, 1).toUpperCase() || '?',
10964
+ repoKey ? h('img', {
10965
+ src: githubAvatarUrl(repoKey), alt: '', loading: 'lazy', referrerPolicy: 'no-referrer',
10966
+ // 头像加载失败就藏起图片(不删 React 管理的节点),露出底下的首字母,不留空白圆圈。
10967
+ onError: (e) => { e.currentTarget.style.display = 'none'; },
10968
+ }) : null),
10969
+ h('div', { className: 'dsh-mcp-piden' },
10970
+ h('h3', { className: 'dsh-mcp-pname', title: item.name }, item.name),
10971
+ h('div', { className: 'dsh-mcp-pmeta' },
10972
+ item.official ? h('span', { className: 'official' }, h('span', { className: 'odot', 'aria-hidden': true }), t('官方')) : null,
10973
+ item.official && repoLabel ? h('span', { className: 'pdot', 'aria-hidden': true }, '·') : null,
10974
+ repoLabel ? h('span', { className: 'dsh-mcp-prepo', title: metaTitle }, repoLabel) : null)),
10975
+ verifiedLabel ? h('span', { className: 'dsh-mcp-pverified', role: 'img', 'aria-label': verifiedLabel, title: sealTip || verifiedLabel }, h(VerifiedSeal, { size: 15 })) : null),
10976
+ h('div', { className: 'dsh-mcp-pdesc' }, item.description || item.name),
10977
+ h('div', { className: 'dsh-mcp-pchips' },
10978
+ shownChips.map((tag) => h('span', { key: tag, className: 'dsh-mcp-pchip' + (tag === npmPackage ? ' mono' : ''), title: tag === npmPackage ? npmPackage : undefined }, tag)),
10979
+ extraChips > 0 ? h('span', { className: 'dsh-mcp-pchip more', title: chips.slice(3).join(', ') }, '+' + extraChips) : null),
10980
+ h('div', { className: 'dsh-mcp-pfoot' },
10981
+ h(PluginCardFacts, {
10982
+ downloadsText: downloads && downloads > 0 ? wbFormatCount(downloads) : '',
10983
+ downloadsTitle: t('npm 近 30 天下载量'),
10984
+ starText: stars ? wbFormatCount(stars) : '',
10985
+ starTitle: '★ ' + stars,
10986
+ dateText: dateFact,
10987
+ dateTitle: t('核验日期来自目录批次核验,不是插件自身的更新时间'),
10988
+ }),
10989
+ h('div', { className: 'dsh-mcp-wb-card-actions', onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation() },
10990
+ item.npm
10991
+ ? (hostComponent
10992
+ ? h('span', { className: 'dsh-mcp-phost', title: t('DSH 宿主官方组件随宿主本体分发,无法也不需要从市场安装') }, h(Icon, { className: 'ico', d: ICONS.info, size: 11 }), t('宿主组件'))
10993
+ : h('button', { type: 'button', className: 'dsh-mcp-pbtn' + (installed ? ' installed' : ''), disabled: busy || isInstallRunning(item.name), onClick: () => installPlugin(item) }, installed ? t('已安装') : t('安装')))
10994
+ : h('span', { className: 'dsh-mcp-phost', title: t('该条目未提供 npm 包名,暂不支持安装') }, t('暂不支持安装')))));
10995
+ })) : null,
10996
+ tab === 'plugin' && awesomePageCount > 1 ? h(Pager, { page: safeAwesomePage, pageCount: awesomePageCount, total: sortedAwesome.length, onPage: setAwesomePage }) : null,
9291
10997
  // ── DSH 社区插件 README 预览(点卡片打开;插图来自 README 自身的相对/绝对地址)──
9292
10998
  readmeView
9293
10999
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => { if (e.target === e.currentTarget) closeReadme(); } },
9294
11000
  h('div', { ref: readmeNodeRef, className: 'dsh-mcp-modal dsh-mcp-wb-modal dsh-mcp-readme-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('{0} README 预览', [readmeView.item && readmeView.item.name ? readmeView.item.name : '']), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape') { e.stopPropagation(); closeReadme(); } } },
9295
- h('h3', null, readmeView.item && readmeView.item.name ? readmeView.item.name : ''),
11001
+ h('div', { className: 'dsh-mcp-detail-person' },
11002
+ h(Avatar, { url: githubAvatarUrl(readmeView.item?.repo, 88), name: readmeView.item?.name, className: 'dsh-mcp-detail-avatar' }),
11003
+ h('div', null,
11004
+ h('h3', null, readmeView.item && readmeView.item.name ? readmeView.item.name : ''),
11005
+ readmeView.item && readmeView.item.repo
11006
+ ? h('div', { className: 'dsh-mcp-card-vendor', title: readmeView.item.repo }, String(readmeView.item.repo).replace(/^https?:\/\/github\.com\//i, ''))
11007
+ : null,
11008
+ ),
11009
+ ),
9296
11010
  h('div', { className: 'dsh-mcp-readme-meta' },
9297
11011
  readmeView.item && readmeView.item.repo ? h(Badge, { title: readmeView.item.repo }, String(readmeView.item.repo).replace(/^https?:\/\/github\.com\//i, '')) : null,
9298
11012
  readmeView.item && readmeView.item.npm ? h(Badge, { title: readmeView.item.npm }, 'npm') : null,
9299
11013
  readmeView.source ? h(Badge, null, readmeView.source === 'npm' ? 'npm registry' : 'GitHub') : null),
11014
+ h(CommunityPresence, {
11015
+ call,
11016
+ repo: readmeView.item && readmeView.item.repo,
11017
+ owner: readmeView.item ? parseGithubRepoKey(readmeView.item.repo).split('/')[0] : '',
11018
+ compact: true,
11019
+ }),
11020
+ // README 可能很长:出口放在正文之前,打开即点得到。
11021
+ readmeView.item ? h(FeedbackLinks, {
11022
+ item: readmeView.item, kind: 'plugin',
11023
+ repo: readmeView.item.repo, homepage: readmeView.item.homepage,
11024
+ sourceLabel: readmeView.item.repo ? String(readmeView.item.repo).replace(/^https?:\/\/github\.com\//i, '') : '',
11025
+ }) : null,
9300
11026
  readmeView.loading
9301
11027
  ? h('p', { className: 'dsh-mcp-wb-modal-note', role: 'status' }, t('正在读取 README…'))
9302
11028
  : readmeView.ok
@@ -9306,7 +11032,23 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
9306
11032
  h('div', { className: 'dsh-mcp-modal-actions' },
9307
11033
  h(Btn, { onClick: closeReadme, children: t('关闭') }),
9308
11034
  readmeView.item && readmeView.item.npm ? h(Btn, { primary: true, icon: 'plus', disabled: busy || isInstallRunning(readmeView.item.name), onClick: () => { const target = readmeView.item; closeReadme(); installPlugin(target); } }, t('安装')) : null,
9309
- h(Btn, { onClick: () => loadAwesomeReadme(readmeView.item, true), disabled: readmeView.loading, children: t('重新读取') }))))
11035
+ h(Btn, { onClick: () => loadAwesomeReadme(readmeView.item, true), disabled: readmeView.loading, children: t('重新读取') })),
11036
+ // README 可能很长:右下角常驻「跳到末尾」。sticky 让它不随正文滚走,又留在
11037
+ // 弹层内部(焦点陷阱、Esc 与点遮罩关闭都不受影响);外层不拦鼠标,只有按钮可点。
11038
+ readmeView.ok
11039
+ ? h('div', { className: 'dsh-mcp-readme-jump-wrap' },
11040
+ h('button', {
11041
+ type: 'button',
11042
+ className: 'dsh-mcp-readme-jump',
11043
+ title: t('滑动到 README 末尾'),
11044
+ 'aria-label': t('滑动到 README 末尾'),
11045
+ onClick: () => {
11046
+ const node = readmeNodeRef.current;
11047
+ if (!node) return;
11048
+ node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
11049
+ },
11050
+ }, h(Icon, { d: ICONS.down, size: 12 }), h('span', null, t('跳到末尾'))))
11051
+ : null))
9310
11052
  : null,
9311
11053
  // ── T04/R7:令牌弹窗(连接器 mcp.json 里的 ${...} 占位符)──
9312
11054
  // 设计约束:只收集用户输入并原样透传;槽位来自目录条目的 tokenSlots,
@@ -9349,7 +11091,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
9349
11091
  );
9350
11092
  })()
9351
11093
  : null,
9352
- tab === 'expert' ? h(ExpertMarket, { call }) : null,
11094
+ tab === 'expert' ? h(ExpertMarket, { call, key: 'expert-' + expertEpoch }) : null,
9353
11095
  // ── R8:OAuth 授权弹层(401 → 浏览器授权 → 自动重装)──
9354
11096
  authDialog
9355
11097
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && closeAuthDialog() },
@@ -9358,9 +11100,11 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
9358
11100
  h('p', { className: 'dsh-mcp-wb-modal-note', role: 'note' },
9359
11101
  authDialog.status === 'authorized'
9360
11102
  ? t('{0}:授权成功,正在完成安装…', [authDialog.item?.name || authDialog.serverName])
9361
- : t('该连接器要求在浏览器里完成一次登录授权。授权页会尝试自动打开;授权完成后这里会自动继续,无需手动刷新。')),
11103
+ : authDialog.mode === 'manual-token'
11104
+ ? t('该服务器没有提供标准 OAuth 授权发现,无法自动打开授权页。如果你已经从服务方拿到访问令牌,粘贴在下面即可安装。')
11105
+ : t('该连接器要求在浏览器里完成一次登录授权。授权页会尝试自动打开;授权完成后这里会自动继续,无需手动刷新。')),
9362
11106
  authDialog.error
9363
- ? h('p', { className: 'dsh-mcp-wb-modal-note', role: 'alert', style: 'color:var(--dsw-alias-state-warn-primary)' }, authDialog.error)
11107
+ ? h('p', { className: 'dsh-mcp-wb-modal-note', role: 'alert', style: { color: 'var(--dsw-alias-state-warn-primary)' } }, authDialog.error)
9364
11108
  : null,
9365
11109
  authDialog.authorizeUrl
9366
11110
  ? h('label', { className: 'dsh-mcp-wb-tokenfield' },
@@ -9422,7 +11166,18 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
9422
11166
  connectorDetail
9423
11167
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setConnectorDetail(null) },
9424
11168
  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); } } },
9425
- h('h3', null, connectorDetail.name),
11169
+ // 头像行与技能详情同款(detail-person):图标 + 名称并排。
11170
+ h('div', { className: 'dsh-mcp-detail-person' },
11171
+ h(ConnectorAvatar, {
11172
+ call, connectorId: connectorDetail.id, name: connectorDetail.name,
11173
+ className: 'dsh-mcp-detail-avatar',
11174
+ }),
11175
+ h('div', null, h('h3', null, connectorDetail.name)),
11176
+ ),
11177
+ h(FeedbackLinks, {
11178
+ item: connectorDetail, kind: 'connector',
11179
+ sourceLabel: t('来源:WorkBuddy 官方分发'),
11180
+ }),
9426
11181
  // T02/R3:详情正文同样 examples 优先,description 兜底。
9427
11182
  connectorDetail.displayBody || connectorDetail.description
9428
11183
  ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, connectorDetail.displayBody || connectorDetail.description) : null,
@@ -9456,50 +11211,33 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
9456
11211
  ),
9457
11212
  )
9458
11213
  : null,
9459
- // ── T03/R5:插件详情弹层(repository/homepage/lspServers 全量透出;X7 lspServers 只展示)──
9460
- pluginDetail
9461
- ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setPluginDetail(null) },
9462
- h('div', { ref: pluginDetailRef, className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('插件详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setPluginDetail(null); } } },
9463
- h('h3', null, pluginDetail.name),
9464
- pluginDetail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, pluginDetail.description) : null,
9465
- pluginDetail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, pluginDetail.detailError) : null,
9466
- h('div', { className: 'dsh-mcp-card-tags' },
9467
- pluginDetail.version ? h(Badge, null, 'v' + pluginDetail.version) : null,
9468
- pluginDetail.author ? h(Badge, null, pluginDetail.author) : null,
9469
- pluginDetail.license ? h(Badge, null, pluginDetail.license) : null,
9470
- ),
9471
- (pluginDetail.dependencies || []).length
9472
- ? h('div', { className: 'dsh-mcp-wb-examples' },
9473
- h('b', null, t('依赖')),
9474
- h('ul', null, pluginDetail.dependencies.map((dep) => h('li', { key: dep.name }, dep.name + (dep.version ? t('({0})', [dep.version]) : '')))),
9475
- )
9476
- : null,
9477
- pluginDetail.lspServers
9478
- ? h('p', { className: 'dsh-mcp-wb-modal-note' },
9479
- t('本插件声明了 {0} 个 LSP 服务,安装后由宿主按声明启动(此处仅展示)。', [Object.keys(pluginDetail.lspServers).length]))
9480
- : null,
9481
- pluginDetail.repository || pluginDetail.homepage
9482
- ? h('p', { className: 'dsh-mcp-wb-modal-note' },
9483
- pluginDetail.repository ? t('来源:') + pluginDetail.repository + ' ' : '',
9484
- pluginDetail.homepage ? t('主页:') + pluginDetail.homepage : '')
9485
- : null,
9486
- h('p', { className: 'dsh-mcp-wb-modal-note' },
9487
- t('安装 = 通过宿主 pluginManager 拉取并落盘;安装成功后需重启 DSH 宿主才会生效。')),
9488
- h('div', { className: 'dsh-mcp-modal-actions' },
9489
- h(Btn, { onClick: () => setPluginDetail(null), disabled: busy, children: t('关闭') }),
9490
- pluginInstallSpec(pluginDetail)
9491
- ? h(Btn, { primary: true, icon: 'plus', disabled: busy || pluginDetail.loading || isInstallRunning(pluginDetail.name), onClick: () => installPlugin(pluginDetail), children: busy || isInstallRunning(pluginDetail.name) ? t('安装中…') : t('安装') })
9492
- : h('span', { className: 'dsh-mcp-wb-blocked' }, t('该插件未提供可推导的安装来源(repository),暂不能安装')),
9493
- ),
9494
- ),
9495
- )
9496
- : null,
9497
11214
  // ── 技能详情弹层:安全报告 + 发布方 + 安装/卸载 ──
9498
11215
  skillDetail
9499
11216
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setSkillDetail(null) },
9500
11217
  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); } } },
9501
- h('h3', null, skillDetail.name),
9502
- h('div', { className: 'dsh-mcp-card-vendor' }, skillDetail.canonical || skillDetail.namespace || ''),
11218
+ h('div', { className: 'dsh-mcp-detail-person' },
11219
+ h(Avatar, {
11220
+ url: skillDetail.iconUrl || githubAvatarUrl(skillDetail.homepage, 88),
11221
+ name: skillDetail.ownerName || skillDetail.publisher || skillDetail.namespace,
11222
+ className: 'dsh-mcp-detail-avatar',
11223
+ }),
11224
+ h('div', null,
11225
+ h('h3', null, skillDetail.name),
11226
+ h('div', { className: 'dsh-mcp-card-vendor' }, skillDetail.canonical || skillDetail.namespace || ''),
11227
+ ),
11228
+ ),
11229
+ h(CommunityPresence, {
11230
+ call, repo: skillDetail.homepage || skillDetail.upstreamUrl,
11231
+ owner: skillDetail.ownerName || skillDetail.publisher,
11232
+ avatar: skillDetail.iconUrl,
11233
+ updatedAt: skillDetail.updatedAt,
11234
+ }),
11235
+ h(FeedbackLinks, {
11236
+ item: skillDetail, kind: 'skill',
11237
+ repo: parseGithubRepoKey(skillDetail.homepage || skillDetail.upstreamUrl) ? (skillDetail.homepage || skillDetail.upstreamUrl) : '',
11238
+ homepage: skillDetail.homepage || skillDetail.upstreamUrl,
11239
+ sourceLabel: skillDetail.ownerName || skillDetail.publisher || t('SkillHub'),
11240
+ }),
9503
11241
  skillDetail.loading
9504
11242
  ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在读取详情…'))
9505
11243
  : h('div', null,
@@ -9509,6 +11247,8 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
9509
11247
  skillDetail.version ? h(Badge, null, 'v' + skillDetail.version) : null,
9510
11248
  skillDetail.publisher ? h(Badge, null, skillDetail.publisher + (skillDetail.verifiedPublisher ? t('(已认证)') : '')) : null,
9511
11249
  skillDetail.certifiedName ? h(Badge, { cls: 's-on' }, skillDetail.certifiedName) : null,
11250
+ skillDetail.createdAt ? h(Badge, null, t('加入时间 {0}', [wbFormatDate(skillDetail.createdAt)])) : null,
11251
+ skillDetail.updatedAt ? h(Badge, null, t('更新于 {0}', [relativeLabel(skillDetail.updatedAt)])) : null,
9512
11252
  Array.isArray(skillDetail.security) ? skillDetail.security.map((status, index) => h(Badge, { key: index, cls: status === 'benign' ? 's-on' : '' }, t('安全: ') + status)) : null,
9513
11253
  ),
9514
11254
  skillDetail.changelog ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('最新版本说明:') + skillDetail.changelog) : null,
@@ -9517,13 +11257,21 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
9517
11257
  ),
9518
11258
  h('div', { className: 'dsh-mcp-modal-actions' },
9519
11259
  h(Btn, { onClick: () => setSkillDetail(null), disabled: busy, children: t('关闭') }),
9520
- installedSkills.has(skillDetail.slug)
11260
+ installedSkills.has(skillInstalledKey(skillDetail))
9521
11261
  ? h(Btn, { danger: true, icon: 'trash', disabled: busy, onClick: () => uninstallSkill(skillDetail), children: t('卸载') })
9522
11262
  : h(Btn, { primary: true, icon: 'plus', disabled: busy || skillDetail.loading, onClick: () => installSkill(skillDetail), children: busy ? t('安装中…') : t('安装') }),
9523
11263
  ),
9524
11264
  ),
9525
11265
  )
9526
11266
  : null,
11267
+ // 装备清单:导出 / 导入对比。只读,不自动安装。
11268
+ loadoutOpen
11269
+ ? h(LoadoutModal, {
11270
+ call,
11271
+ onClose: () => setLoadoutOpen(false),
11272
+ onLocate: locateLoadoutRow,
11273
+ })
11274
+ : null,
9527
11275
  );
9528
11276
  };
9529
11277
 
@@ -10123,7 +11871,7 @@ const McpBoardPage = (props) => {
10123
11871
  // '/' 聚焦搜索框:宿主会话页也把 '/' 当命令入口,所以这里必须避开输入态,
10124
11872
  // 并且只在事件目标不在可编辑元素内时才接管。
10125
11873
  if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !isTyping(e.target)) {
10126
- 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');
11874
+ 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, .dsh-mcp-page .dsh-mcp-skill-search input');
10127
11875
  if (box) { e.preventDefault(); box.focus(); box.select?.(); }
10128
11876
  }
10129
11877
  };
@@ -10133,38 +11881,43 @@ const McpBoardPage = (props) => {
10133
11881
 
10134
11882
  return h('div', { className: 'dsh-mcp-page' },
10135
11883
  h('div', { className: 'dsh-mcp-wrap' },
10136
- h('div', { className: 'dsh-mcp-cats', role: 'tablist', 'aria-label': t('看板分类'), ref: catsRef },
10137
- BOARD_CATEGORIES.map((cat) =>
10138
- h('button', {
10139
- key: cat.key,
10140
- type: 'button',
10141
- role: 'tab',
10142
- 'aria-pressed': category === cat.key,
10143
- 'aria-selected': category === cat.key,
10144
- className: 'dsh-mcp-cat',
10145
- onClick: () => setCategory(cat.key),
10146
- // §1.1 hover 摘要替代原来的 title 长文案:title 要等一秒、不可扫读、
10147
- // 移动端根本没有。摘要用同一份数据渲染成两三行事实,鼠标停住就能读。
10148
- onMouseEnter: (e) => showCatHint(cat.key, e),
10149
- onMouseLeave: hideCatHint,
10150
- onFocus: (e) => showCatHint(cat.key, e),
10151
- onBlur: hideCatHint,
10152
- },
10153
- h(Icon, { d: ICONS[cat.icon], size: 14 }),
10154
- cat.label,
10155
- cat.key === 'mcp' && mcpCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, mcpCount) : null,
10156
- cat.key === 'skill' && skillCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, skillCount) : null,
10157
- cat.key === 'tailor' && tailorReport && typeof tailorReport.deadMcpCount === 'number' ? h('span', { className: 'dsh-mcp-cat-count' }, tailorReport.deadMcpCount) : null,
10158
- cat.key === 'expert' && expertCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, expertCount) : null,
10159
- cat.key === 'wb' && mkCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, mkCount) : null,
10160
- // 异常红点:只在「这一页里有需要处理的东西」时出现,不参与布局也不改宽度。
10161
- // 裁缝页的死 MCP 用 warn(橙)——它只是白交税,不是错误;
10162
- // 若无死工具则完全不点,避免"每个标签都带点"导致红点失效。
10163
- cat.key === 'tailor' && tailorReport && typeof tailorReport.deadMcpCount === 'number' && tailorReport.deadMcpCount > 0
10164
- ? h('span', { className: 'dsh-mcp-cat-alert warn', 'aria-hidden': true })
10165
- : null,
10166
- )),
10167
- catHint ? h(CatHintPopover, { hint: catHint, category, counts: { mcp: mcpCount, skill: skillCount, expert: expertCount, tailor: tailorReport, wb: mkCount } }) : null,
11884
+ // 分类栏与右端「点 Star」共行。star 刻意挂在 role=tablist **之外**:
11885
+ // tablist 里只该有 tab,把非 tab 控件塞进去会让读屏器的 tab 计数出错。
11886
+ h('div', { className: 'dsh-mcp-cats-row' },
11887
+ h('div', { className: 'dsh-mcp-cats', role: 'tablist', 'aria-label': t('看板分类'), ref: catsRef },
11888
+ BOARD_CATEGORIES.map((cat) =>
11889
+ h('button', {
11890
+ key: cat.key,
11891
+ type: 'button',
11892
+ role: 'tab',
11893
+ 'aria-pressed': category === cat.key,
11894
+ 'aria-selected': category === cat.key,
11895
+ className: 'dsh-mcp-cat',
11896
+ onClick: () => setCategory(cat.key),
11897
+ // §1.1 hover 摘要替代原来的 title 长文案:title 要等一秒、不可扫读、
11898
+ // 移动端根本没有。摘要用同一份数据渲染成两三行事实,鼠标停住就能读。
11899
+ onMouseEnter: (e) => showCatHint(cat.key, e),
11900
+ onMouseLeave: hideCatHint,
11901
+ onFocus: (e) => showCatHint(cat.key, e),
11902
+ onBlur: hideCatHint,
11903
+ },
11904
+ h(Icon, { d: ICONS[cat.icon], size: 14 }),
11905
+ cat.label,
11906
+ cat.key === 'mcp' && mcpCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, mcpCount) : null,
11907
+ cat.key === 'skill' && skillCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, skillCount) : null,
11908
+ cat.key === 'tailor' && tailorReport && typeof tailorReport.deadMcpCount === 'number' ? h('span', { className: 'dsh-mcp-cat-count' }, tailorReport.deadMcpCount) : null,
11909
+ cat.key === 'expert' && expertCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, expertCount) : null,
11910
+ cat.key === 'wb' && mkCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, mkCount) : null,
11911
+ // 异常红点:只在「这一页里有需要处理的东西」时出现,不参与布局也不改宽度。
11912
+ // 裁缝页的死 MCP 用 warn(橙)——它只是白交税,不是错误;
11913
+ // 若无死工具则完全不点,避免"每个标签都带点"导致红点失效。
11914
+ cat.key === 'tailor' && tailorReport && typeof tailorReport.deadMcpCount === 'number' && tailorReport.deadMcpCount > 0
11915
+ ? h('span', { className: 'dsh-mcp-cat-alert warn', 'aria-hidden': true })
11916
+ : null,
11917
+ )),
11918
+ catHint ? h(CatHintPopover, { hint: catHint, category, counts: { mcp: mcpCount, skill: skillCount, expert: expertCount, tailor: tailorReport, wb: mkCount } }) : null,
11919
+ ),
11920
+ h(RepoStarChip, { call }),
10168
11921
  ),
10169
11922
  // OPT-07:每个分类各自包一层错误边界。key 用分类名,切走再切回会重建边界,
10170
11923
  // 顺带让「重试」之外还有一条自然恢复路径。
@@ -10575,8 +12328,6 @@ const MCP_CONTRIBUTION = {
10575
12328
  // tools 收 payload 而不是裸 name:项目 MCP 必须带 wsPath 才能精确定位(两个项目可能同名)。
10576
12329
  // 声明成标量 name 时,网关会把传入对象当成 args 映射展开并报 unexpected "wsPath"。
10577
12330
  { id: 'dsh-armory#mcpManager/tools', service: 'mcpManager', namespace: 'mcpManager', method: 'tools', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('ToolsResult') },
10578
- { id: 'dsh-armory#mcpManager/builtins', service: 'mcpManager', namespace: 'mcpManager', method: 'builtins', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('BuiltinCatalogResult') },
10579
- { id: 'dsh-armory#mcpManager/installBuiltins', service: 'mcpManager', namespace: 'mcpManager', method: 'installBuiltins', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'BuiltinInstallPayload')], result: mcpCodec('BuiltinInstallResult') },
10580
12331
  { id: 'dsh-armory#mcpManager/add', service: 'mcpManager', namespace: 'mcpManager', method: 'add', invocation: { kind: 'direct' }, parameters: [mcpParam('spec', 'Spec')], result: mcpCodec('NoteResult') },
10581
12332
  { id: 'dsh-armory#mcpManager/update', service: 'mcpManager', namespace: 'mcpManager', method: 'update', invocation: { kind: 'direct' }, parameters: [mcpParam('spec', 'Spec')], result: mcpCodec('NoteResult') },
10582
12333
  { id: 'dsh-armory#mcpManager/removeServer', service: 'mcpManager', namespace: 'mcpManager', method: 'removeServer', invocation: { kind: 'direct' }, parameters: [mcpParam('name', 'String')], result: mcpCodec('NoteResult') },
@@ -10592,7 +12343,6 @@ const MCP_CONTRIBUTION = {
10592
12343
  { id: 'dsh-armory#mcpManager/updateWorkspaceServer', service: 'mcpManager', namespace: 'mcpManager', method: 'updateWorkspaceServer', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
10593
12344
  { id: 'dsh-armory#mcpManager/removeWorkspaceServer', service: 'mcpManager', namespace: 'mcpManager', method: 'removeWorkspaceServer', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
10594
12345
  { id: 'dsh-armory#mcpManager/setWorkspaceExclude', service: 'mcpManager', namespace: 'mcpManager', method: 'setWorkspaceExclude', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
10595
- { id: 'dsh-armory#mcpManager/installWorkspaceBuiltins', service: 'mcpManager', namespace: 'mcpManager', method: 'installWorkspaceBuiltins', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'BuiltinInstallPayload')], result: mcpCodec('BuiltinInstallResult') },
10596
12346
  { id: 'dsh-armory#mcpManager/previewWorkspaceImport', service: 'mcpManager', namespace: 'mcpManager', method: 'previewWorkspaceImport', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('ImportPreview') },
10597
12347
  { id: 'dsh-armory#mcpManager/importWorkspaceJson', service: 'mcpManager', namespace: 'mcpManager', method: 'importWorkspaceJson', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('ImportResult') },
10598
12348
  { id: 'dsh-armory#mcpManager/revealWorkspaceServer', service: 'mcpManager', namespace: 'mcpManager', method: 'revealWorkspaceServer', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('RevealResult') },
@@ -10621,6 +12371,8 @@ const MCP_CONTRIBUTION = {
10621
12371
  // 顶栏「市场」chip 常驻计数:只读磁盘缓存(零网络),无缓存返回 { total: null }。
10622
12372
  { id: 'dsh-armory#mcpManager/wbConnectorCachedCount', service: 'mcpManager', namespace: 'mcpManager', method: 'wbConnectorCachedCount', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbCachedCountResult') },
10623
12373
  { id: 'dsh-armory#mcpManager/wbConnectorPreview', service: 'mcpManager', namespace: 'mcpManager', method: 'wbConnectorPreview', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbPreviewResult') },
12374
+ // 市场连接器卡片头像:payload {connectorId},出 {ok, dataUrl}。与宿主 method、typert.js 三处同步。
12375
+ { id: 'dsh-armory#mcpManager/wbConnectorIcon', service: 'mcpManager', namespace: 'mcpManager', method: 'wbConnectorIcon', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbIconResult') },
10624
12376
  { id: 'dsh-armory#mcpManager/wbConnectorInstall', service: 'mcpManager', namespace: 'mcpManager', method: 'wbConnectorInstall', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
10625
12377
  { id: 'dsh-armory#mcpManager/wbOAuthBegin', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthBegin', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
10626
12378
  { id: 'dsh-armory#mcpManager/wbOAuthPoll', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthPoll', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
@@ -10636,20 +12388,22 @@ const MCP_CONTRIBUTION = {
10636
12388
  // T03/R4:多市场注册表(读 / 增改)。与宿主 method、typert.js 三处必须同步。
10637
12389
  { id: 'dsh-armory#mcpManager/wbMarketRegistry', service: 'mcpManager', namespace: 'mcpManager', method: 'wbMarketRegistry', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbMarketRegistryResult') },
10638
12390
  { id: 'dsh-armory#mcpManager/wbMarketRegistryUpsert', service: 'mcpManager', namespace: 'mcpManager', method: 'wbMarketRegistryUpsert', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbMarketRegistryResult') },
10639
- // T03/R5:codebuddy 插件市场(列表 / 详情)。与宿主 method、typert.js 三处必须同步。
10640
- { id: 'dsh-armory#mcpManager/wbPluginCatalog', service: 'mcpManager', namespace: 'mcpManager', method: 'wbPluginCatalog', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbPluginCatalogResult') },
10641
12391
  // T04/R8:安装状态账本(读全量 / 登记 / 忘记)。与宿主 method、typert.js 三处必须同步。
10642
12392
  { id: 'dsh-armory#mcpManager/wbInstallState', service: 'mcpManager', namespace: 'mcpManager', method: 'wbInstallState', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('InstallStateResult') },
10643
12393
  { id: 'dsh-armory#mcpManager/wbInstallStateRecord', service: 'mcpManager', namespace: 'mcpManager', method: 'wbInstallStateRecord', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
10644
12394
  { id: 'dsh-armory#mcpManager/wbInstallStateForget', service: 'mcpManager', namespace: 'mcpManager', method: 'wbInstallStateForget', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
10645
- // T05/R9:插件市场更新探测(只读 ETag,绝不落新包)。与宿主 method、typert.js 三处必须同步。
10646
- { id: 'dsh-armory#mcpManager/wbPluginUpdateCheck', service: 'mcpManager', namespace: 'mcpManager', method: 'wbPluginUpdateCheck', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbUpdateHintResult') },
10647
12395
  // T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins)。与宿主 method、typert.js 三处必须同步。
10648
12396
  { id: 'dsh-armory#mcpManager/wbAwesomeCatalog', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeCatalog', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeCatalogResult') },
10649
12397
  { id: 'dsh-armory#mcpManager/wbAwesomeDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeDetailResult') },
10650
12398
  { id: 'dsh-armory#mcpManager/wbAwesomeReadme', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeReadme', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeReadmeResult') },
10651
12399
  { id: 'dsh-armory#mcpManager/wbAwesomeUpdateCheck', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeUpdateCheck', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbUpdateHintResult') },
10652
- { id: 'dsh-armory#mcpManager/wbPluginDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbPluginDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbPluginDetailResult') },
12400
+ // 社区元信息(仓库维护信号):payload {repos, refresh},返回 {repos:{repo:{...}}}。
12401
+ { id: 'dsh-armory#mcpManager/wbRepoMeta', service: 'mcpManager', namespace: 'mcpManager', method: 'wbRepoMeta', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbRepoMetaResult') },
12402
+ // npm 近 30 天下载量:payload {packages, refresh},返回 {packages:{pkg:{ok,downloads,...}}}。
12403
+ // 缺这一行 = 面板 call('wbNpmDownloads') 恒 404,卡片上那枚「↓」永远不出现。
12404
+ { id: 'dsh-armory#mcpManager/wbNpmDownloads', service: 'mcpManager', namespace: 'mcpManager', method: 'wbNpmDownloads', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbNpmDownloadsResult') },
12405
+ { id: 'dsh-armory#mcpManager/wbLoadoutExport', service: 'mcpManager', namespace: 'mcpManager', method: 'wbLoadoutExport', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('LoadoutResult') },
12406
+ { id: 'dsh-armory#mcpManager/wbLoadoutDiff', service: 'mcpManager', namespace: 'mcpManager', method: 'wbLoadoutDiff', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('LoadoutDiffResult') },
10653
12407
  // ── WorkBuddy 专家直连:召唤预设 + 捆绑技能(同一命名空间同一信封)──
10654
12408
  { id: 'dsh-armory#mcpManager/wbExpertCatalog', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertCatalog', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbExpertCatalogResult') },
10655
12409
  { id: 'dsh-armory#mcpManager/wbExpertDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbExpertDetailResult') },