dsh-armory 2.4.0 → 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 +233 -13
- package/lib/wb-market.js +76 -30
- package/lib/whats-new-en.js +14 -0
- package/lib/whats-new.js +17 -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",
|
|
@@ -492,7 +494,7 @@ const EN_US_MESSAGES = {
|
|
|
492
494
|
"技能 / MCP / 专家 快速调用": "Skills / MCPs / Experts quick invoke",
|
|
493
495
|
"技能 · MCP · 专家": "Skills · MCPs · Experts",
|
|
494
496
|
"技能 · MCP · 专家看板": "Skills · MCPs · Experts board",
|
|
495
|
-
"技能 · MCP ·
|
|
497
|
+
"技能 · MCP · 专家看板(拖拽移动,右键或长按隐藏)": "Skills · MCPs · Experts board (drag to move; right-click or long-press to hide)",
|
|
496
498
|
"技能·专家": "Skills·Experts",
|
|
497
499
|
"技能按需触发:选择后以 /技能名 插入输入框": "Skills trigger on demand: picking inserts /skill-name into the input box",
|
|
498
500
|
"技能来自 SkillHub 公开 API,可能还没同步下来。": "Skills come from the public SkillHub API and may not have synced yet.",
|
|
@@ -749,6 +751,9 @@ const EN_US_MESSAGES = {
|
|
|
749
751
|
"随 DSH 分发": "Ships with DSH",
|
|
750
752
|
"隐藏 ": "Hide ",
|
|
751
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.",
|
|
752
757
|
"零调用": "Zero-call",
|
|
753
758
|
"零调用工具": "Zero-call tools",
|
|
754
759
|
"零调用的 MCP 工具 · 点击下钻": "Zero-call MCP tools · click to drill down",
|
|
@@ -2056,6 +2061,11 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2056
2061
|
.dsh-mcp-fab:hover{transform:scale(1.05);background:var(--dsw-alias-button-primary-hover)}
|
|
2057
2062
|
.dsh-mcp-fab:active{transform:scale(.97)}
|
|
2058
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}
|
|
2059
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}
|
|
2060
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)}
|
|
2061
2071
|
.dsh-mcp-panel .dsh-mcp-wrap{height:100%;min-height:0;padding:18px}
|
|
@@ -5228,6 +5238,8 @@ const McpTab = (props) => {
|
|
|
5228
5238
|
// left/top 重绘成本可忽略,故保留 left/top 定位以复用现有 :hover/:active 的 transform 缩放反馈。
|
|
5229
5239
|
const FAB_POS_KEY = 'dsh-armory/fab-pos';
|
|
5230
5240
|
const DRAG_THRESHOLD = 5;
|
|
5241
|
+
// 触屏 / 手写笔长按多久叫出隐藏菜单:与常见长按菜单同一量级,短于拖动判定的误触窗口。
|
|
5242
|
+
const FAB_MENU_PRESS_MS = 520;
|
|
5231
5243
|
|
|
5232
5244
|
function fabViewport() {
|
|
5233
5245
|
const w = typeof window !== 'undefined' ? window.innerWidth : 0;
|
|
@@ -5278,6 +5290,9 @@ const ENTRY_KEY = 'dsh-armory/entry';
|
|
|
5278
5290
|
const ENTRY_VISIBILITY = {
|
|
5279
5291
|
both: { panel: true, fab: true, sidebar: false },
|
|
5280
5292
|
panel: { panel: true, fab: false, sidebar: false },
|
|
5293
|
+
// 「隐藏悬浮按钮」要保留另外两个入口:两种「面板 + 侧栏」组合各有一档,
|
|
5294
|
+
// 否则从 all 隐藏后会连带丢掉侧栏底部入口(issue #4)。
|
|
5295
|
+
panelSidebar: { panel: true, fab: false, sidebar: true },
|
|
5281
5296
|
fab: { panel: false, fab: true, sidebar: false },
|
|
5282
5297
|
sidebar: { panel: false, fab: false, sidebar: true },
|
|
5283
5298
|
all: { panel: true, fab: true, sidebar: true },
|
|
@@ -5287,6 +5302,7 @@ const ENTRY_VISIBILITY = {
|
|
|
5287
5302
|
const ENTRY_LABELS = {
|
|
5288
5303
|
get both() { return t('全局面板 + 悬浮按钮(推荐)'); },
|
|
5289
5304
|
get panel() { return t('仅全局面板(左上角,"插件"旁)'); },
|
|
5305
|
+
get panelSidebar() { return t('全局面板 + 侧栏底部入口(无悬浮按钮)'); },
|
|
5290
5306
|
get fab() { return t('仅悬浮按钮'); },
|
|
5291
5307
|
get sidebar() { return t('仅侧栏底部入口'); },
|
|
5292
5308
|
get all() { return t('三个入口全开'); },
|
|
@@ -5299,6 +5315,18 @@ const ENTRY_MODES = Object.keys(ENTRY_VISIBILITY);
|
|
|
5299
5315
|
function normalizeEntryMode(value) {
|
|
5300
5316
|
return Object.prototype.hasOwnProperty.call(ENTRY_VISIBILITY, value) ? value : ENTRY_DEFAULT;
|
|
5301
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
|
+
}
|
|
5302
5330
|
function readEntryMode() {
|
|
5303
5331
|
try {
|
|
5304
5332
|
if (typeof localStorage === 'undefined') return ENTRY_DEFAULT;
|
|
@@ -5358,6 +5386,12 @@ function overlaySeats(mode) {
|
|
|
5358
5386
|
// 两个 id 必须是同一个字符串:侧栏行点击后宿主会先校验 main slot 里存在同 key 的
|
|
5359
5387
|
// 条目(layout.selectPanel → hasMainPanel),不存在会直接抛错,侧栏就点不动了。
|
|
5360
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
|
+
}
|
|
5361
5395
|
// 入口排序:插件管理器用 0,定时任务用 10;取 5 让本入口正好落在「插件」旁边。
|
|
5362
5396
|
const PANEL_ORDER = 5;
|
|
5363
5397
|
// 看板五分类(MCP / Skill / 专家 / 工具裁缝 / 市场)随名称一并示人。
|
|
@@ -9432,6 +9466,26 @@ const CommunityPresence = ({ call, repo, owner, avatar, updatedAt, compact }) =>
|
|
|
9432
9466
|
),
|
|
9433
9467
|
);
|
|
9434
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
|
+
};
|
|
9435
9489
|
const feedbackKindLabel = (kind) =>
|
|
9436
9490
|
kind === 'skill' ? t('技能') : kind === 'expert' ? t('专家') : kind === 'plugin' ? t('插件') : kind === 'connector' ? t('连接器') : t('条目');
|
|
9437
9491
|
const feedbackTemplate = (item, kind, repo, homepage) => {
|
|
@@ -9460,8 +9514,8 @@ const FeedbackLinks = ({ item, kind, repo, homepage, sourceLabel }) => {
|
|
|
9460
9514
|
const name = String(item?.name || item?.id || item?.plugin || item?.slug || '').trim() || t('未命名条目');
|
|
9461
9515
|
const repoKey = parseGithubRepoKey(repo || item?.repo || item?.repository);
|
|
9462
9516
|
const repoUrl = repoKey ? `https://github.com/${repoKey}` : '';
|
|
9463
|
-
const home =
|
|
9464
|
-
const homeUrl =
|
|
9517
|
+
const home = browsableHomepageUrl(homepage || item?.homepage || item?.upstreamUrl);
|
|
9518
|
+
const homeUrl = home && home !== repoUrl ? home : '';
|
|
9465
9519
|
const template = feedbackTemplate(item, kind, repoUrl || homeUrl, homeUrl);
|
|
9466
9520
|
const title = t('{0} · {1} 的反馈', [feedbackKindLabel(kind), name]);
|
|
9467
9521
|
const issueUrl = ARMORY_REPO_URL + '/issues/new?title=' + encodeURIComponent(title) + '&body=' + encodeURIComponent(template);
|
|
@@ -10434,7 +10488,8 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10434
10488
|
try {
|
|
10435
10489
|
const res = await call('wbSkillDetail', { slug: skill.slug, namespace: skill.namespace });
|
|
10436
10490
|
if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
|
|
10437
|
-
|
|
10491
|
+
// 回包里的空串不能覆盖列表项已有的中文名 / 介绍(issue #4)。
|
|
10492
|
+
setSkillDetail({ ...mergeSkillDetail(skill, res), loading: false });
|
|
10438
10493
|
} catch (e) {
|
|
10439
10494
|
setSkillDetail({ ...skill, loading: false, detailError: String(e?.message || e) });
|
|
10440
10495
|
}
|
|
@@ -10989,7 +11044,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10989
11044
|
owner: skill.ownerName || skill.namespace,
|
|
10990
11045
|
avatar: skill.iconUrl || githubAvatarUrl(skill.homepage),
|
|
10991
11046
|
updatedAt: skill.updatedAt,
|
|
10992
|
-
href:
|
|
11047
|
+
href: browsableHomepageUrl(skill.homepage),
|
|
10993
11048
|
className: 'dsh-mcp-presence-card',
|
|
10994
11049
|
}),
|
|
10995
11050
|
h('div', { className: 'dsh-mcp-card-tags' },
|
|
@@ -11337,15 +11392,20 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11337
11392
|
}),
|
|
11338
11393
|
h(FeedbackLinks, {
|
|
11339
11394
|
item: skillDetail, kind: 'skill',
|
|
11340
|
-
|
|
11395
|
+
// upstream_url(GitHub)优先,其次才是技能公开页:公开页不是仓库时
|
|
11396
|
+
// 不该占掉「去仓库」的位置。
|
|
11397
|
+
repo: parseGithubRepoKey(skillDetail.upstreamUrl)
|
|
11398
|
+
? skillDetail.upstreamUrl
|
|
11399
|
+
: (parseGithubRepoKey(skillDetail.homepage) ? skillDetail.homepage : ''),
|
|
11341
11400
|
homepage: skillDetail.homepage || skillDetail.upstreamUrl,
|
|
11342
11401
|
sourceLabel: skillDetail.ownerName || skillDetail.publisher || t('SkillHub'),
|
|
11343
11402
|
}),
|
|
11403
|
+
// 介绍先从列表项渲染(不必等详情回来),详情到达后原地替换为完整正文。
|
|
11404
|
+
skillDetail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, skillDetail.description) : null,
|
|
11344
11405
|
skillDetail.loading
|
|
11345
11406
|
? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在读取详情…'))
|
|
11346
11407
|
: h('div', null,
|
|
11347
11408
|
skillDetail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, skillDetail.detailError) : null,
|
|
11348
|
-
skillDetail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, skillDetail.description) : null,
|
|
11349
11409
|
h('div', { className: 'dsh-mcp-card-tags' },
|
|
11350
11410
|
skillDetail.version ? h(Badge, null, 'v' + skillDetail.version) : null,
|
|
11351
11411
|
skillDetail.publisher ? h(Badge, null, skillDetail.publisher + (skillDetail.verifiedPublisher ? t('(已认证)') : '')) : null,
|
|
@@ -12282,24 +12342,120 @@ const WhatsNewModal = ({ call }) => {
|
|
|
12282
12342
|
);
|
|
12283
12343
|
};
|
|
12284
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
|
+
|
|
12285
12397
|
// ── 悬浮按钮宿主(shell.overlay):只剩这一个席位 ────────────────────────────────
|
|
12286
12398
|
const McpOverlay = () => {
|
|
12287
12399
|
useI18nRevision(); // 宿主切语言后重渲染,模块级 getter 文案即时更新
|
|
12288
12400
|
const mode = useEntryMode();
|
|
12289
|
-
const
|
|
12401
|
+
const activePanelId = useActivePanelId();
|
|
12402
|
+
// 看板页打开时自动收起:悬浮按钮固定在中栏之上,会直接盖住列表与详情文字(issue #4)。
|
|
12403
|
+
const fabVisible = fabVisibleFor(mode, activePanelId);
|
|
12404
|
+
const canHideFab = entryModeWithoutFab(mode) !== mode;
|
|
12290
12405
|
const [pos, setPos] = useState(() => readFabPos() || defaultFabPos());
|
|
12406
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
12407
|
+
const [hideHint, setHideHint] = useState(false);
|
|
12291
12408
|
const fabRef = useRef(null);
|
|
12409
|
+
const menuRef = useRef(null);
|
|
12292
12410
|
const dragRef = useRef(null);
|
|
12293
12411
|
const suppressClickRef = useRef(false);
|
|
12412
|
+
const pressTimerRef = useRef(0);
|
|
12294
12413
|
|
|
12295
12414
|
// 窗口尺寸变化时把按钮拉回可视区,避免记忆的位置因分辨率变化而出界。
|
|
12296
|
-
//
|
|
12415
|
+
// 悬浮按钮不可见时不订阅:给一个看不见的按钮算位置只是白渲染。
|
|
12297
12416
|
useEffect(() => {
|
|
12298
|
-
if (!
|
|
12417
|
+
if (!fabVisible) return;
|
|
12299
12418
|
const onResize = () => setPos((current) => clampFabPos(current.x, current.y));
|
|
12300
12419
|
window.addEventListener('resize', onResize);
|
|
12301
12420
|
return () => window.removeEventListener('resize', onResize);
|
|
12302
|
-
}, [
|
|
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, []);
|
|
12303
12459
|
|
|
12304
12460
|
const handlePointerDown = (event) => {
|
|
12305
12461
|
if (event.button !== undefined && event.button !== 0) return;
|
|
@@ -12316,6 +12472,15 @@ const McpOverlay = () => {
|
|
|
12316
12472
|
moved: false,
|
|
12317
12473
|
};
|
|
12318
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
|
+
}
|
|
12319
12484
|
};
|
|
12320
12485
|
const handlePointerMove = (event) => {
|
|
12321
12486
|
const drag = dragRef.current;
|
|
@@ -12325,6 +12490,7 @@ const McpOverlay = () => {
|
|
|
12325
12490
|
const dy = event.clientY - drag.startClientY;
|
|
12326
12491
|
if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
12327
12492
|
drag.moved = true;
|
|
12493
|
+
cancelPressMenu();
|
|
12328
12494
|
drag.live = clampFabPos(drag.startX + dx, drag.startY + dy);
|
|
12329
12495
|
// 跟手:直接写 DOM,不触发 React 渲染;松手时才 setPos 提交。
|
|
12330
12496
|
el.style.left = drag.live.x + 'px';
|
|
@@ -12332,6 +12498,7 @@ const McpOverlay = () => {
|
|
|
12332
12498
|
el.style.cursor = 'grabbing';
|
|
12333
12499
|
};
|
|
12334
12500
|
const handlePointerEnd = () => {
|
|
12501
|
+
cancelPressMenu();
|
|
12335
12502
|
const drag = dragRef.current;
|
|
12336
12503
|
dragRef.current = null;
|
|
12337
12504
|
if (fabRef.current) fabRef.current.style.cursor = '';
|
|
@@ -12346,24 +12513,53 @@ const McpOverlay = () => {
|
|
|
12346
12513
|
};
|
|
12347
12514
|
const handleClick = () => {
|
|
12348
12515
|
if (suppressClickRef.current) { suppressClickRef.current = false; return; }
|
|
12516
|
+
// 菜单开着时,点按钮先把菜单收起(否则会同时打开看板 + 留下菜单)。
|
|
12517
|
+
if (menuOpen) { setMenuOpen(false); return; }
|
|
12349
12518
|
openBoardPanel();
|
|
12350
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
|
+
};
|
|
12351
12531
|
|
|
12352
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
|
+
};
|
|
12353
12546
|
|
|
12354
12547
|
return h(
|
|
12355
12548
|
React2.Fragment,
|
|
12356
12549
|
null,
|
|
12357
|
-
|
|
12550
|
+
fabVisible ? h(
|
|
12358
12551
|
'button',
|
|
12359
12552
|
{
|
|
12360
12553
|
ref: fabRef,
|
|
12361
12554
|
type: 'button',
|
|
12362
12555
|
className: 'dsh-mcp-fab',
|
|
12363
|
-
title: t('技能 · MCP ·
|
|
12556
|
+
title: t('技能 · MCP · 专家看板(拖拽移动,右键或长按隐藏)'),
|
|
12364
12557
|
'aria-label': t('打开技能 · MCP · 专家看板'),
|
|
12558
|
+
'aria-haspopup': 'menu',
|
|
12559
|
+
'aria-expanded': menuOpen,
|
|
12365
12560
|
style,
|
|
12366
12561
|
onClick: handleClick,
|
|
12562
|
+
onContextMenu: handleContextMenu,
|
|
12367
12563
|
onPointerDown: handlePointerDown,
|
|
12368
12564
|
onPointerMove: handlePointerMove,
|
|
12369
12565
|
onPointerUp: handlePointerEnd,
|
|
@@ -12371,6 +12567,30 @@ const McpOverlay = () => {
|
|
|
12371
12567
|
},
|
|
12372
12568
|
h(Icon, { d: ICONS.board, size: 22 }),
|
|
12373
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,
|
|
12374
12594
|
);
|
|
12375
12595
|
};
|
|
12376
12596
|
|
package/lib/wb-market.js
CHANGED
|
@@ -505,6 +505,33 @@ export async function wbConnectorCachedCount() {
|
|
|
505
505
|
// 详情:/api/v1/skills/:slug?namespace=<handle>(带 latestVersion、publisher、securityReports)
|
|
506
506
|
// 文件:/api/v1/skills/:slug/files?namespace=<handle>[&version=];/file 同形 + &path=
|
|
507
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
|
+
}
|
|
508
535
|
const SKILLHUB_SORTS = new Set(["downloads", "installs", "stars", "score"]);
|
|
509
536
|
// 2026-10 实测(api.skillhub.cn):`sortBy=downloads` 已不再是可用值——TCP 能连上、
|
|
510
537
|
// 但服务端不回任何字节,一直挂到客户端 AbortSignal 超时;installs / stars / score /
|
|
@@ -543,32 +570,39 @@ export async function wbSkillCatalog(payload = {}) {
|
|
|
543
570
|
if (category) params.set("category", category.slice(0, 64));
|
|
544
571
|
const body = await skillhubJson(`/api/skills?${params.toString()}`);
|
|
545
572
|
const skills = Array.isArray(body?.data?.skills) ? body.data.skills : [];
|
|
546
|
-
const rows = skills.map((skill) =>
|
|
547
|
-
slug
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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);
|
|
572
606
|
// 下载量排序上游已不可用,退化为「当前页内按下载量降序」:跨页顺序仍是上游默认序,
|
|
573
607
|
// 所以这是页内排序而不是全库排序(UI 侧「最多下载」的含义随之收窄,见下)。
|
|
574
608
|
if (!remoteSort) rows.sort((a, b) => b.downloads - a.downloads);
|
|
@@ -590,20 +624,32 @@ export async function wbSkillDetail(payload = {}) {
|
|
|
590
624
|
// 详情端点**没有** {code,data,message} 包装:latestVersion 等字段直接在根上
|
|
591
625
|
// (列表端点才有 data 包装)。两种形状都兼容,避免上游改包装方式时把面板打空。
|
|
592
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);
|
|
593
632
|
const reports = data.securityReports && typeof data.securityReports === "object" ? data.securityReports : {};
|
|
594
633
|
const reportStatus = Object.values(reports).map((report) => String(report?.status || "")).filter(Boolean);
|
|
595
634
|
return {
|
|
596
635
|
slug,
|
|
597
|
-
namespace,
|
|
598
|
-
name: String(data.name || slug),
|
|
636
|
+
namespace: handle,
|
|
637
|
+
name: String(skill.displayName || data.name || slug),
|
|
599
638
|
version: String(data.latestVersion?.version || ""),
|
|
600
639
|
changelog: String(data.latestVersion?.changelog || ""),
|
|
601
|
-
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 || ""),
|
|
602
643
|
publisher: String(data.publisher?.name || data.owner?.displayName || ""),
|
|
603
644
|
verifiedPublisher: !!data.publisher?.verified,
|
|
604
645
|
certifiedName: String(data.publisher?.certifiedName || ""),
|
|
605
646
|
security: reportStatus.length ? reportStatus : ["unknown"],
|
|
606
|
-
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 || ""),
|
|
607
653
|
};
|
|
608
654
|
}
|
|
609
655
|
export async function wbSkillFiles(payload = {}) {
|
package/lib/whats-new-en.js
CHANGED
|
@@ -434,6 +434,20 @@ export const WHATS_NEW_EN = {
|
|
|
434
434
|
where: 'Skill board',
|
|
435
435
|
},
|
|
436
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
|
+
],
|
|
437
451
|
};
|
|
438
452
|
|
|
439
453
|
/**
|
package/lib/whats-new.js
CHANGED
|
@@ -499,4 +499,21 @@ export const WHATS_NEW = [
|
|
|
499
499
|
},
|
|
500
500
|
],
|
|
501
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
|
+
},
|
|
502
519
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-armory",
|
|
3
|
-
"version": "2.4.
|
|
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",
|