dsh-armory 2.3.2 → 2.4.1
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/README.en.md +3 -1
- package/README.md +3 -1
- package/lib/client.js +350 -24
- package/lib/index.js +8 -0
- package/lib/skill-dir-times.js +111 -0
- package/lib/typert.js +5 -0
- package/lib/wb-market.js +96 -32
- package/lib/whats-new-en.js +32 -0
- package/lib/whats-new.js +38 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
> **The all-in-one control board for DeepSeek Harness**: MCP servers · skills · experts · plugins · tool-usage ledger · four markets — browse, search, install and summon in one place.
|
|
6
6
|
> Forked from [Imzl-zl/dsh-mcp-manager-ui](https://github.com/Imzl-zl/dsh-mcp-manager-ui) (MIT) and extended into a full armory.
|
|
7
7
|
>
|
|
8
|
-
> **New in v2.
|
|
8
|
+
> **New in v2.4.1**: SkillHub skill details now show the real name and full summary, "Source homepage" opens the public skill page instead of the 405 API endpoint, and the floating button can be hidden (it also steps aside while the board is open) — issue #4. See the [changelog](.github/release-notes/v2.4.1.md).
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
11
|
<a href="https://github.com/2026heshao/dsh-armory/stargazers"><img src="https://img.shields.io/github/stars/2026heshao/dsh-armory?style=social" alt="stars"></a>
|
|
@@ -34,6 +34,7 @@ Restart DSH, then open **Skills · MCP · Experts** from the global panel list a
|
|
|
34
34
|
|---|---|
|
|
35
35
|
| MCP config means hand-editing YAML with no feedback on whether it connects | Visual panel: status, tool schemas, enable/disable, reconnect — global & per-project scopes, project changes apply to live sessions instantly |
|
|
36
36
|
| Installing skills means hunting for `SKILL.md` repos everywhere | **Direct markets**: SkillHub (190k+ skills), WorkBuddy connectors (316) — search, filter, one-click install with per-file sha256 verification |
|
|
37
|
+
| You install a skill from the market, then cannot find it on the board or tell which one is new | **Recognisable installs**: cards show "installed …" with a 7-day highlight and relative time; the filter bar lays skills out newest-first as a "recently installed" timeline; when the board name differs from the market name, the original is shown |
|
|
37
38
|
| Every MCP's tool descriptions eat context tokens; zero-use tools sit there for free | **Tool Tailor**: scans local session logs for per-tool call counts / errors / latency, plus a skill-level usage ledger; prune dead tools with one click |
|
|
38
39
|
| Migrating from Claude/Cursor/VS Code means copying config by hand | Detects local configs from 11 clients and imports them with a preview |
|
|
39
40
|
| OAuth-protected or key-protected servers and plugins just fail, with no hint why | **Plugin market** (the DSH community list) with per-entry installability, **one-click MCP OAuth** (RFC 9728 → 8414 → 7591 → 7636 PKCE → 8707, three callback fallbacks ending at "paste a token"), and a **credential prompt before install** that fills `${...}` placeholders so the server works on first connect |
|
|
@@ -134,6 +135,7 @@ Experts (WorkBuddy expert center, 429 experts / 15 industries; category filter +
|
|
|
134
135
|
- **Skill board categories (new in 2.0.0)**: skills are grouped into **dev / design & creation / docs & office / productivity & automation / ops / uncategorised**, with grid and list views, a category filter and a persistent "showing N/M skills" line. Details add the category, source directory and file location, with one-click "copy SKILL.md path" and "copy @session reference"
|
|
135
136
|
- **Skill uninstall (new in 2.2.0)**: skills in user-level / project-level directories can be deleted from the detail pane (inline second confirmation, **permanent, no undo**), while skills shipped with DSH, registered at runtime, or living in custom directories stay read-only and say so. The host only exposes read endpoints, so removal is handled by this plugin's `removeSkill`: source allowlist → strict root containment → parent-directory realpath → symlinks unlinked without touching their targets → the folder must contain `SKILL.md` and stay under the root
|
|
136
137
|
- **Live skill-set refresh (new in 2.2.0)**: installing or uninstalling from the market, and uninstalling from the board, all broadcast on a module-level change bus. The top **Skill** badge and the board list subscribe to the same bus and re-read the truth, with a sequence guard on the badge fetch so rapid toggles cannot roll the number back. The new set is reflected immediately — no tab switch or panel reopen
|
|
138
|
+
- **Recognisable skill installs (new in 2.4.0)**: every Skill card / list row shows an "installed …" badge — green with relative time within 7 days ("installed 3 hours ago"), "N days ago" within 30 days, an absolute date beyond. The timestamp prefers the first-install time recorded in the install-state ledger, then the directory creation time, then its mtime; skills shipped with DSH or registered at runtime have no install event and show no badge. The filter bar adds a **recently installed** timeline (newest first), and when a board name (from `SKILL.md`'s `name:`) differs from the market name recorded in the ledger, the card carries a "from market: original name" badge and the detail pane adds an original-name row. Data comes from the new `skillInstallInfo` RPC (the host scans the standard skill roots and reads only the first 4KB of each `SKILL.md`); the host must be restarted once to register it, and the board says so explicitly instead of showing nothing
|
|
137
139
|
- **Custom suites (new in 2.2.0)**: bundle skills you use together. The **Suites** button in the Skill board toolbar opens a manager: pick skills on the left, arrange them on the right (click **+** or drag one in, drag to reorder, delete with an inline confirmation), and one save overwrites the whole set into `~/.dsh/armory/suites.json` (staging + atomic rename; invalid input is rejected without touching the file). The composer's Skills submenu shows a suite group at the top — one click expands the suite in member order into multiple `/name` tokens in the draft. Limits: 100 suites, 40 members each; members count individually toward "recently used" rather than the suite name
|
|
138
140
|
- **Marketplace registry (new in 2.0.0)**: market sources are no longer hard-coded — they live in `~/.dsh/armory/marketplaces.json` (`REGISTRY_VERSION = 1`), fall back to the built-in defaults when missing or corrupt, persist on first read, and can be added / edited / removed. Writes are staging + atomic rename
|
|
139
141
|
- **Instant community list (new in 2.0.0)**: the last successful slim list is mirrored into `localStorage` (7 days / 4MB cap, older snapshot kept on quota errors), so the first paint renders the snapshot before the real result silently replaces it (zero network, zero IPC). When no source is reachable it says "showing the local cache" instead of an empty list
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
> **DeepSeek Harness 的一体化管理看板**:MCP · 技能 · 专家 · 插件 · 工具账本 · 四大市场,浏览、搜索、一键安装、一键召唤。
|
|
6
6
|
> fork 自 [Imzl-zl/dsh-mcp-manager-ui](https://github.com/Imzl-zl/dsh-mcp-manager-ui)(MIT),现已扩展为完整军械库。
|
|
7
7
|
>
|
|
8
|
-
> **最新 v2.
|
|
8
|
+
> **最新 v2.4.1**:修复 SkillHub 技能详情空白、标题退化成 slug、来源主页指向 405 接口,以及悬浮按钮无法隐藏并遮挡看板(issue #4)。详见 [更新日志](.github/release-notes/v2.4.1.md)。
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
11
|
<a href="https://github.com/2026heshao/dsh-armory/stargazers"><img src="https://img.shields.io/github/stars/2026heshao/dsh-armory?style=social" alt="stars"></a>
|
|
@@ -34,6 +34,7 @@ dsh plugin --profile web add github:2026heshao/dsh-armory
|
|
|
34
34
|
|---|---|
|
|
35
35
|
| MCP 配置要手改 YAML,改完不知道连没连上 | 可视化面板:状态、工具 Schema、启停、重连,全局与项目双作用域,项目级改完即对存活会话生效 |
|
|
36
36
|
| 装技能要去各处翻 `SKILL.md` 仓库 | **市场直连**:SkillHub 19 万+ 技能、WorkBuddy 316 个连接器,搜索/筛选/一键安装,逐文件 sha256 校验 |
|
|
37
|
+
| 在市场装了技能,回看板却找不到,也分不清哪个是刚装的 | **技能安装可辨识**:卡片标「装于 …」,7 天内高亮并给相对时间;筛选条按安装时间倒序成「最近安装」;看板名与市场原名不一致时标出「来自市场 原名」 |
|
|
37
38
|
| 每个 MCP 的工具描述都在吃上下文 token,零调用的工具白白占位 | **工具裁缝**:扫描本机会话日志统计每个工具的调用/错误/延迟,并给出技能维度的使用账本;死工具清单一键禁用 |
|
|
38
39
|
| 从 Claude/Cursor/VS Code 迁移要逐条抄配置 | 自动检出 11 种客户端的本机配置,预览后一键导入 |
|
|
39
40
|
| 插件散在宿主自带与社区清单两处,格式与可装性各不相同 | **插件页**:把 DSH 社区清单一并呈现并标明可装性(宿主自带组件单独标注),安装统一走宿主 `pluginManager`,装完明确提示需重启 |
|
|
@@ -125,6 +126,7 @@ dsh plugin --profile web add github:2026heshao/dsh-armory
|
|
|
125
126
|
- **技能看板分类(2.0.0 新增)**:技能按 **编程开发 / 设计与创作 / 文档办公 / 效率与自动化 / 开发运维 / 未分类** 六类归置,网格与列表双视图可切、可按分类过滤,常驻「显示 N / M 个 skill」;详情新增所属分类、来源目录、文件位置,可一键「复制 SKILL.md 路径」「复制 @会话引用」
|
|
126
127
|
- **技能卸载(2.2.0 新增)**:用户级 / 项目级目录里的 skill 可在详情里直接卸载(就地二次确认、**永久删除且不可撤销**);随 DSH 分发、运行时注册与自定义目录的 skill 只读,并说明不在可卸载范围内。宿主本身只提供读取接口,卸载由本插件的 `removeSkill` 完成:来源白名单 → 根目录严格包含性 → 父目录 realpath → 符号链接只 unlink 不碰目标 → 目录必须含 `SKILL.md` 且在根内
|
|
127
128
|
- **技能集合实时刷新(2.2.0 新增)**:市场安装 / 卸载技能、看板自己卸载,三处都会向模块级变更总线广播;顶部 **Skill** 数量角标与看板列表订阅同一条总线,各自重读真值,角标重取带序号守卫,快速连点也不会被乱序回包回滚。装完 / 卸完立刻对新集合,不必切页或重开面板
|
|
129
|
+
- **技能安装可辨识(2.4.0 新增)**:Skill 看板每张卡片 / 列表行显示「装于 …」——7 天内绿色高亮并给相对时间(「3 小时前装」),30 天内显示「N 天前装」,更早给绝对日期。时间优先取安装状态账本登记的首次安装时间,其次目录创建时间,最后目录修改时间;随 DSH 分发 / 运行时注册的技能没有安装事件,不显示徽标。筛选条新增「最近安装」时间线,按安装时间倒序平铺;看板名(取自 `SKILL.md` 的 `name:`)与账本登记的市场名不一致时,卡片出现「来自市场 原名」徽标,详情面板新增「市场原名」行。数据由新增 RPC `skillInstallInfo` 提供(宿主自扫标准技能根目录、只读每个 `SKILL.md` 头部 4KB 解析 `name:`),宿主需重启一次才注册;未注册时看板显式提示,不静默
|
|
128
130
|
- **自定义套件(2.2.0 新增)**:把常一起用的 skill 打包成「套件」——Skill 看板工具栏的「套件」打开管理窗,左侧挑技能、右侧编排(点 **+** 或拖入添加,拖拽排序,删除就地二次确认),一次保存即整集合覆盖落盘到 `~/.dsh/armory/suites.json`(staging + rename 原子写,坏数据拒绝且原文件不动)。输入框召唤菜单的「技能」子菜单顶部会出现套件分组,点一次就按成员顺序把多个 `/name` 一并插进草稿。上限 100 个套件 / 每套 40 个成员;成员按各自的技能名进「最近使用」,不会把套件名记成一条孤立记录
|
|
129
131
|
- **多市场注册表(2.0.0 新增)**:市场源不再硬编码,落 `~/.dsh/armory/marketplaces.json`(`REGISTRY_VERSION = 1`),缺失或损坏回落内置默认项、首读即落盘,支持增删改;写盘一律 staging + rename 原子替换
|
|
130
132
|
- **社区目录秒开(2.0.0 新增)**:上次成功的精简清单镜像进浏览器 `localStorage`(7 天 / 4MB 上限,超配额保留旧快照),首屏先渲染快照再被真实结果静默替换(0 网络 0 IPC);源不可达时明说「显示的是本地缓存」而非给空列表
|
package/lib/client.js
CHANGED
|
@@ -221,6 +221,7 @@ const EN_US_MESSAGES = {
|
|
|
221
221
|
"仅在本页 {0} 条里筛出已安装的(SkillHub 是远程分页,无法跨页筛选)": "Filters installed ones within this page's {0} entries only (SkillHub pages remotely; cross-page filtering isn't possible)",
|
|
222
222
|
"仅展示": "Display only",
|
|
223
223
|
"仅悬浮按钮": "Floating button only",
|
|
224
|
+
"悬浮按钮设置": "Floating button settings",
|
|
224
225
|
"从左侧列表点选一台服务器,可以查看它的连接信息、参数与工具清单。": "Pick a server from the list on the left to view its connection info, parameters and tool list.",
|
|
225
226
|
"从左侧选择一个 skill 查看详情": "Pick a skill on the left to see details",
|
|
226
227
|
"从未": "Never",
|
|
@@ -246,6 +247,7 @@ const EN_US_MESSAGES = {
|
|
|
246
247
|
"全局配置": "Global config",
|
|
247
248
|
"全局配置(在「全局」标签页可编辑)": "Global config (editable on the 'Global' tab)",
|
|
248
249
|
"全局面板 + 悬浮按钮(推荐)": "Global panel + floating button (recommended)",
|
|
250
|
+
"全局面板 + 侧栏底部入口(无悬浮按钮)": "Global panel + sidebar bottom entry (no floating button)",
|
|
249
251
|
"全部": "All",
|
|
250
252
|
"全部传输": "All transports",
|
|
251
253
|
"全部状态": "All states",
|
|
@@ -375,6 +377,11 @@ const EN_US_MESSAGES = {
|
|
|
375
377
|
"该技能正在安装中,请等当前任务结束": "This skill is already installing; wait for the current task to finish.",
|
|
376
378
|
"安装到全局": "Install globally",
|
|
377
379
|
"安装失败": "Install failed",
|
|
380
|
+
"安装于 {0}": "Installed {0}",
|
|
381
|
+
"安装时间": "Install time",
|
|
382
|
+
"安装时间与市场原名不可用:{0}。宿主侧接口在重启 DeepSeek Harness 后才会注册,重启后即可显示。": "Install times and market names unavailable: {0}. The host-side API registers only after restarting DeepSeek Harness; they will appear after a restart.",
|
|
383
|
+
"按安装时间倒序平铺;随 DSH 分发、运行时注册的技能没有安装时间,不在此列": "Flat list sorted by install time, newest first; skills shipped with DSH or registered at runtime have no install time and are not listed",
|
|
384
|
+
"它在市场里叫「{0}」,安装后看板显示的是 SKILL.md 的 name 字段。": "It is called \"{0}\" in the market; the board shows the name field from SKILL.md after install.",
|
|
378
385
|
"安装任务已结束": "Install finished",
|
|
379
386
|
"大肥鱼军械库": "Big Fish Armory",
|
|
380
387
|
"宿主正在停止,未升级;请重启后再试。": "Host is shutting down; not upgraded. Restart and try again.",
|
|
@@ -445,6 +452,7 @@ const EN_US_MESSAGES = {
|
|
|
445
452
|
"已重连 ": "Reconnected ",
|
|
446
453
|
"已隐藏 {0} 个不兼容条目": "Hidden {0} incompatible entries",
|
|
447
454
|
"市场": "Market",
|
|
455
|
+
"市场原名": "Market name",
|
|
448
456
|
"市场数据加载失败;其余分类仍可正常使用。": "Failed to load market data; other categories keep working.",
|
|
449
457
|
"市场:连接器、SkillHub 技能与专家召唤。": "Market: connectors, SkillHub skills and expert summoning.",
|
|
450
458
|
"归属存疑": "Ownership uncertain",
|
|
@@ -486,7 +494,7 @@ const EN_US_MESSAGES = {
|
|
|
486
494
|
"技能 / MCP / 专家 快速调用": "Skills / MCPs / Experts quick invoke",
|
|
487
495
|
"技能 · MCP · 专家": "Skills · MCPs · Experts",
|
|
488
496
|
"技能 · MCP · 专家看板": "Skills · MCPs · Experts board",
|
|
489
|
-
"技能 · MCP ·
|
|
497
|
+
"技能 · MCP · 专家看板(拖拽移动,右键或长按隐藏)": "Skills · MCPs · Experts board (drag to move; right-click or long-press to hide)",
|
|
490
498
|
"技能·专家": "Skills·Experts",
|
|
491
499
|
"技能按需触发:选择后以 /技能名 插入输入框": "Skills trigger on demand: picking inserts /skill-name into the input box",
|
|
492
500
|
"技能来自 SkillHub 公开 API,可能还没同步下来。": "Skills come from the public SkillHub API and may not have synced yet.",
|
|
@@ -556,6 +564,7 @@ const EN_US_MESSAGES = {
|
|
|
556
564
|
"最新版本说明:": "Latest release notes: ",
|
|
557
565
|
"最近": "Recent",
|
|
558
566
|
"最近使用": "Recently used",
|
|
567
|
+
"最近安装": "Recently installed",
|
|
559
568
|
"服务器": "Servers",
|
|
560
569
|
"未知来源": "Unknown source",
|
|
561
570
|
"未知错误": "Unknown error",
|
|
@@ -569,6 +578,7 @@ const EN_US_MESSAGES = {
|
|
|
569
578
|
"本页没有已安装的技能": "No installed skills on this page",
|
|
570
579
|
"来自 mcp-client 最近日志": "From recent mcp-client logs",
|
|
571
580
|
"来自其他配置层,只读": "From another config layer, read-only",
|
|
581
|
+
"来自市场 {0}": "From market: {0}",
|
|
572
582
|
"构建授权已过期,未升级;请重试。": "Build authorization expired; not upgraded. Please retry.",
|
|
573
583
|
"查看": "View",
|
|
574
584
|
"查看原始 JSON": "View raw JSON",
|
|
@@ -657,6 +667,7 @@ const EN_US_MESSAGES = {
|
|
|
657
667
|
"自定义目录": "Custom directory",
|
|
658
668
|
"裁缝建议:": "Tailor suggestions: ",
|
|
659
669
|
"装 {0}": "Install {0}",
|
|
670
|
+
"装于 {0}": "Added {0}",
|
|
660
671
|
"解析中…": "Parsing…",
|
|
661
672
|
"解析并预览": "Parse and preview",
|
|
662
673
|
"认证": "Authentication",
|
|
@@ -675,6 +686,7 @@ const EN_US_MESSAGES = {
|
|
|
675
686
|
"读取 skill 列表失败": "Failed to read the skill list",
|
|
676
687
|
"读取专家目录失败": "Failed to read the expert catalog",
|
|
677
688
|
"读取中…": "Reading…",
|
|
689
|
+
"读取失败": "Read failed",
|
|
678
690
|
"读取已召唤专家失败": "Failed to read summoned experts",
|
|
679
691
|
"读取技能目录失败": "Failed to read the skill catalog",
|
|
680
692
|
"读取本机客户端配置失败": "Failed to read local client configs",
|
|
@@ -739,6 +751,9 @@ const EN_US_MESSAGES = {
|
|
|
739
751
|
"随 DSH 分发": "Ships with DSH",
|
|
740
752
|
"隐藏 ": "Hide ",
|
|
741
753
|
"隐藏原值:": "Hide value: ",
|
|
754
|
+
"隐藏悬浮按钮": "Hide floating button",
|
|
755
|
+
"这是当前唯一入口;先到面板「入口」菜单切换模式。": "This is the only entry right now; switch modes in the panel's \"Entries\" menu first.",
|
|
756
|
+
"悬浮按钮已隐藏,可从左上角「大肥鱼军械库」或面板「入口」菜单重新打开。": "Floating button hidden. Reopen it from \"Big Fish Armory\" (top-left) or the panel's \"Entries\" menu.",
|
|
742
757
|
"零调用": "Zero-call",
|
|
743
758
|
"零调用工具": "Zero-call tools",
|
|
744
759
|
"零调用的 MCP 工具 · 点击下钻": "Zero-call MCP tools · click to drill down",
|
|
@@ -2046,6 +2061,11 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2046
2061
|
.dsh-mcp-fab:hover{transform:scale(1.05);background:var(--dsw-alias-button-primary-hover)}
|
|
2047
2062
|
.dsh-mcp-fab:active{transform:scale(.97)}
|
|
2048
2063
|
.dsh-mcp-fab svg{width:22px;height:22px}
|
|
2064
|
+
.dsh-mcp-tab-pop.dsh-mcp-fab-menu{position:fixed;right:auto;min-width:196px;max-width:240px;z-index:191}
|
|
2065
|
+
.dsh-mcp-fab-menu-item:disabled{opacity:.55;cursor:not-allowed}
|
|
2066
|
+
.dsh-mcp-fab-menu-item:disabled:hover{background:transparent}
|
|
2067
|
+
.dsh-mcp-fab-menu-note{margin:4px 6px 2px;color:var(--mcp-mut);font-size:11px;line-height:1.5}
|
|
2068
|
+
.dsh-mcp-fab-hint{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:191;max-width:min(440px,82vw);padding:9px 14px;border-radius:999px;background:var(--mcp-bg-2);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-panel);font-size:12px;line-height:1.5;text-align:center;pointer-events:none}
|
|
2049
2069
|
.dsh-mcp-panel-overlay{position:fixed;inset:0;background:var(--dsw-alias-bg-overlay);display:flex;align-items:center;justify-content:center;z-index:180;padding:26px;pointer-events:auto;animation:mcpFade .14s ease-out}
|
|
2050
2070
|
.dsh-mcp-panel{box-sizing:border-box;width:1000px;max-width:95vw;height:88vh;background:var(--mcp-bg-2);border:0;border-radius:var(--mcp-r-panel);box-shadow:var(--dsw-elevation-prominent);display:flex;flex-direction:column;overflow:hidden;animation:mcpPop .18s cubic-bezier(.2,.9,.3,1.15)}
|
|
2051
2071
|
.dsh-mcp-panel .dsh-mcp-wrap{height:100%;min-height:0;padding:18px}
|
|
@@ -5218,6 +5238,8 @@ const McpTab = (props) => {
|
|
|
5218
5238
|
// left/top 重绘成本可忽略,故保留 left/top 定位以复用现有 :hover/:active 的 transform 缩放反馈。
|
|
5219
5239
|
const FAB_POS_KEY = 'dsh-armory/fab-pos';
|
|
5220
5240
|
const DRAG_THRESHOLD = 5;
|
|
5241
|
+
// 触屏 / 手写笔长按多久叫出隐藏菜单:与常见长按菜单同一量级,短于拖动判定的误触窗口。
|
|
5242
|
+
const FAB_MENU_PRESS_MS = 520;
|
|
5221
5243
|
|
|
5222
5244
|
function fabViewport() {
|
|
5223
5245
|
const w = typeof window !== 'undefined' ? window.innerWidth : 0;
|
|
@@ -5268,6 +5290,9 @@ const ENTRY_KEY = 'dsh-armory/entry';
|
|
|
5268
5290
|
const ENTRY_VISIBILITY = {
|
|
5269
5291
|
both: { panel: true, fab: true, sidebar: false },
|
|
5270
5292
|
panel: { panel: true, fab: false, sidebar: false },
|
|
5293
|
+
// 「隐藏悬浮按钮」要保留另外两个入口:两种「面板 + 侧栏」组合各有一档,
|
|
5294
|
+
// 否则从 all 隐藏后会连带丢掉侧栏底部入口(issue #4)。
|
|
5295
|
+
panelSidebar: { panel: true, fab: false, sidebar: true },
|
|
5271
5296
|
fab: { panel: false, fab: true, sidebar: false },
|
|
5272
5297
|
sidebar: { panel: false, fab: false, sidebar: true },
|
|
5273
5298
|
all: { panel: true, fab: true, sidebar: true },
|
|
@@ -5277,6 +5302,7 @@ const ENTRY_VISIBILITY = {
|
|
|
5277
5302
|
const ENTRY_LABELS = {
|
|
5278
5303
|
get both() { return t('全局面板 + 悬浮按钮(推荐)'); },
|
|
5279
5304
|
get panel() { return t('仅全局面板(左上角,"插件"旁)'); },
|
|
5305
|
+
get panelSidebar() { return t('全局面板 + 侧栏底部入口(无悬浮按钮)'); },
|
|
5280
5306
|
get fab() { return t('仅悬浮按钮'); },
|
|
5281
5307
|
get sidebar() { return t('仅侧栏底部入口'); },
|
|
5282
5308
|
get all() { return t('三个入口全开'); },
|
|
@@ -5289,6 +5315,18 @@ const ENTRY_MODES = Object.keys(ENTRY_VISIBILITY);
|
|
|
5289
5315
|
function normalizeEntryMode(value) {
|
|
5290
5316
|
return Object.prototype.hasOwnProperty.call(ENTRY_VISIBILITY, value) ? value : ENTRY_DEFAULT;
|
|
5291
5317
|
}
|
|
5318
|
+
// 「隐藏悬浮按钮」= 换成同一组其它席位不变的档位,而不是直接写 fab:false。
|
|
5319
|
+
// 表里没有对应档位(仅悬浮按钮是唯一入口)时原样返回,调用方据此禁用隐藏项,
|
|
5320
|
+
// 「三个入口全关」因此仍然无法表达。
|
|
5321
|
+
function entryModeWithoutFab(mode) {
|
|
5322
|
+
const current = normalizeEntryMode(mode);
|
|
5323
|
+
if (!ENTRY_VISIBILITY[current].fab) return current;
|
|
5324
|
+
const target = ENTRY_MODES.find((value) => {
|
|
5325
|
+
const visibility = ENTRY_VISIBILITY[value];
|
|
5326
|
+
return !visibility.fab && visibility.panel === ENTRY_VISIBILITY[current].panel && visibility.sidebar === ENTRY_VISIBILITY[current].sidebar;
|
|
5327
|
+
});
|
|
5328
|
+
return target || current;
|
|
5329
|
+
}
|
|
5292
5330
|
function readEntryMode() {
|
|
5293
5331
|
try {
|
|
5294
5332
|
if (typeof localStorage === 'undefined') return ENTRY_DEFAULT;
|
|
@@ -5348,6 +5386,12 @@ function overlaySeats(mode) {
|
|
|
5348
5386
|
// 两个 id 必须是同一个字符串:侧栏行点击后宿主会先校验 main slot 里存在同 key 的
|
|
5349
5387
|
// 条目(layout.selectPanel → hasMainPanel),不存在会直接抛错,侧栏就点不动了。
|
|
5350
5388
|
const PANEL_ID = 'mcp-manager';
|
|
5389
|
+
// 悬浮按钮这一帧该不该出现:入口偏好允许 + 当前不在看板页。
|
|
5390
|
+
// 看板本身占据中栏时按钮只会盖住列表/详情(issue #4),所以自动收起;
|
|
5391
|
+
// 回到会话后 panelInfo 变回别的 id,按钮自然复现。纯函数便于直接断言。
|
|
5392
|
+
function fabVisibleFor(mode, activePanelId) {
|
|
5393
|
+
return overlaySeats(mode).fab === true && activePanelId !== PANEL_ID;
|
|
5394
|
+
}
|
|
5351
5395
|
// 入口排序:插件管理器用 0,定时任务用 10;取 5 让本入口正好落在「插件」旁边。
|
|
5352
5396
|
const PANEL_ORDER = 5;
|
|
5353
5397
|
// 看板五分类(MCP / Skill / 专家 / 工具裁缝 / 市场)随名称一并示人。
|
|
@@ -5402,6 +5446,9 @@ const SKILL_CATS = [
|
|
|
5402
5446
|
weak: ['debug', 'windows', '安装', '升级', '更新'] },
|
|
5403
5447
|
];
|
|
5404
5448
|
const SKILL_UNCAT = { key: 'uncat', get name() { return t('未分类'); }, color: 'var(--mcp-cat-uncat)' };
|
|
5449
|
+
// 「最近安装」视图的簇头:不是真分类——不进 SKILL_CATS 轨道、不参与 skillcat hash 校验,
|
|
5450
|
+
// 只在 recentOnly 开启时当单簇标题(时间线)用。
|
|
5451
|
+
const SKILL_RECENT_CAT = { key: 'recent', get name() { return t('最近安装'); }, color: 'var(--mcp-cat-all)' };
|
|
5405
5452
|
// skillcat hash 值合法性校验用。
|
|
5406
5453
|
const SKILL_CAT_KEYS = new Set(SKILL_CATS.map((c) => c.key));
|
|
5407
5454
|
const skillCatByKey = (key) => SKILL_CATS.find((c) => c.key === key) || null;
|
|
@@ -6671,6 +6718,9 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
6671
6718
|
const [onlyModel, setOnlyModel] = useState(false);
|
|
6672
6719
|
const [onlyUser, setOnlyUser] = useState(false);
|
|
6673
6720
|
const [sourceFilter, setSourceFilter] = useState('all');
|
|
6721
|
+
// 「最近安装」:按安装时间倒序平铺。分类是关键词猜的不一定准,时间线正好补导航缺口——
|
|
6722
|
+
// 刚装的永远排最前,用户一眼认出哪个是自己刚装的。
|
|
6723
|
+
const [recentOnly, setRecentOnly] = useState(false);
|
|
6674
6724
|
const [selected, setSelected] = useState('');
|
|
6675
6725
|
const [detail, setDetail] = useState(null);
|
|
6676
6726
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
@@ -6774,6 +6824,36 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
6774
6824
|
return () => { alive = false; };
|
|
6775
6825
|
}, [call]);
|
|
6776
6826
|
|
|
6827
|
+
// 安装信息(skill 名字 → { installedAt, marketName?, marketplace? }):宿主自扫标准
|
|
6828
|
+
// 技能根目录、读 SKILL.md 头部 name 建映射后按名字返回——**不依赖 listSkills 行的
|
|
6829
|
+
// path**(注册表行可能整列缺 path,按 path 查会静默落空,实测踩过)。账本命中的
|
|
6830
|
+
// 附带市场原名:SKILL.md 的 name 可以和市场名不同,供卡片对应显示。
|
|
6831
|
+
// 失败不静默:新接口在旧宿主进程上不存在(404 / 未连接 ≈ 没重启),提示用户重启。
|
|
6832
|
+
const [installInfo, setInstallInfo] = useState(() => new Map());
|
|
6833
|
+
const [installInfoNote, setInstallInfoNote] = useState('');
|
|
6834
|
+
useEffect(() => {
|
|
6835
|
+
let alive = true;
|
|
6836
|
+
call('skillInstallInfo', skillScopeArg(scope)).then((res) => {
|
|
6837
|
+
if (!alive) return;
|
|
6838
|
+
if (res?.ok && res.info) {
|
|
6839
|
+
const map = new Map();
|
|
6840
|
+
for (const [name, row] of Object.entries(res.info)) {
|
|
6841
|
+
if (row && Number(row.installedAt) > 0) map.set(name, row);
|
|
6842
|
+
}
|
|
6843
|
+
setInstallInfo(map);
|
|
6844
|
+
setInstallInfoNote('');
|
|
6845
|
+
return;
|
|
6846
|
+
}
|
|
6847
|
+
setInstallInfo(new Map());
|
|
6848
|
+
setInstallInfoNote(t('安装时间与市场原名不可用:{0}。宿主侧接口在重启 DeepSeek Harness 后才会注册,重启后即可显示。', [String((res && res.error) || t('读取失败'))]));
|
|
6849
|
+
}).catch((e) => {
|
|
6850
|
+
if (!alive) return;
|
|
6851
|
+
setInstallInfo(new Map());
|
|
6852
|
+
setInstallInfoNote(t('安装时间与市场原名不可用:{0}。宿主侧接口在重启 DeepSeek Harness 后才会注册,重启后即可显示。', [String((e && e.message) || e)]));
|
|
6853
|
+
});
|
|
6854
|
+
return () => { alive = false; };
|
|
6855
|
+
}, [skills, scope, call]);
|
|
6856
|
+
|
|
6777
6857
|
const queryKey = (query || '').toLowerCase();
|
|
6778
6858
|
// 分类判定只依赖 name+description、与筛选无关 → 一次 memo 全量映射;
|
|
6779
6859
|
// 分类轨道计数固定全量(决策 9),不随来源/权限/搜索联动。
|
|
@@ -6803,15 +6883,45 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
6803
6883
|
return true;
|
|
6804
6884
|
});
|
|
6805
6885
|
const activeCat = cat ? skillCatByKey(cat) : null;
|
|
6886
|
+
// 卡片的安装时间徽标:30 天内相对时间(「3 天前装」,7 天内高亮),更早用绝对日期;
|
|
6887
|
+
// title 悬浮给完整日期。拿不到时间(bundled / runtime 等非磁盘来源)不渲染。
|
|
6888
|
+
const skillTimeOf = (s) => (installInfo.get(s.name)?.installedAt || 0);
|
|
6889
|
+
const skillTimeBadge = (s) => {
|
|
6890
|
+
const at = skillTimeOf(s);
|
|
6891
|
+
if (!at) return null;
|
|
6892
|
+
const days = Math.floor((Date.now() - at) / 86_400_000);
|
|
6893
|
+
const label = days <= 30 ? t('装于 {0}', [fmtLast(at)]) : t('装于 {0}', [wbFormatDate(at)]);
|
|
6894
|
+
return h(Badge, { cls: days <= 7 ? 's-on' : '', title: t('安装于 {0}', [wbFormatDate(at)]) }, label);
|
|
6895
|
+
};
|
|
6896
|
+
// 卡片上的市场原名徽标:只在「账本登记的市场名 ≠ SKILL.md 显示名」时出现——
|
|
6897
|
+
// 名字一致的没有歧义,不值得占一格。
|
|
6898
|
+
const skillMarketBadge = (s) => {
|
|
6899
|
+
const info = installInfo.get(s.name);
|
|
6900
|
+
if (!info?.marketName || info.marketName === s.name) return null;
|
|
6901
|
+
const label = info.marketplace === 'workbuddy-skills' ? t('SkillHub') : (info.marketplace || '');
|
|
6902
|
+
return h(Badge, {
|
|
6903
|
+
title: t('它在市场里叫「{0}」,安装后看板显示的是 SKILL.md 的 name 字段。', [info.marketName])
|
|
6904
|
+
+ (label ? ' ' + t('来源:{0}', [label]) : ''),
|
|
6905
|
+
}, t('来自市场 {0}', [info.marketName]));
|
|
6906
|
+
};
|
|
6907
|
+
// 「最近安装」视图行:只留拿得到安装时间的(bundled / runtime 没有落盘时间,不在此列),
|
|
6908
|
+
// 按时间倒序。其余筛选(搜索 / 来源 / 权限 / 分类)照常叠加。
|
|
6909
|
+
const visibleRows = useMemo(() => {
|
|
6910
|
+
if (!recentOnly) return filtered;
|
|
6911
|
+
return filtered.filter((s) => skillTimeOf(s) > 0).sort((a, b) => skillTimeOf(b) - skillTimeOf(a));
|
|
6912
|
+
// eslint 不识别 skillTimeOf 的闭包稳定性;installInfo 变化即重算。
|
|
6913
|
+
}, [filtered, installInfo, recentOnly]);
|
|
6806
6914
|
// 分类聚簇:网格与列表共用同一套簇序(决策 7:不按来源分组,改按分类聚簇,
|
|
6807
6915
|
// 簇序 = SKILL_CATS 声明序,未分类垫底,簇内保持注册表顺序)。网格渲染成卡片簇,
|
|
6808
6916
|
// 列表渲染成一个列表面板 + 分类分割线。选中具体分类时退化为单簇(无聚簇意义)。
|
|
6809
6917
|
const clustered = useMemo(() => {
|
|
6918
|
+
// 「最近安装」:单簇平铺,簇头当时间线标题用。
|
|
6919
|
+
if (recentOnly) return [{ key: 'recent', cat: SKILL_RECENT_CAT, rows: visibleRows }];
|
|
6810
6920
|
// 选中具体分类时退化为单簇(网格无聚簇意义,列表也无需一条分割线)。
|
|
6811
|
-
if (activeCat) return [{ key: activeCat.key, cat: activeCat, rows:
|
|
6921
|
+
if (activeCat) return [{ key: activeCat.key, cat: activeCat, rows: visibleRows }];
|
|
6812
6922
|
const order = [...SKILL_CATS.filter((c) => c.key !== 'all').map((c) => c.key), SKILL_UNCAT.key];
|
|
6813
6923
|
const buckets = new Map(order.map((k) => [k, []]));
|
|
6814
|
-
for (const s of
|
|
6924
|
+
for (const s of visibleRows) {
|
|
6815
6925
|
const key = (catOf.get(s.name) || SKILL_UNCAT).key;
|
|
6816
6926
|
if (!buckets.has(key)) buckets.set(key, []);
|
|
6817
6927
|
buckets.get(key).push(s);
|
|
@@ -6819,7 +6929,7 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
6819
6929
|
return order
|
|
6820
6930
|
.filter((k) => buckets.get(k).length)
|
|
6821
6931
|
.map((k) => ({ key: k, cat: k === SKILL_UNCAT.key ? SKILL_UNCAT : skillCatByKey(k), rows: buckets.get(k) }));
|
|
6822
|
-
}, [
|
|
6932
|
+
}, [visibleRows, catOf, view, activeCat, recentOnly]);
|
|
6823
6933
|
// 来源统计一次算完(不要在每个 chip 里各 filter 一遍)。
|
|
6824
6934
|
const sourceCounts = useMemo(() => {
|
|
6825
6935
|
const map = new Map();
|
|
@@ -6924,6 +7034,9 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
6924
7034
|
|
|
6925
7035
|
const summary = selected ? skills.find((s) => s.name === selected) : null;
|
|
6926
7036
|
const summaryCat = summary ? (catOf.get(summary.name) || SKILL_UNCAT) : null;
|
|
7037
|
+
// 详情的安装时间 + 市场原名:按 skill 名字从 skillInstallInfo 映射取。
|
|
7038
|
+
const summaryInstall = summary ? installInfo.get(summary.name) : null;
|
|
7039
|
+
const installTime = summaryInstall?.installedAt || 0;
|
|
6927
7040
|
|
|
6928
7041
|
// 网格视图不再占右侧详情栏:有选中就浮成居中弹窗;列表视图仍走右侧栏(决策见 §12)。
|
|
6929
7042
|
// 注意 view 的取值是 'list' | ''(网格是空串,见 setView(readHashParam('skillview') === 'list' ...)),
|
|
@@ -7076,9 +7189,11 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7076
7189
|
))),
|
|
7077
7190
|
// 结果行 + 权限/来源 chips + 只读提示:位置保持原样(决策 8)。
|
|
7078
7191
|
h('div', { className: 'dsh-mcp-skill-result dsh-mcp-item-sub' },
|
|
7079
|
-
h('span', null, (loading && !refreshing) ? t('读取中…') : t('显示 {0} / {1} 个 skill', [
|
|
7080
|
-
+ (
|
|
7192
|
+
h('span', null, (loading && !refreshing) ? t('读取中…') : t('显示 {0} / {1} 个 skill', [visibleRows.length, skills.length])
|
|
7193
|
+
+ (visibleRows.length !== skills.length ? t('(共 {0},已筛选)', [skills.length]) : '')),
|
|
7081
7194
|
refreshing ? h('span', null, t(' · 刷新中…')) : null,
|
|
7195
|
+
// 安装时间数据源不可用(旧宿主 / 服务未连接):不静默,一行说明 + 悬浮全文。
|
|
7196
|
+
installInfoNote ? h('span', { title: installInfoNote }, ' · ' + installInfoNote) : null,
|
|
7082
7197
|
// OPT-31 · 5.1:把「能做什么、不能做什么」从代码注释提到界面上。
|
|
7083
7198
|
// 宿主注册表本身只有 listSkills / skillDetail 两个读接口;卸载是本插件补的
|
|
7084
7199
|
// removeSkill(仅限用户级/项目级目录)。这层边界不写明,用户会把「随包分发 /
|
|
@@ -7100,6 +7215,11 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7100
7215
|
title: t('只显示用户可以用 /名字 触发的 skill'),
|
|
7101
7216
|
onClick: () => setOnlyUser((v) => !v),
|
|
7102
7217
|
}, t('用户可调用')),
|
|
7218
|
+
h('button', {
|
|
7219
|
+
type: 'button', className: 'dsh-mcp-chip-s' + (recentOnly ? ' on' : ''), 'aria-pressed': recentOnly,
|
|
7220
|
+
title: t('按安装时间倒序平铺;随 DSH 分发、运行时注册的技能没有安装时间,不在此列'),
|
|
7221
|
+
onClick: () => setRecentOnly((v) => !v),
|
|
7222
|
+
}, t('最近安装')),
|
|
7103
7223
|
[...sourceCounts.entries()].map(([source, count]) => h('button', {
|
|
7104
7224
|
key: source,
|
|
7105
7225
|
type: 'button',
|
|
@@ -7117,7 +7237,7 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7117
7237
|
),
|
|
7118
7238
|
// 主区画布:网格 = 分类聚簇双壳卡片;列表 = 一个列表面板 + 分类分割线。
|
|
7119
7239
|
h('div', { className: 'dsh-mcp-skill-canvas' },
|
|
7120
|
-
|
|
7240
|
+
visibleRows.length === 0 && !loading
|
|
7121
7241
|
? emptyBoard
|
|
7122
7242
|
: (view === 'list'
|
|
7123
7243
|
? h('div', { className: 'dsh-mcp-skill-list', role: 'listbox', 'aria-label': t('skill 列表') },
|
|
@@ -7150,6 +7270,7 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7150
7270
|
// §3.1 来源固定在行内:不再有来源分组头,滚动到中途也要知道这条来自哪里。
|
|
7151
7271
|
s.source ? h('span', { className: 'dsh-mcp-skill-src-badge', title: skillSourceLabel(s.source) }, skillSourceLabel(s.source)) : null,
|
|
7152
7272
|
s.provider ? h('span', { className: 'dsh-mcp-skill-src' }, s.provider) : null,
|
|
7273
|
+
skillTimeBadge(s),
|
|
7153
7274
|
),
|
|
7154
7275
|
s.description ? h('div', { className: 'dsh-mcp-skill-desc' }, s.description) : null,
|
|
7155
7276
|
),
|
|
@@ -7187,7 +7308,9 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7187
7308
|
: h('div', { className: 'dsh-mcp-skill-desc dsh-mcp-skill-desc-none' }, t('(无描述)')),
|
|
7188
7309
|
h('div', { className: 'dsh-mcp-skill-card-foot' },
|
|
7189
7310
|
h('span', { className: 'dsh-mcp-skill-cat-tag' }, sCat.name),
|
|
7190
|
-
|
|
7311
|
+
skillTimeBadge(s),
|
|
7312
|
+
skillMarketBadge(s),
|
|
7313
|
+
h('div', { className: 'dsh-mcp-skill-sub' },
|
|
7191
7314
|
s.modelInvocable ? h(Badge, { cls: 't-http' }, t('模型可调用')) : null,
|
|
7192
7315
|
s.userInvocable ? h(Badge, { cls: 't-stdio' }, t('用户可调用')) : null,
|
|
7193
7316
|
),
|
|
@@ -7274,6 +7397,13 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7274
7397
|
h('span', null, t('来源目录')),
|
|
7275
7398
|
h('b', null, skillSourceLabel(summary.source) + (summary.provider ? ' · ' + summary.provider : '')),
|
|
7276
7399
|
summary.path ? [h('span', { key: 'pl' }, t('文件位置')), h('b', { key: 'pv' }, summary.path)] : null,
|
|
7400
|
+
installTime ? [h('span', { key: 'itl' }, t('安装时间')), h('b', { key: 'itv' }, wbFormatDate(installTime))] : null,
|
|
7401
|
+
summaryInstall?.marketName
|
|
7402
|
+
? [h('span', { key: 'ml' }, t('市场原名')),
|
|
7403
|
+
h('b', { key: 'mnv' }, summaryInstall.marketName
|
|
7404
|
+
+ (summaryInstall.marketplace
|
|
7405
|
+
? ' · ' + (summaryInstall.marketplace === 'workbuddy-skills' ? t('SkillHub') : summaryInstall.marketplace)
|
|
7406
|
+
: ''))] : null,
|
|
7277
7407
|
),
|
|
7278
7408
|
),
|
|
7279
7409
|
h('div', { className: 'dsh-mcp-skill-d-actions' },
|
|
@@ -9336,6 +9466,26 @@ const CommunityPresence = ({ call, repo, owner, avatar, updatedAt, compact }) =>
|
|
|
9336
9466
|
),
|
|
9337
9467
|
);
|
|
9338
9468
|
};
|
|
9469
|
+
// 详情接口的 homepage 历史上可能是 api.skillhub.cn(JSON 接口,浏览器打开 405)。
|
|
9470
|
+
// 数据层已统一成公开页(lib/wb-market.js 的 skillhubHomepage);渲染层再兜一层,
|
|
9471
|
+
// 旧缓存或未来上游变化都不会把接口地址交给浏览器。
|
|
9472
|
+
const browsableHomepageUrl = (value) => {
|
|
9473
|
+
const url = String(value || '').trim();
|
|
9474
|
+
if (!/^https?:\/\//i.test(url)) return '';
|
|
9475
|
+
if (/^https?:\/\/api\.skillhub\.cn(?=[/?#]|$)/i.test(url)) return '';
|
|
9476
|
+
return url;
|
|
9477
|
+
};
|
|
9478
|
+
// 详情回包只覆盖「有值」的字段:上游对缺失字段返回空串 / 空数组时,不能把列表项里
|
|
9479
|
+
// 已有的中文名、介绍、上架时间冲掉(issue #4)。0 与 false 是合法值,照常覆盖。
|
|
9480
|
+
const mergeSkillDetail = (listItem, detail) => {
|
|
9481
|
+
const merged = { ...(listItem || {}) };
|
|
9482
|
+
for (const [key, value] of Object.entries(detail || {})) {
|
|
9483
|
+
if (value === '' || value === null || value === undefined) continue;
|
|
9484
|
+
if (Array.isArray(value) && value.length === 0) continue;
|
|
9485
|
+
merged[key] = value;
|
|
9486
|
+
}
|
|
9487
|
+
return merged;
|
|
9488
|
+
};
|
|
9339
9489
|
const feedbackKindLabel = (kind) =>
|
|
9340
9490
|
kind === 'skill' ? t('技能') : kind === 'expert' ? t('专家') : kind === 'plugin' ? t('插件') : kind === 'connector' ? t('连接器') : t('条目');
|
|
9341
9491
|
const feedbackTemplate = (item, kind, repo, homepage) => {
|
|
@@ -9364,8 +9514,8 @@ const FeedbackLinks = ({ item, kind, repo, homepage, sourceLabel }) => {
|
|
|
9364
9514
|
const name = String(item?.name || item?.id || item?.plugin || item?.slug || '').trim() || t('未命名条目');
|
|
9365
9515
|
const repoKey = parseGithubRepoKey(repo || item?.repo || item?.repository);
|
|
9366
9516
|
const repoUrl = repoKey ? `https://github.com/${repoKey}` : '';
|
|
9367
|
-
const home =
|
|
9368
|
-
const homeUrl =
|
|
9517
|
+
const home = browsableHomepageUrl(homepage || item?.homepage || item?.upstreamUrl);
|
|
9518
|
+
const homeUrl = home && home !== repoUrl ? home : '';
|
|
9369
9519
|
const template = feedbackTemplate(item, kind, repoUrl || homeUrl, homeUrl);
|
|
9370
9520
|
const title = t('{0} · {1} 的反馈', [feedbackKindLabel(kind), name]);
|
|
9371
9521
|
const issueUrl = ARMORY_REPO_URL + '/issues/new?title=' + encodeURIComponent(title) + '&body=' + encodeURIComponent(template);
|
|
@@ -9930,7 +10080,9 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
9930
10080
|
const [skillLoading, setSkillLoading] = useState(true);
|
|
9931
10081
|
const [skillError, setSkillError] = useState('');
|
|
9932
10082
|
const [skillDetail, setSkillDetail] = useState(null);
|
|
9933
|
-
|
|
10083
|
+
// 已安装集合:键是 namespace/slug 双键 → 值是安装时间(毫秒,0 = 取不到)。Map 上的
|
|
10084
|
+
// has() 与 Set 同用法,所有既有判定点不变;get() 供「安装于 …」徽标取时间。
|
|
10085
|
+
const [installedSkills, setInstalledSkills] = useState(new Map());
|
|
9934
10086
|
// §6.3 已安装筛选:SkillHub 每页 20 条远程分页,而「已装」是本地集合,
|
|
9935
10087
|
// 所以这层过滤只能作用于**当前页**——必须在标签上写清,否则会被误读成全库筛选。
|
|
9936
10088
|
// T04/R7:令牌弹窗。连接器 mcp.json 里带 ${...} 占位符时要先让用户填值——
|
|
@@ -9962,7 +10114,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
9962
10114
|
|
|
9963
10115
|
const loadInstalled = useCallback(async () => {
|
|
9964
10116
|
const res = await call('wbSkillInstalled');
|
|
9965
|
-
if (res?.ok) setInstalledSkills(new
|
|
10117
|
+
if (res?.ok) setInstalledSkills(new Map((res.installed || []).map((row) => [skillInstalledKey(row), Math.max(0, Number(row.installedAt) || 0)])));
|
|
9966
10118
|
}, [call]);
|
|
9967
10119
|
|
|
9968
10120
|
// T04/R8:安装状态账本(三类安装的统一登记,宿主 ~/.dsh/armory/install-state.json)。
|
|
@@ -10336,7 +10488,8 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10336
10488
|
try {
|
|
10337
10489
|
const res = await call('wbSkillDetail', { slug: skill.slug, namespace: skill.namespace });
|
|
10338
10490
|
if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
|
|
10339
|
-
|
|
10491
|
+
// 回包里的空串不能覆盖列表项已有的中文名 / 介绍(issue #4)。
|
|
10492
|
+
setSkillDetail({ ...mergeSkillDetail(skill, res), loading: false });
|
|
10340
10493
|
} catch (e) {
|
|
10341
10494
|
setSkillDetail({ ...skill, loading: false, detailError: String(e?.message || e) });
|
|
10342
10495
|
}
|
|
@@ -10866,7 +11019,11 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10866
11019
|
tab === 'skill' && shownSkills.length
|
|
10867
11020
|
? h('div', { className: 'dsh-mcp-wb-grid' },
|
|
10868
11021
|
shownSkills.map((skill) => {
|
|
10869
|
-
const
|
|
11022
|
+
const installedKey = skillInstalledKey(skill);
|
|
11023
|
+
const installed = installedSkills.has(installedKey);
|
|
11024
|
+
// 安装时间:wbSkillInstalled 行自带(账本首次安装时间,无账本回落目录创建时间)。
|
|
11025
|
+
const installedAt = installedSkills.get(installedKey) || 0;
|
|
11026
|
+
const installedLabel = installedAt ? t('安装于 {0}', [wbFormatDate(installedAt)]) : '';
|
|
10870
11027
|
return h('div', { key: skill.namespace + '/' + skill.slug, className: 'dsh-mcp-card wb' },
|
|
10871
11028
|
h('div', { className: 'dsh-mcp-card-top' },
|
|
10872
11029
|
h('div', { className: 'dsh-mcp-card-person' },
|
|
@@ -10880,14 +11037,14 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10880
11037
|
h('div', { className: 'dsh-mcp-card-vendor' }, skill.canonical || skill.namespace),
|
|
10881
11038
|
),
|
|
10882
11039
|
),
|
|
10883
|
-
installed ? h(Badge, { cls: 's-on' }, t('已安装')) : null,
|
|
11040
|
+
installed ? h(Badge, { cls: 's-on', title: installedLabel || undefined }, t('已安装')) : null,
|
|
10884
11041
|
),
|
|
10885
11042
|
h('div', { className: 'dsh-mcp-card-summary' }, skill.description || skill.name),
|
|
10886
11043
|
h(PresenceLine, {
|
|
10887
11044
|
owner: skill.ownerName || skill.namespace,
|
|
10888
11045
|
avatar: skill.iconUrl || githubAvatarUrl(skill.homepage),
|
|
10889
11046
|
updatedAt: skill.updatedAt,
|
|
10890
|
-
href:
|
|
11047
|
+
href: browsableHomepageUrl(skill.homepage),
|
|
10891
11048
|
className: 'dsh-mcp-presence-card',
|
|
10892
11049
|
}),
|
|
10893
11050
|
h('div', { className: 'dsh-mcp-card-tags' },
|
|
@@ -10897,6 +11054,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10897
11054
|
skill.verified ? h(Badge, { cls: 's-on' }, t('认证')) : null,
|
|
10898
11055
|
skill.version ? h(Badge, null, 'v' + skill.version) : null,
|
|
10899
11056
|
wbFormatDate(skill.updatedAt) ? h(Badge, null, wbFormatDate(skill.updatedAt)) : null,
|
|
11057
|
+
installed && installedAt ? h(Badge, null, installedLabel) : null,
|
|
10900
11058
|
),
|
|
10901
11059
|
h('div', { className: 'dsh-mcp-wb-card-actions' },
|
|
10902
11060
|
h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openSkillDetail(skill) }, t('详情')),
|
|
@@ -11234,19 +11392,26 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11234
11392
|
}),
|
|
11235
11393
|
h(FeedbackLinks, {
|
|
11236
11394
|
item: skillDetail, kind: 'skill',
|
|
11237
|
-
|
|
11395
|
+
// upstream_url(GitHub)优先,其次才是技能公开页:公开页不是仓库时
|
|
11396
|
+
// 不该占掉「去仓库」的位置。
|
|
11397
|
+
repo: parseGithubRepoKey(skillDetail.upstreamUrl)
|
|
11398
|
+
? skillDetail.upstreamUrl
|
|
11399
|
+
: (parseGithubRepoKey(skillDetail.homepage) ? skillDetail.homepage : ''),
|
|
11238
11400
|
homepage: skillDetail.homepage || skillDetail.upstreamUrl,
|
|
11239
11401
|
sourceLabel: skillDetail.ownerName || skillDetail.publisher || t('SkillHub'),
|
|
11240
11402
|
}),
|
|
11403
|
+
// 介绍先从列表项渲染(不必等详情回来),详情到达后原地替换为完整正文。
|
|
11404
|
+
skillDetail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, skillDetail.description) : null,
|
|
11241
11405
|
skillDetail.loading
|
|
11242
11406
|
? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在读取详情…'))
|
|
11243
11407
|
: h('div', null,
|
|
11244
11408
|
skillDetail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, skillDetail.detailError) : null,
|
|
11245
|
-
skillDetail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, skillDetail.description) : null,
|
|
11246
11409
|
h('div', { className: 'dsh-mcp-card-tags' },
|
|
11247
11410
|
skillDetail.version ? h(Badge, null, 'v' + skillDetail.version) : null,
|
|
11248
11411
|
skillDetail.publisher ? h(Badge, null, skillDetail.publisher + (skillDetail.verifiedPublisher ? t('(已认证)') : '')) : null,
|
|
11249
11412
|
skillDetail.certifiedName ? h(Badge, { cls: 's-on' }, skillDetail.certifiedName) : null,
|
|
11413
|
+
(installedSkills.get(skillInstalledKey(skillDetail)) || 0)
|
|
11414
|
+
? h(Badge, { cls: 's-on' }, t('安装于 {0}', [wbFormatDate(installedSkills.get(skillInstalledKey(skillDetail)))])) : null,
|
|
11250
11415
|
skillDetail.createdAt ? h(Badge, null, t('加入时间 {0}', [wbFormatDate(skillDetail.createdAt)])) : null,
|
|
11251
11416
|
skillDetail.updatedAt ? h(Badge, null, t('更新于 {0}', [relativeLabel(skillDetail.updatedAt)])) : null,
|
|
11252
11417
|
Array.isArray(skillDetail.security) ? skillDetail.security.map((status, index) => h(Badge, { key: index, cls: status === 'benign' ? 's-on' : '' }, t('安全: ') + status)) : null,
|
|
@@ -12177,24 +12342,120 @@ const WhatsNewModal = ({ call }) => {
|
|
|
12177
12342
|
);
|
|
12178
12343
|
};
|
|
12179
12344
|
|
|
12345
|
+
// 当前主面板 id:与 openBoardPanel 读同一份 layout.panelInfo 快照。
|
|
12346
|
+
// 取不到(旧宿主 / layout 还没装好)就返回 undefined —— 悬浮按钮保持原样,不误隐藏。
|
|
12347
|
+
function readActivePanelId() {
|
|
12348
|
+
try {
|
|
12349
|
+
const ctx = MODULE_CTX.ctx;
|
|
12350
|
+
const layout = ctx && typeof ctx.get === 'function' ? ctx.get('layout') : null;
|
|
12351
|
+
const info = layout && layout.panelInfo;
|
|
12352
|
+
const snapshot = info && typeof info.getSnapshot === 'function'
|
|
12353
|
+
? info.getSnapshot()
|
|
12354
|
+
: (info && typeof info.getState === 'function' ? info.getState() : null);
|
|
12355
|
+
return snapshot && typeof snapshot.activePanelId === 'string' ? snapshot.activePanelId : undefined;
|
|
12356
|
+
} catch {
|
|
12357
|
+
return undefined;
|
|
12358
|
+
}
|
|
12359
|
+
}
|
|
12360
|
+
// 订阅宿主的面板切换。panelInfo 有 subscribe 就真订阅(zustand 风格 store),
|
|
12361
|
+
// 没有就 1 秒轮询兜底——面板开合是低频事件,这个代价可以忽略。
|
|
12362
|
+
const useActivePanelId = () => {
|
|
12363
|
+
const [active, setActive] = useState(readActivePanelId);
|
|
12364
|
+
useEffect(() => {
|
|
12365
|
+
let disposed = false;
|
|
12366
|
+
let off = null;
|
|
12367
|
+
let timer = 0;
|
|
12368
|
+
const bind = () => {
|
|
12369
|
+
if (disposed) return;
|
|
12370
|
+
const ctx = MODULE_CTX.ctx;
|
|
12371
|
+
const layout = ctx && typeof ctx.get === 'function' ? ctx.get('layout') : null;
|
|
12372
|
+
const info = layout && layout.panelInfo;
|
|
12373
|
+
if (!info) {
|
|
12374
|
+
timer = setTimeout(bind, 1000);
|
|
12375
|
+
return;
|
|
12376
|
+
}
|
|
12377
|
+
const read = () => { if (!disposed) setActive(readActivePanelId()); };
|
|
12378
|
+
read();
|
|
12379
|
+
if (typeof info.subscribe === 'function') {
|
|
12380
|
+
const stop = info.subscribe(read);
|
|
12381
|
+
if (typeof stop === 'function') off = stop;
|
|
12382
|
+
return;
|
|
12383
|
+
}
|
|
12384
|
+
timer = setInterval(read, 1000);
|
|
12385
|
+
};
|
|
12386
|
+
bind();
|
|
12387
|
+
return () => {
|
|
12388
|
+
disposed = true;
|
|
12389
|
+
clearTimeout(timer);
|
|
12390
|
+
clearInterval(timer);
|
|
12391
|
+
if (typeof off === 'function') off();
|
|
12392
|
+
};
|
|
12393
|
+
}, []);
|
|
12394
|
+
return active;
|
|
12395
|
+
};
|
|
12396
|
+
|
|
12180
12397
|
// ── 悬浮按钮宿主(shell.overlay):只剩这一个席位 ────────────────────────────────
|
|
12181
12398
|
const McpOverlay = () => {
|
|
12182
12399
|
useI18nRevision(); // 宿主切语言后重渲染,模块级 getter 文案即时更新
|
|
12183
12400
|
const mode = useEntryMode();
|
|
12184
|
-
const
|
|
12401
|
+
const activePanelId = useActivePanelId();
|
|
12402
|
+
// 看板页打开时自动收起:悬浮按钮固定在中栏之上,会直接盖住列表与详情文字(issue #4)。
|
|
12403
|
+
const fabVisible = fabVisibleFor(mode, activePanelId);
|
|
12404
|
+
const canHideFab = entryModeWithoutFab(mode) !== mode;
|
|
12185
12405
|
const [pos, setPos] = useState(() => readFabPos() || defaultFabPos());
|
|
12406
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
12407
|
+
const [hideHint, setHideHint] = useState(false);
|
|
12186
12408
|
const fabRef = useRef(null);
|
|
12409
|
+
const menuRef = useRef(null);
|
|
12187
12410
|
const dragRef = useRef(null);
|
|
12188
12411
|
const suppressClickRef = useRef(false);
|
|
12412
|
+
const pressTimerRef = useRef(0);
|
|
12189
12413
|
|
|
12190
12414
|
// 窗口尺寸变化时把按钮拉回可视区,避免记忆的位置因分辨率变化而出界。
|
|
12191
|
-
//
|
|
12415
|
+
// 悬浮按钮不可见时不订阅:给一个看不见的按钮算位置只是白渲染。
|
|
12192
12416
|
useEffect(() => {
|
|
12193
|
-
if (!
|
|
12417
|
+
if (!fabVisible) return;
|
|
12194
12418
|
const onResize = () => setPos((current) => clampFabPos(current.x, current.y));
|
|
12195
12419
|
window.addEventListener('resize', onResize);
|
|
12196
12420
|
return () => window.removeEventListener('resize', onResize);
|
|
12197
|
-
}, [
|
|
12421
|
+
}, [fabVisible]);
|
|
12422
|
+
|
|
12423
|
+
// 按钮被隐藏(手动 / 看板页自动收起)时,菜单不能留在屏幕上。
|
|
12424
|
+
useEffect(() => {
|
|
12425
|
+
if (!fabVisible) setMenuOpen(false);
|
|
12426
|
+
}, [fabVisible]);
|
|
12427
|
+
|
|
12428
|
+
// 菜单外侧点击 / Esc 关闭。菜单项自己的 click 由 stopPropagation 挡住,不会误关。
|
|
12429
|
+
useEffect(() => {
|
|
12430
|
+
if (!menuOpen) return;
|
|
12431
|
+
const onDocClick = (event) => {
|
|
12432
|
+
if (menuRef.current && menuRef.current.contains(event.target)) return;
|
|
12433
|
+
setMenuOpen(false);
|
|
12434
|
+
};
|
|
12435
|
+
const onKey = (event) => { if (event.key === 'Escape') setMenuOpen(false); };
|
|
12436
|
+
document.addEventListener('click', onDocClick);
|
|
12437
|
+
document.addEventListener('keydown', onKey);
|
|
12438
|
+
return () => {
|
|
12439
|
+
document.removeEventListener('click', onDocClick);
|
|
12440
|
+
document.removeEventListener('keydown', onKey);
|
|
12441
|
+
};
|
|
12442
|
+
}, [menuOpen]);
|
|
12443
|
+
|
|
12444
|
+
// 隐藏后的提示条自动收起(文案在渲染期取 t(),切语言即时变)。
|
|
12445
|
+
useEffect(() => {
|
|
12446
|
+
if (!hideHint) return;
|
|
12447
|
+
const id = setTimeout(() => setHideHint(false), 6000);
|
|
12448
|
+
return () => clearTimeout(id);
|
|
12449
|
+
}, [hideHint]);
|
|
12450
|
+
|
|
12451
|
+
// 长按(触屏 / 手写笔)与右键都能叫出菜单;鼠标拖动与长按互不抢占。
|
|
12452
|
+
const cancelPressMenu = () => {
|
|
12453
|
+
if (pressTimerRef.current) {
|
|
12454
|
+
clearTimeout(pressTimerRef.current);
|
|
12455
|
+
pressTimerRef.current = 0;
|
|
12456
|
+
}
|
|
12457
|
+
};
|
|
12458
|
+
useEffect(() => cancelPressMenu, []);
|
|
12198
12459
|
|
|
12199
12460
|
const handlePointerDown = (event) => {
|
|
12200
12461
|
if (event.button !== undefined && event.button !== 0) return;
|
|
@@ -12211,6 +12472,15 @@ const McpOverlay = () => {
|
|
|
12211
12472
|
moved: false,
|
|
12212
12473
|
};
|
|
12213
12474
|
try { el.setPointerCapture(event.pointerId); } catch { /* 忽略不支持捕获的环境 */ }
|
|
12475
|
+
if (event.pointerType && event.pointerType !== 'mouse') {
|
|
12476
|
+
cancelPressMenu();
|
|
12477
|
+
pressTimerRef.current = setTimeout(() => {
|
|
12478
|
+
pressTimerRef.current = 0;
|
|
12479
|
+
dragRef.current = null;
|
|
12480
|
+
suppressClickRef.current = true;
|
|
12481
|
+
setMenuOpen(true);
|
|
12482
|
+
}, FAB_MENU_PRESS_MS);
|
|
12483
|
+
}
|
|
12214
12484
|
};
|
|
12215
12485
|
const handlePointerMove = (event) => {
|
|
12216
12486
|
const drag = dragRef.current;
|
|
@@ -12220,6 +12490,7 @@ const McpOverlay = () => {
|
|
|
12220
12490
|
const dy = event.clientY - drag.startClientY;
|
|
12221
12491
|
if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
12222
12492
|
drag.moved = true;
|
|
12493
|
+
cancelPressMenu();
|
|
12223
12494
|
drag.live = clampFabPos(drag.startX + dx, drag.startY + dy);
|
|
12224
12495
|
// 跟手:直接写 DOM,不触发 React 渲染;松手时才 setPos 提交。
|
|
12225
12496
|
el.style.left = drag.live.x + 'px';
|
|
@@ -12227,6 +12498,7 @@ const McpOverlay = () => {
|
|
|
12227
12498
|
el.style.cursor = 'grabbing';
|
|
12228
12499
|
};
|
|
12229
12500
|
const handlePointerEnd = () => {
|
|
12501
|
+
cancelPressMenu();
|
|
12230
12502
|
const drag = dragRef.current;
|
|
12231
12503
|
dragRef.current = null;
|
|
12232
12504
|
if (fabRef.current) fabRef.current.style.cursor = '';
|
|
@@ -12241,24 +12513,53 @@ const McpOverlay = () => {
|
|
|
12241
12513
|
};
|
|
12242
12514
|
const handleClick = () => {
|
|
12243
12515
|
if (suppressClickRef.current) { suppressClickRef.current = false; return; }
|
|
12516
|
+
// 菜单开着时,点按钮先把菜单收起(否则会同时打开看板 + 留下菜单)。
|
|
12517
|
+
if (menuOpen) { setMenuOpen(false); return; }
|
|
12244
12518
|
openBoardPanel();
|
|
12245
12519
|
};
|
|
12520
|
+
const handleContextMenu = (event) => {
|
|
12521
|
+
event.preventDefault();
|
|
12522
|
+
setMenuOpen(true);
|
|
12523
|
+
};
|
|
12524
|
+
const handleHideFab = () => {
|
|
12525
|
+
setMenuOpen(false);
|
|
12526
|
+
const next = entryModeWithoutFab(mode);
|
|
12527
|
+
if (next === mode) return;
|
|
12528
|
+
entryMode.set(next);
|
|
12529
|
+
setHideHint(true);
|
|
12530
|
+
};
|
|
12246
12531
|
|
|
12247
12532
|
const style = { left: pos.x + 'px', top: pos.y + 'px', right: 'auto', bottom: 'auto' };
|
|
12533
|
+
// 菜单贴着按钮,但夹在视口内;下方放不下就翻到按钮上面。
|
|
12534
|
+
const viewportWidth = typeof window === 'undefined' ? 1024 : window.innerWidth;
|
|
12535
|
+
const viewportHeight = typeof window === 'undefined' ? 768 : window.innerHeight;
|
|
12536
|
+
const menuWidth = 216;
|
|
12537
|
+
const menuHeight = canHideFab ? 78 : 112;
|
|
12538
|
+
const menuBelow = pos.y + FAB_SIZE + 8;
|
|
12539
|
+
const menuStyle = {
|
|
12540
|
+
left: Math.max(8, Math.min(pos.x, viewportWidth - menuWidth - 8)) + 'px',
|
|
12541
|
+
top: (menuBelow + menuHeight <= viewportHeight - 8
|
|
12542
|
+
? menuBelow
|
|
12543
|
+
: Math.max(8, pos.y - menuHeight - 8)) + 'px',
|
|
12544
|
+
width: menuWidth + 'px',
|
|
12545
|
+
};
|
|
12248
12546
|
|
|
12249
12547
|
return h(
|
|
12250
12548
|
React2.Fragment,
|
|
12251
12549
|
null,
|
|
12252
|
-
|
|
12550
|
+
fabVisible ? h(
|
|
12253
12551
|
'button',
|
|
12254
12552
|
{
|
|
12255
12553
|
ref: fabRef,
|
|
12256
12554
|
type: 'button',
|
|
12257
12555
|
className: 'dsh-mcp-fab',
|
|
12258
|
-
title: t('技能 · MCP ·
|
|
12556
|
+
title: t('技能 · MCP · 专家看板(拖拽移动,右键或长按隐藏)'),
|
|
12259
12557
|
'aria-label': t('打开技能 · MCP · 专家看板'),
|
|
12558
|
+
'aria-haspopup': 'menu',
|
|
12559
|
+
'aria-expanded': menuOpen,
|
|
12260
12560
|
style,
|
|
12261
12561
|
onClick: handleClick,
|
|
12562
|
+
onContextMenu: handleContextMenu,
|
|
12262
12563
|
onPointerDown: handlePointerDown,
|
|
12263
12564
|
onPointerMove: handlePointerMove,
|
|
12264
12565
|
onPointerUp: handlePointerEnd,
|
|
@@ -12266,6 +12567,30 @@ const McpOverlay = () => {
|
|
|
12266
12567
|
},
|
|
12267
12568
|
h(Icon, { d: ICONS.board, size: 22 }),
|
|
12268
12569
|
) : null,
|
|
12570
|
+
fabVisible && menuOpen ? h(
|
|
12571
|
+
'div',
|
|
12572
|
+
{
|
|
12573
|
+
ref: menuRef,
|
|
12574
|
+
className: 'dsh-mcp-tab-pop dsh-mcp-fab-menu',
|
|
12575
|
+
role: 'menu',
|
|
12576
|
+
'aria-label': t('悬浮按钮设置'),
|
|
12577
|
+
style: menuStyle,
|
|
12578
|
+
onClick: (event) => event.stopPropagation(),
|
|
12579
|
+
onContextMenu: (event) => event.preventDefault(),
|
|
12580
|
+
},
|
|
12581
|
+
h('button', {
|
|
12582
|
+
type: 'button',
|
|
12583
|
+
role: 'menuitem',
|
|
12584
|
+
className: 'dsh-mcp-entry-item dsh-mcp-fab-menu-item',
|
|
12585
|
+
disabled: !canHideFab,
|
|
12586
|
+
onClick: handleHideFab,
|
|
12587
|
+
}, t('隐藏悬浮按钮')),
|
|
12588
|
+
canHideFab
|
|
12589
|
+
? null
|
|
12590
|
+
: h('p', { className: 'dsh-mcp-fab-menu-note' }, t('这是当前唯一入口;先到面板「入口」菜单切换模式。')),
|
|
12591
|
+
) : null,
|
|
12592
|
+
hideHint ? h('div', { className: 'dsh-mcp-fab-hint', role: 'status' },
|
|
12593
|
+
t('悬浮按钮已隐藏,可从左上角「大肥鱼军械库」或面板「入口」菜单重新打开。')) : null,
|
|
12269
12594
|
);
|
|
12270
12595
|
};
|
|
12271
12596
|
|
|
@@ -12360,6 +12685,7 @@ const MCP_CONTRIBUTION = {
|
|
|
12360
12685
|
{ id: 'dsh-armory#mcpManager/skillDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'skillDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillDetailResult') },
|
|
12361
12686
|
{ id: 'dsh-armory#mcpManager/revealSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'revealSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12362
12687
|
{ id: 'dsh-armory#mcpManager/removeSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'removeSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12688
|
+
{ id: 'dsh-armory#mcpManager/skillInstallInfo', service: 'mcpManager', namespace: 'mcpManager', method: 'skillInstallInfo', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillInstallInfoResult') },
|
|
12363
12689
|
// 自定义套件:读全量集合 / 整集合覆盖保存(与 typert.js、lib/index.js 三处同步,
|
|
12364
12690
|
// 漏一处这条通道恒 404 —— toolTailor、installInfo 各踩过一次)。
|
|
12365
12691
|
{ id: 'dsh-armory#mcpManager/listSkillSuites', service: 'mcpManager', namespace: 'mcpManager', method: 'listSkillSuites', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('SkillSuiteListResult') },
|
package/lib/index.js
CHANGED
|
@@ -14,6 +14,8 @@ import { deriveMcpPhase, formatMcpLog, sanitizeMcpLog } from "./mcp-observabilit
|
|
|
14
14
|
import { allMcpEntries, entriesForName, projectToolSchema, revisionOf, toolInventory } from "./mcp-registry.js";
|
|
15
15
|
// Skill 看板的卸载落盘点:来源白名单 + 根目录包含性 + 符号链接/根目录校验(lib/skill-remove.js)。
|
|
16
16
|
import { removeSkillFromDisk } from "./skill-remove.js";
|
|
17
|
+
// Skill 看板的安装时间 + 市场原名投影(名字自建映射,lib/skill-dir-times.js)。
|
|
18
|
+
import { skillInstallInfo } from "./skill-dir-times.js";
|
|
17
19
|
import { WHATS_NEW } from "./whats-new.js";
|
|
18
20
|
import { attachWhatsNewEnglish } from "./whats-new-en.js";
|
|
19
21
|
import { probeMcpServersAuthorization, wbConnectorCatalog, wbConnectorCachedCount, wbConnectorIcon, wbConnectorPreview, wbSkillCatalog, wbSkillDetail, wbSkillInstall, wbSkillInstalled, wbSkillUninstall } from "./wb-market.js";
|
|
@@ -1815,6 +1817,12 @@ let McpManagerGateway = (() => {
|
|
|
1815
1817
|
if (result === undefined) throw new Error("宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),无法卸载");
|
|
1816
1818
|
return { note: `已卸载 ${name}(${result.kind === "symlink" ? "符号链接" : "目录"}:${result.path})` };
|
|
1817
1819
|
}
|
|
1820
|
+
// 安装时间 + 市场原名(按 skill 名字索引):宿主自扫标准技能根目录、读 SKILL.md
|
|
1821
|
+
// 头部 name 建立映射——不依赖 listSkills 行里的 path(注册表可能整列不给)。
|
|
1822
|
+
// 永不抛错——时间与原名是辅助信息,单个目录读失败只是没有该条目,不该拖垮看板。
|
|
1823
|
+
async skillInstallInfo(payload) {
|
|
1824
|
+
return skillInstallInfo(payload || {});
|
|
1825
|
+
}
|
|
1818
1826
|
// ── 自定义套件(Skill 看板)─────────────────────────────────────────
|
|
1819
1827
|
// 数据层在 lib/suites-registry.js(~/.dsh/armory/suites.json);这里只做 RPC 转发。
|
|
1820
1828
|
// listSkillSuites —— 永不抛错:文件缺失 / JSON 损坏 / 结构不对都回落空集合,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Skill 看板的「安装时间 + 市场原名」投影(按 skill 名字索引)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么不按 listSkills 行里的 path 查:宿主注册表的行**不一定**带 path(字段是条件
|
|
4
|
+
// 展开,lib/index.js 只透传注册表给的),实测有的版本整列都缺——按 path 查会整表静默
|
|
5
|
+
// 落空。这里改为宿主自建映射:扫描标准技能根目录,读每个目录 SKILL.md 头部的
|
|
6
|
+
// `name:` 字段得到「看板显示名」,建立 名字 → 目录 → 时间/原名 的映射,按名字返回。
|
|
7
|
+
// 面板的每一行必有 name(看板主键),join 永不落空。
|
|
8
|
+
//
|
|
9
|
+
// installedAt 取值优先级(与 wb-market.wbSkillInstalled 同一口径):
|
|
10
|
+
// ① 安装状态账本(kind=skill,按 installPath 归一对齐)登记的首次安装时间;
|
|
11
|
+
// ② 目录创建时间——重装 = rm 后 rename 换入新目录,创建时间即最近一次落盘;
|
|
12
|
+
// ③ 目录修改时间兜底(个别文件系统不吐 birthtime)。
|
|
13
|
+
// 没有 SKILL.md 的目录不会被注册表发现,直接跳过;同名目录按扫描序首个优先
|
|
14
|
+
// (agents 根在 dsh 根之前,与注册表的来源优先级同向)。
|
|
15
|
+
// 账本命中的条目附带 marketName / marketplace / marketVersion:账本登记的是市场里的
|
|
16
|
+
// 名字,看板显示的是 SKILL.md 的 name 字段——两者可以不同,投影出去供面板对应。
|
|
17
|
+
//
|
|
18
|
+
// 纯只读(readdir / stat / 只读 SKILL.md 头部 4KB);账本读失败只降级到 ②③。
|
|
19
|
+
// 存储根目录可注入(options.rootDir / DSH_ARMORY_HOME),home 可注入(options.home,测试用)。
|
|
20
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { listInstalled } from "./wb-install-state.js";
|
|
24
|
+
|
|
25
|
+
/** join 键归一:分隔符统一 /、去尾斜杠。账本 installPath 与实际扫描目录是两套来源,
|
|
26
|
+
* Windows 下分隔符与结尾斜杠可能有差异,对齐前先拉齐形态。 */
|
|
27
|
+
const normPath = (value) => String(value || "").trim().replace(/[\\/]+/g, "/").replace(/\/+$/, "");
|
|
28
|
+
|
|
29
|
+
/** 只读文件头部 4KB——frontmatter 永远在最前面,整读大 SKILL.md 不值得。 */
|
|
30
|
+
async function head(path, bytes = 4096) {
|
|
31
|
+
const handle = await open(path, "r");
|
|
32
|
+
try {
|
|
33
|
+
const buffer = Buffer.alloc(bytes);
|
|
34
|
+
const { bytesRead } = await handle.read(buffer, 0, bytes, 0);
|
|
35
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
36
|
+
} finally {
|
|
37
|
+
await handle.close().catch(() => {});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** SKILL.md 头部 frontmatter 的 name 字段;没有 frontmatter / 没写 name 返回空串。 */
|
|
42
|
+
export function frontmatterName(text) {
|
|
43
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ""));
|
|
44
|
+
if (!block) return "";
|
|
45
|
+
const line = /^name:[ \t]*(.+?)[ \t]*$/m.exec(block[1]);
|
|
46
|
+
return line ? line[1].replace(/^["']|["']$/g, "") : "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 扫描技能根目录,返回「看板名字 → 安装信息」映射。
|
|
51
|
+
* @param {object} payload 客户端信封:{ cwd? }(项目作用域时带项目根)
|
|
52
|
+
* @returns {Promise<{info: Record<string, {installedAt: number, dir: string,
|
|
53
|
+
* marketName?: string, marketplace?: string, marketVersion?: string}>}>}
|
|
54
|
+
*/
|
|
55
|
+
export async function skillInstallInfo(payload = {}, options = {}) {
|
|
56
|
+
const home = options.home || homedir();
|
|
57
|
+
const roots = [
|
|
58
|
+
join(home, ".agents", "skills"),
|
|
59
|
+
join(home, ".dsh", "skills"),
|
|
60
|
+
];
|
|
61
|
+
const cwd = String(payload?.cwd || "").trim();
|
|
62
|
+
if (cwd) {
|
|
63
|
+
roots.push(join(cwd, ".agents", "skills"));
|
|
64
|
+
roots.push(join(cwd, ".dsh", "skills"));
|
|
65
|
+
}
|
|
66
|
+
const ledger = await listInstalled("skill", options).catch(() => []);
|
|
67
|
+
const byPath = new Map(
|
|
68
|
+
ledger.filter((row) => row && row.installPath).map((row) => [normPath(row.installPath), row]),
|
|
69
|
+
);
|
|
70
|
+
const info = {};
|
|
71
|
+
for (const root of roots) {
|
|
72
|
+
let entries = [];
|
|
73
|
+
try {
|
|
74
|
+
entries = await readdir(root);
|
|
75
|
+
} catch {
|
|
76
|
+
continue; // 根目录不存在(没装过任何市场技能 / 没有 ~/.dsh/skills):跳过
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.startsWith(".")) continue;
|
|
80
|
+
const dir = join(root, entry);
|
|
81
|
+
let dirStat;
|
|
82
|
+
try {
|
|
83
|
+
dirStat = await stat(dir);
|
|
84
|
+
} catch {
|
|
85
|
+
continue; // 竞态删除等:跳过
|
|
86
|
+
}
|
|
87
|
+
if (!dirStat.isDirectory()) continue;
|
|
88
|
+
let name = "";
|
|
89
|
+
try {
|
|
90
|
+
name = frontmatterName(await head(join(dir, "SKILL.md")));
|
|
91
|
+
} catch {
|
|
92
|
+
continue; // 没有 SKILL.md:注册表发现不了,不进映射
|
|
93
|
+
}
|
|
94
|
+
if (!name) name = entry;
|
|
95
|
+
if (info[name]) continue; // 同名:首个根/首个目录优先
|
|
96
|
+
const hit = byPath.get(normPath(dir));
|
|
97
|
+
const bornAt = Math.round(Number(dirStat.birthtimeMs) || 0);
|
|
98
|
+
const row = {
|
|
99
|
+
installedAt: (hit?.installedAt || 0) || bornAt || Math.round(Number(dirStat.mtimeMs) || 0),
|
|
100
|
+
dir,
|
|
101
|
+
};
|
|
102
|
+
if (hit?.name) {
|
|
103
|
+
row.marketName = hit.name;
|
|
104
|
+
row.marketplace = hit.source?.marketplace || "";
|
|
105
|
+
row.marketVersion = hit.version || "";
|
|
106
|
+
}
|
|
107
|
+
info[name] = row;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { info };
|
|
111
|
+
}
|
package/lib/typert.js
CHANGED
|
@@ -77,6 +77,11 @@ export const TYPERT = {
|
|
|
77
77
|
// 卸载已安装 skill:永久删除用户级/项目级 skills 根目录内的条目。三处契约缺一即 404,
|
|
78
78
|
// 删除规则全在 lib/skill-remove.js(来源白名单 / 根目录包含性 / 符号链接只删链接)。
|
|
79
79
|
mv('removeSkill', [payloadParam], 'NoteResult'),
|
|
80
|
+
// 安装时间 + 市场原名(Skill 看板卡片徽标 /「最近安装」排序的数据源):宿主自扫
|
|
81
|
+
// 标准根目录按 SKILL.md name 建映射,出 { ok, info: { [name]: { installedAt, ... } } }。
|
|
82
|
+
// 不按 listSkills 行的 path 查——注册表行可能整列缺 path(实测)。形状由
|
|
83
|
+
// lib/skill-dir-times.js 校验,与 wb* 系列同一宽容度(payload $any)。
|
|
84
|
+
mv('skillInstallInfo', [payloadParam], 'SkillInstallInfoResult'),
|
|
80
85
|
// 自定义套件(Skill 看板的「一组相关 skill 打包引用」):读 = 全量集合,
|
|
81
86
|
// 存 = 整集合覆盖(UI 一次提交右侧全部套件,不做增量 upsert)。数据层在
|
|
82
87
|
// lib/suites-registry.js,id 分配 / 成员去重保序 / 脏数据拒绝都在那边,
|
package/lib/wb-market.js
CHANGED
|
@@ -32,6 +32,8 @@ import { sourceFor } from "./wb-registry.js";
|
|
|
32
32
|
// R8:鉴权失败的判定与 OAuth 客户端共用同一份实现(见 mcp-oauth.js 的 mcpAuthFailure)。
|
|
33
33
|
// 两家各写一套「什么算 401」迟早分叉,而分叉的代价是用户看不到授权入口。
|
|
34
34
|
import { mcpAuthFailure } from "./mcp-oauth.js";
|
|
35
|
+
// R8:安装状态账本读侧——wbSkillInstalled 用它给每条已装技能补 installedAt。
|
|
36
|
+
import { listInstalled } from "./wb-install-state.js";
|
|
35
37
|
|
|
36
38
|
const CONNECTOR_ZIP_URL = "https://static.workbuddy.cn/connectors-config-v2/connectors-config.zip";
|
|
37
39
|
|
|
@@ -503,6 +505,33 @@ export async function wbConnectorCachedCount() {
|
|
|
503
505
|
// 详情:/api/v1/skills/:slug?namespace=<handle>(带 latestVersion、publisher、securityReports)
|
|
504
506
|
// 文件:/api/v1/skills/:slug/files?namespace=<handle>[&version=];/file 同形 + &path=
|
|
505
507
|
// namespace 用 namespace.handle(列表项里是对象), 不是 canonicalName。
|
|
508
|
+
const SKILLHUB_PUBLIC_ORIGIN = "https://skillhub.cn";
|
|
509
|
+
/**
|
|
510
|
+
* 把 SkillHub 的 homepage 归一成「浏览器能打开」的地址(issue #4)。
|
|
511
|
+
* 上游列表/详情的 homepage 实际是 `https://api.skillhub.cn/<namespace>/<slug>`——那是
|
|
512
|
+
* JSON 接口,浏览器直接打开只得到 405。人可读页面在 `https://skillhub.cn/<namespace>/<slug>`
|
|
513
|
+
* (2026-10 实测 200 text/html)。优先用 namespace + slug 自己拼公开页,这样即使上游
|
|
514
|
+
* 换成别的内部域名也不会把接口地址交给浏览器;GitHub 等其它可浏览地址原样保留。
|
|
515
|
+
*/
|
|
516
|
+
export function skillhubHomepage(rawHomepage, namespace, slug) {
|
|
517
|
+
const handle = String(namespace || "").trim();
|
|
518
|
+
const skillSlug = String(slug || "").trim();
|
|
519
|
+
const publicPage = handle && skillSlug
|
|
520
|
+
? `${SKILLHUB_PUBLIC_ORIGIN}/${encodeURIComponent(handle)}/${encodeURIComponent(skillSlug)}`
|
|
521
|
+
: "";
|
|
522
|
+
const raw = String(rawHomepage || "").trim();
|
|
523
|
+
if (!raw) return publicPage;
|
|
524
|
+
let parsed;
|
|
525
|
+
try {
|
|
526
|
+
parsed = new URL(raw);
|
|
527
|
+
} catch {
|
|
528
|
+
return raw;
|
|
529
|
+
}
|
|
530
|
+
if (parsed.hostname.toLowerCase() === "api.skillhub.cn") {
|
|
531
|
+
return publicPage || `${SKILLHUB_PUBLIC_ORIGIN}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
532
|
+
}
|
|
533
|
+
return raw;
|
|
534
|
+
}
|
|
506
535
|
const SKILLHUB_SORTS = new Set(["downloads", "installs", "stars", "score"]);
|
|
507
536
|
// 2026-10 实测(api.skillhub.cn):`sortBy=downloads` 已不再是可用值——TCP 能连上、
|
|
508
537
|
// 但服务端不回任何字节,一直挂到客户端 AbortSignal 超时;installs / stars / score /
|
|
@@ -541,32 +570,39 @@ export async function wbSkillCatalog(payload = {}) {
|
|
|
541
570
|
if (category) params.set("category", category.slice(0, 64));
|
|
542
571
|
const body = await skillhubJson(`/api/skills?${params.toString()}`);
|
|
543
572
|
const skills = Array.isArray(body?.data?.skills) ? body.data.skills : [];
|
|
544
|
-
const rows = skills.map((skill) =>
|
|
545
|
-
slug
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
573
|
+
const rows = skills.map((skill) => {
|
|
574
|
+
const slug = String(skill.slug || "");
|
|
575
|
+
const namespace = String(skill.namespace?.handle || "");
|
|
576
|
+
const homepage = skillhubHomepage(skill.homepage, namespace, slug);
|
|
577
|
+
return {
|
|
578
|
+
slug,
|
|
579
|
+
name: String(skill.name || skill.slug || ""),
|
|
580
|
+
namespace,
|
|
581
|
+
canonical: String(skill.namespace?.canonicalName || ""),
|
|
582
|
+
// 「人」的字段:发布方显示名、作品图标、公开详情页、上架时间与认领状态。
|
|
583
|
+
// 这些字段列表接口本来就有,不额外发请求——卡片上的人味信号零成本。
|
|
584
|
+
ownerName: String(skill.ownerName || skill.namespace?.displayName || skill.namespace?.handle || ""),
|
|
585
|
+
iconUrl: String(skill.iconUrl || ""),
|
|
586
|
+
// 上游把 homepage 填成了 api.skillhub.cn 的 JSON 地址(405),换成公开页。
|
|
587
|
+
homepage,
|
|
588
|
+
createdAt: Number(skill.created_at) || 0,
|
|
589
|
+
claimState: String(skill.claim_state || ""),
|
|
590
|
+
source: String(skill.source || ""),
|
|
591
|
+
description: String(skill.description_zh || skill.description || ""),
|
|
592
|
+
descriptionEn: String(skill.description_en || ""),
|
|
593
|
+
downloads: Number(skill.downloads) || 0,
|
|
594
|
+
installs: Number(skill.installs) || 0,
|
|
595
|
+
stars: Number(skill.stars) || 0,
|
|
596
|
+
score: Number(skill.score) || 0,
|
|
597
|
+
version: String(skill.version || ""),
|
|
598
|
+
verified: !!skill.verified,
|
|
599
|
+
updatedAt: Number(skill.updated_at) || 0,
|
|
600
|
+
category: String(skill.category || ""),
|
|
601
|
+
tags: Array.isArray(skill.tags) ? skill.tags.slice(0, 6).map(String) : [],
|
|
602
|
+
// upstream_url 是真正的外部来源(GitHub / ClawHub):能打开就保留,缺失回落公开页。
|
|
603
|
+
upstreamUrl: skillhubHomepage(skill.upstream_url, namespace, slug) || homepage,
|
|
604
|
+
};
|
|
605
|
+
}).filter((skill) => skill.slug && skill.namespace);
|
|
570
606
|
// 下载量排序上游已不可用,退化为「当前页内按下载量降序」:跨页顺序仍是上游默认序,
|
|
571
607
|
// 所以这是页内排序而不是全库排序(UI 侧「最多下载」的含义随之收窄,见下)。
|
|
572
608
|
if (!remoteSort) rows.sort((a, b) => b.downloads - a.downloads);
|
|
@@ -588,20 +624,32 @@ export async function wbSkillDetail(payload = {}) {
|
|
|
588
624
|
// 详情端点**没有** {code,data,message} 包装:latestVersion 等字段直接在根上
|
|
589
625
|
// (列表端点才有 data 包装)。两种形状都兼容,避免上游改包装方式时把面板打空。
|
|
590
626
|
const data = body?.latestVersion ? body : (body?.data || {});
|
|
627
|
+
// 技能本体挂在 data.skill 下,字段名与列表不同(displayName / summary_zh / summary /
|
|
628
|
+
// iconUrl),根层没有 name、description、homepage。只读根层会得到「标题退化成 slug、
|
|
629
|
+
// 介绍空白」(issue #4 实测)。下面逐字段回退到列表的旧字段名,两种形状都兼容。
|
|
630
|
+
const skill = data.skill && typeof data.skill === "object" ? data.skill : {};
|
|
631
|
+
const handle = String(data.namespace?.handle || namespace);
|
|
591
632
|
const reports = data.securityReports && typeof data.securityReports === "object" ? data.securityReports : {};
|
|
592
633
|
const reportStatus = Object.values(reports).map((report) => String(report?.status || "")).filter(Boolean);
|
|
593
634
|
return {
|
|
594
635
|
slug,
|
|
595
|
-
namespace,
|
|
596
|
-
name: String(data.name || slug),
|
|
636
|
+
namespace: handle,
|
|
637
|
+
name: String(skill.displayName || data.name || slug),
|
|
597
638
|
version: String(data.latestVersion?.version || ""),
|
|
598
639
|
changelog: String(data.latestVersion?.changelog || ""),
|
|
599
|
-
description: String(data.description_zh || data.description || ""),
|
|
640
|
+
description: String(skill.summary_zh || skill.summary || data.description_zh || data.description || ""),
|
|
641
|
+
iconUrl: String(skill.iconUrl || data.iconUrl || ""),
|
|
642
|
+
ownerName: String(data.owner?.displayName || data.namespace?.displayName || ""),
|
|
600
643
|
publisher: String(data.publisher?.name || data.owner?.displayName || ""),
|
|
601
644
|
verifiedPublisher: !!data.publisher?.verified,
|
|
602
645
|
certifiedName: String(data.publisher?.certifiedName || ""),
|
|
603
646
|
security: reportStatus.length ? reportStatus : ["unknown"],
|
|
604
|
-
homepage
|
|
647
|
+
// 详情根层没有 homepage;公开页由 namespace + slug 拼,绝不把 api.skillhub.cn 交出去。
|
|
648
|
+
homepage: skillhubHomepage(data.homepage || skill.homepage, handle, slug),
|
|
649
|
+
// 详情缺 createdAt 时不回 0:列表项自带的上架时间会被 0 覆盖掉。
|
|
650
|
+
...(Number(skill.createdAt) ? { createdAt: Number(skill.createdAt) } : {}),
|
|
651
|
+
category: String(skill.category || ""),
|
|
652
|
+
source: String(skill.source || ""),
|
|
605
653
|
};
|
|
606
654
|
}
|
|
607
655
|
export async function wbSkillFiles(payload = {}) {
|
|
@@ -723,7 +771,12 @@ export async function wbSkillUninstall(payload = {}) {
|
|
|
723
771
|
await rm(finalDir, { recursive: true, force: true });
|
|
724
772
|
return { note: `已卸载 ${targetName}` };
|
|
725
773
|
}
|
|
726
|
-
/** 已装的市场技能(安装记录即目录本身),用于卡片上的「已安装」标记与卸载。
|
|
774
|
+
/** 已装的市场技能(安装记录即目录本身),用于卡片上的「已安装」标记与卸载。
|
|
775
|
+
* installedAt:安装时间(毫秒时间戳,0 = 完全取不到)。取值优先级:
|
|
776
|
+
* ① 安装状态账本(kind=skill 按 installPath 对齐)登记的首次安装时间;
|
|
777
|
+
* ② 目录创建时间——重装是 rm 后 rename 换入新目录,创建时间即最近一次安装;
|
|
778
|
+
* ③ 目录修改时间兜底(个别文件系统不吐 birthtime)。
|
|
779
|
+
* 账本读失败只降级到 ②③,不影响已装列表本身。 */
|
|
727
780
|
export async function wbSkillInstalled() {
|
|
728
781
|
const root = join(homedir(), ".agents", "skills");
|
|
729
782
|
const rows = [];
|
|
@@ -733,6 +786,10 @@ export async function wbSkillInstalled() {
|
|
|
733
786
|
} catch {
|
|
734
787
|
return { installed: [] };
|
|
735
788
|
}
|
|
789
|
+
const ledger = await listInstalled("skill").catch(() => []);
|
|
790
|
+
const ledgerAt = new Map(
|
|
791
|
+
ledger.filter((row) => row && row.installPath).map((row) => [row.installPath, row.installedAt || 0]),
|
|
792
|
+
);
|
|
736
793
|
for (const name of names) {
|
|
737
794
|
if (!name.includes("__")) continue;
|
|
738
795
|
const [namespace, ...slugParts] = name.split("__");
|
|
@@ -740,7 +797,14 @@ export async function wbSkillInstalled() {
|
|
|
740
797
|
try {
|
|
741
798
|
const info = await stat(dir);
|
|
742
799
|
if (!info.isDirectory()) continue;
|
|
743
|
-
|
|
800
|
+
const bornAt = Math.round(Number(info.birthtimeMs) || 0);
|
|
801
|
+
rows.push({
|
|
802
|
+
dirName: name,
|
|
803
|
+
namespace,
|
|
804
|
+
slug: slugParts.join("__"),
|
|
805
|
+
dir,
|
|
806
|
+
installedAt: ledgerAt.get(dir) || bornAt || Math.round(Number(info.mtimeMs) || 0),
|
|
807
|
+
});
|
|
744
808
|
} catch {
|
|
745
809
|
// 竞态删除:跳过
|
|
746
810
|
}
|
package/lib/whats-new-en.js
CHANGED
|
@@ -416,6 +416,38 @@ export const WHATS_NEW_EN = {
|
|
|
416
416
|
where: 'Market -> install a connector that needs authorization',
|
|
417
417
|
},
|
|
418
418
|
],
|
|
419
|
+
'2.4.0': [
|
|
420
|
+
{
|
|
421
|
+
text: '**Every Skill card now shows its install time**: "Added 3 h ago / Added 2026-09-01" sits right on the card (green highlight within 7 days). The install ledger\'s first-install time takes priority, falling back to the directory\'s creation time — the card you just installed is obvious at a glance; the detail panel gains an "Install time" row too',
|
|
422
|
+
where: 'Skill board / Market -> skills',
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
text: '**New "Recently installed" filter**: a flat timeline sorted by install time, newest first, with a green highlight within 7 days. Categories are keyword guesses and not always right — the timeline fills that navigation gap; search, source and category filters keep working on top',
|
|
426
|
+
where: 'Skill board -> filter bar',
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
text: '**Market-name correspondence**: when a skill\'s board name (the name field in SKILL.md) differs from its market name, the card shows a "From market: …" badge and the detail panel gains a "Market name" row — no more "I installed X from the market but can\'t find X on the board"; matching names stay badge-free to avoid noise',
|
|
430
|
+
where: 'Skill board / Market -> skills',
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
text: '**Install-time lookup now uses a host-built name map (skillInstallInfo)**: the host scans the standard skill roots, reads the name field from each SKILL.md header and returns the map keyed by name — no longer depending on the path column that the registry rows may omit entirely (hit in practice: path-based lookups silently returned nothing); when the API is unavailable the board says so and asks for a restart instead of staying silent',
|
|
434
|
+
where: 'Skill board',
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
'2.4.1': [
|
|
438
|
+
{
|
|
439
|
+
text: '**Skill details no longer go blank and titles no longer fall back to the slug**: SkillHub\'s detail endpoint keeps the name and summary under the `skill` object (`displayName` / `summary_zh`), while the old mapping only read the root level — the title became the slug and the summary was empty. It now falls back `summary_zh -> summary -> legacy fields` and merges the list item\'s existing name, summary and created time into the detail; empty strings from the response no longer overwrite what is already on screen, and the list summary stays visible while the detail loads (issue #4)',
|
|
440
|
+
where: 'Market -> Skills -> detail',
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
text: '**"Source homepage" now opens a readable page**: upstream `homepage` is actually the `api.skillhub.cn` JSON endpoint (405 in a browser). It is now rewritten to the public `https://skillhub.cn/<namespace>/<slug>` page, with a second guard at render time so the API host is never handed to the browser (issue #4)',
|
|
444
|
+
where: 'Market -> Skills -> Source homepage',
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
text: '**The floating button can be hidden and steps aside on the board**: right-click (or a 0.5 s long-press on touch) opens a Hide action with a hint on how to bring it back from the top-left entry or the "Entries" menu; a new "panel + sidebar entry" mode keeps at least two entry points. The button hides automatically while the board occupies the center panel and returns when you go back to the conversation (issue #4)',
|
|
448
|
+
where: 'Floating button / Panel -> Entries',
|
|
449
|
+
},
|
|
450
|
+
],
|
|
419
451
|
};
|
|
420
452
|
|
|
421
453
|
/**
|
package/lib/whats-new.js
CHANGED
|
@@ -478,4 +478,42 @@ export const WHATS_NEW = [
|
|
|
478
478
|
},
|
|
479
479
|
],
|
|
480
480
|
},
|
|
481
|
+
{
|
|
482
|
+
version: '2.4.0',
|
|
483
|
+
items: [
|
|
484
|
+
{
|
|
485
|
+
text: '**每张 Skill 卡片显示安装时间**:「装于 3 小时前 / 装于 2026-09-01」直接标在卡片上(7 天内绿色高亮),安装账本登记的首次安装时间优先、无账本用目录创建时间兜底——刚装的是哪张一眼可见;详情面板同步补「安装时间」行',
|
|
486
|
+
where: 'Skill 看板 / 市场 → 技能',
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
text: '**新增「最近安装」筛选**:按安装时间倒序平铺成时间线,刚装的永远排最前,7 天内高亮——分类是关键词猜的、不一定准,时间线正好补上导航缺口;搜索、来源、分类筛选照常叠加',
|
|
490
|
+
where: 'Skill 看板 → 筛选条',
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
text: '**市场原名对应**:看板显示名(SKILL.md 的 name)与市场名不一致的技能,卡片出现「来自市场 原名」徽标、详情面板新增「市场原名」行——解决「在市场装了 X,看板里找不到 X」;名字一致的不显示,避免噪音',
|
|
494
|
+
where: 'Skill 看板 / 市场 → 技能',
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
text: '**安装时间查询改为宿主自建名字映射(skillInstallInfo)**:宿主扫描标准技能根目录、读 SKILL.md 头部 name 建立映射后按名字返回——不再依赖注册表行可能整列缺失的 path 字段(实测踩过:按 path 查静默落空);接口不可用时看板明确提示重启,不再无声无息',
|
|
498
|
+
where: 'Skill 看板',
|
|
499
|
+
},
|
|
500
|
+
],
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
version: '2.4.1',
|
|
504
|
+
items: [
|
|
505
|
+
{
|
|
506
|
+
text: '**技能详情不再空白、标题不再退化成 slug**:SkillHub 详情接口把名称与介绍放在 `skill` 子对象(`displayName` / `summary_zh`),旧映射只读根层,于是标题变 slug、介绍全空。现在按 `summary_zh → summary → 旧字段` 逐级回退,并把列表项已有的中文名、介绍、上架时间合并进详情——回包里的空串不再覆盖已显示的内容,加载中也先显示列表里的介绍(issue #4)',
|
|
507
|
+
where: '市场 → 技能 → 详情',
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
text: '**「来源主页」改开可读页面**:上游 `homepage` 实际是 `api.skillhub.cn` 的 JSON 接口(浏览器打开 405)。现在统一改写成 `https://skillhub.cn/<namespace>/<slug>` 公开页,渲染层再拦一道,接口域名不会再被当成网页交出去(issue #4)',
|
|
511
|
+
where: '市场 → 技能 → 来源主页',
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
text: '**悬浮按钮可隐藏,看板打开时自动收起**:右键(触屏长按 0.5 秒)悬浮按钮可隐藏并提示从左上角或「入口」菜单重开;新增「面板 + 侧栏底部入口」档位,隐藏后仍保留至少两个入口。看板占据中栏时按钮自动让位,回到会话再恢复(issue #4)',
|
|
515
|
+
where: '悬浮按钮 / 面板 → 入口',
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
},
|
|
481
519
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-armory",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "Unified Skill + MCP + Expert + Plugin board for DeepSeek Harness (desktop app and Web GUI) — custom skill suites, skill install/uninstall with live refresh, global panel page, JSON import, WorkBuddy connector & SkillHub & Expert & DSH community plugin market installs, MCP OAuth authorization, tool-usage tailor with wire-tax trends, and profile-backed Host Remote management.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|