dsh-toolbox-ui 1.6.3 → 1.6.5
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/LICENSE +1 -1
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/docs/installation.md +7 -7
- package/docs/skills.md +9 -1
- package/lib/client.js +42 -5
- package/lib/skills-file-ops.js +11 -2
- package/lib/skills-registry.js +159 -32
- package/lib/whats-new.js +34 -0
- package/package.json +2 -1
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026 dsh-
|
|
3
|
+
Copyright (c) 2026 dsh-toolbox-ui contributors
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
package/README.en.md
CHANGED
|
@@ -57,10 +57,10 @@ See every installed skill's **real scope** and cross-scope shadowing, enable/dis
|
|
|
57
57
|
|
|
58
58
|
```sh
|
|
59
59
|
# Recommended: from npm — `dsh plugin update` then upgrades within ^1.x
|
|
60
|
-
dsh plugin --profile web add dsh-toolbox-ui@^1.6.
|
|
60
|
+
dsh plugin --profile web add dsh-toolbox-ui@^1.6.5
|
|
61
61
|
|
|
62
62
|
# Or pin a GitHub release tag (no automatic upgrades; re-add with a new tag)
|
|
63
|
-
dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#v1.6.
|
|
63
|
+
dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#v1.6.5
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
Then **restart `dsh web`**. To uninstall (your existing MCP entries stay):
|
package/README.md
CHANGED
|
@@ -57,10 +57,10 @@
|
|
|
57
57
|
|
|
58
58
|
```sh
|
|
59
59
|
# 推荐:npm 安装 —— 之后 `dsh plugin update` 在 ^1.x 内自动升小版本
|
|
60
|
-
dsh plugin --profile web add dsh-toolbox-ui@^1.6.
|
|
60
|
+
dsh plugin --profile web add dsh-toolbox-ui@^1.6.5
|
|
61
61
|
|
|
62
62
|
# 或固定 GitHub release tag(不自动跨版本,升级要换 tag 重新 add)
|
|
63
|
-
dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#v1.6.
|
|
63
|
+
dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#v1.6.5
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
装完**重启 `dsh web`**。卸载(不会删除 profile 里已有的 MCP 条目):
|
package/docs/installation.md
CHANGED
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
推荐用 npm 安装:包里的 `^1.x` 范围让后续 `dsh plugin update` 能自动升级小版本。
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
dsh plugin --profile web add dsh-toolbox-ui@^1.6.
|
|
18
|
+
dsh plugin --profile web add dsh-toolbox-ui@^1.6.5
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
也可以固定 GitHub release tag(适合需要锁定某个具体版本时):
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
24
|
# 固定 release tag
|
|
25
|
-
dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#v1.6.
|
|
25
|
+
dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#v1.6.5
|
|
26
26
|
|
|
27
27
|
# 或固定某个 commit
|
|
28
28
|
dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#<commit>
|
|
@@ -73,7 +73,7 @@ dsh plugin --profile web update dsh-toolbox-ui
|
|
|
73
73
|
|
|
74
74
|
| 安装规格 | `update` 的效果 | 怎么跨版本 |
|
|
75
75
|
|---|---|---|
|
|
76
|
-
| `dsh-toolbox-ui@^1.6.
|
|
76
|
+
| `dsh-toolbox-ui@^1.6.5`(npm,推荐) | 在 `^1.x` 内自动升到最新小版本,不会跨到 2.0.0 | 已是自动;跨大版本显式安装 `@^2` |
|
|
77
77
|
| `github:...#v1.4.0`(tag) | 重新解析同一个 tag,**不会动** | 换成新 tag 重新 `add`:`... #<新 tag>` |
|
|
78
78
|
| `github:...#<commit>` | 同上,固定在 commit | 用新 commit/tag 重新 `add` |
|
|
79
79
|
| `github:...#main`(分支) | 拉取最新 main | 已是自动;但不推荐生产(无 release 门槛) |
|
|
@@ -113,7 +113,7 @@ dsh web # 打开面板点一遍
|
|
|
113
113
|
# 3) 提交并推 tag(CI 会校验 tag 与 version 一致,不一致就拒绝发布)
|
|
114
114
|
git add -A && git commit -m "..."
|
|
115
115
|
git push origin main
|
|
116
|
-
git tag -a v1.6.
|
|
116
|
+
git tag -a v1.6.5 -m "dsh-toolbox-ui 1.6.5" && git push origin v1.6.5
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
推 tag 后 CI 依次做:校验版本 → `pnpm install --frozen-lockfile` → `npm test` → `npm publish`(trusted publishing,无需 token)→ `gh release create`。
|
|
@@ -132,7 +132,7 @@ git tag -a v1.6.3 -m "dsh-toolbox-ui 1.6.3" && git push origin v1.6.3
|
|
|
132
132
|
|
|
133
133
|
```sh
|
|
134
134
|
npm publish # 会弹 2FA 挑战;若用 bypass-2FA 的 granular token 则非交互
|
|
135
|
-
gh release create v1.6.
|
|
135
|
+
gh release create v1.6.5 --title "dsh-toolbox-ui v1.6.5" --notes-file <notes>
|
|
136
136
|
```
|
|
137
137
|
|
|
138
138
|
`npm login` 的会话 token 只有 2 小时,超时需要重新登录;classic token 已被 npm 永久吊销,能非交互发布的只剩勾了 **Bypass 2FA** 的 granular access token——但它正在被 npm 收窄(已不能做账号/包管理类操作,且 2027-01 起将失去直接发布能力),所以长期方案是上面的 trusted publishing。
|
|
@@ -150,9 +150,9 @@ dsh plugin --profile web remove dsh-toolbox-ui
|
|
|
150
150
|
本地开发才使用目录安装:
|
|
151
151
|
|
|
152
152
|
```sh
|
|
153
|
-
cd C:\
|
|
153
|
+
cd C:\path\to\dsh-toolbox-ui
|
|
154
154
|
pnpm install
|
|
155
|
-
dsh plugin --profile web add C:\
|
|
155
|
+
dsh plugin --profile web add C:\path\to\dsh-toolbox-ui
|
|
156
156
|
```
|
|
157
157
|
|
|
158
158
|
修改 Host 代码后需要重启 `dsh web`;浏览器 bundle 是否热更新取决于当前 DSH 的 client HMR 状态。
|
package/docs/skills.md
CHANGED
|
@@ -39,7 +39,15 @@ MCP 面的市场走的是同一个模式的同一种形态(内联网格,不
|
|
|
39
39
|
| 500 | `<AGENTS_HOME>/skills` | 全局 |
|
|
40
40
|
| 600 | 内置(bundled) | **只读**,不可启停/删除 |
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
同名技能出现在多个作用域时,面板会标出**哪一份生效、哪一份被遮蔽**——被遮蔽的那份模型收不到,但你确实装了它。生效者按优先级数值**最小**者判定,与它在列表里的位置无关。⚠️ 边界:宿主按名字只报告**生效**的那一份,所以落选副本只有在以 `SKILL.md.disabled` 形态留下时才看得见;两份都启用时,落选那份目前不在面板数据里(要报它得自己扫各根,见 TODO row 21 旁边的同名副本扫描)。
|
|
43
|
+
|
|
44
|
+
### 同一个目录被两处指到时
|
|
45
|
+
|
|
46
|
+
优先级表里的两个位置**可能算出来是同一个目录**——最常见的是 `DSH_HOME` 就在工作区里:设 `DSH_HOME=D:\DSH\.dsh`、工作区是 `D:\DSH`(它自己是 git 根),那么项目级 `.dsh/skills` 与全局 `<DSH_HOME>/skills` 是同一个物理目录。
|
|
47
|
+
|
|
48
|
+
面板按**物理位置**去重:同一个文件只占一行,留优先级更前的那份(也就是模型实际收到的那份);安装目标的候选里同一个目录也只出现一次。反过来,**不同目录**里的同名技能一定各占一行——那是真实的跨作用域遮蔽,合并它会把你真装了的那份藏起来。
|
|
49
|
+
|
|
50
|
+
同理,技能目录本身是**符号链接**(把它们指向外部同步库是常见做法)时,面板照样能定位到它:宿主报的展示路径与真实路径不一致这件事已经被处理,不会退化成「位置未知」。
|
|
43
51
|
|
|
44
52
|
## 启用与停用
|
|
45
53
|
|
package/lib/client.js
CHANGED
|
@@ -782,12 +782,26 @@ const selectedSkillRow = (rows, key) => (Array.isArray(rows) ? rows.find((row) =
|
|
|
782
782
|
* DSH 按 rank 裁决同名技能谁生效,被遮蔽的那份模型收不到——但用户确实装了它。
|
|
783
783
|
* 不提示的话,用户会以为自己操作的是生效的那份(本仓库的独有价值之一)。
|
|
784
784
|
*
|
|
785
|
+
* ⚠️ 判据必须是 **rank**,不是数组位置。曾经取「排在最前的那个」当生效者,而那个前提是错的:
|
|
786
|
+
* 宿主 `list` 的输出按**名字**排序(dsh-skill 的 compareSkillSummary),面板自己又按
|
|
787
|
+
* 「全局在前、项目在后」拼接——而项目级 rank(100/200) 恰好**小于**用户级(400/500),
|
|
788
|
+
* 于是"最前的"正好是 rank 最大、也就是真正**落选**的那份,方向完全相反(真机报告 + 已复现)。
|
|
789
|
+
* 结果是面板指着生效的那份说「你被遮蔽了」,用户照它去操作,改的其实是模型收不到的那份。
|
|
790
|
+
*
|
|
791
|
+
* 同名里 rank 最小者胜;rank 相同(同一档的不同项目)时按输入顺序取先者——那种情况下
|
|
792
|
+
* 谁生效由宿主的 providerOrder 决定,面板拿不到,但两个不同项目里的同名技能互不影响,
|
|
793
|
+
* 标一份为胜者只是为了让提示有个主语,不会误导操作。
|
|
794
|
+
*
|
|
795
|
+
* 已停用的行不参与:它的文件是 `SKILL.md.disabled`,模型根本看不到它,说它"遮蔽/被遮蔽"
|
|
796
|
+
* 都是假的。它只该出现在诊断里(见 skillDiagnostics)。
|
|
797
|
+
*
|
|
785
798
|
* @param {object[]} rows
|
|
786
799
|
* @returns {object[]} 被遮蔽的行(附生效者信息)。
|
|
787
800
|
*/
|
|
788
801
|
function shadowedSkills(rows) {
|
|
789
802
|
const groups = new Map();
|
|
790
803
|
for (const row of Array.isArray(rows) ? rows : []) {
|
|
804
|
+
if (row?.enabled === false) continue;
|
|
791
805
|
const list = groups.get(row.name) || [];
|
|
792
806
|
list.push(row);
|
|
793
807
|
groups.set(row.name, list);
|
|
@@ -795,9 +809,12 @@ function shadowedSkills(rows) {
|
|
|
795
809
|
const shadowed = [];
|
|
796
810
|
for (const [, list] of groups) {
|
|
797
811
|
if (list.length < 2) continue;
|
|
798
|
-
//
|
|
799
|
-
const
|
|
800
|
-
for (const row of
|
|
812
|
+
// 生效者 = rank 最小者(同 rank 取先出现的)。不能改用位置,理由见上。
|
|
813
|
+
const winner = list.reduce((best, row) => (sourceRank(row.source) < sourceRank(best.source) ? row : best), list[0]);
|
|
814
|
+
for (const row of list) {
|
|
815
|
+
if (row === winner) continue;
|
|
816
|
+
shadowed.push({ ...row, shadowedBy: winner });
|
|
817
|
+
}
|
|
801
818
|
}
|
|
802
819
|
return shadowed;
|
|
803
820
|
}
|
|
@@ -4123,6 +4140,26 @@ const SkillGroupHeader = ({ group, collapsed, onToggleCollapse }) => h(
|
|
|
4123
4140
|
h('span', { className: 'dsh-skills-group-count' }, String(group.rows.length)),
|
|
4124
4141
|
);
|
|
4125
4142
|
|
|
4143
|
+
/**
|
|
4144
|
+
* 「调用权限未知」到底是哪种未知——给用户一句**说得对**的原因。
|
|
4145
|
+
*
|
|
4146
|
+
* 为什么不能一律写成「frontmatter 读不出来」:那是把两种不同的事说成了一种,而且真机报告
|
|
4147
|
+
* 证明它会把用户引向错误的方向。技能目录是符号链接时根定位不到(`root === undefined`),
|
|
4148
|
+
* 文件本身完全合法、宿主也照常加载,用户却看到「frontmatter 读不出来(宿主会跳过这条技能)」
|
|
4149
|
+
* ——一句凭空的断言,照它去查 frontmatter 只会白费功夫。
|
|
4150
|
+
*
|
|
4151
|
+
* 这里能分开的是两种(行上有 root 与否就是面板掌握的全部线索):
|
|
4152
|
+
* · 根没定位到 → 面板根本不知道去哪读这个文件(定位问题,不是文件问题);
|
|
4153
|
+
* · 有根却读不出权限 → 文件没了/搬走了/没权限,或 frontmatter 本身有问题。
|
|
4154
|
+
* 第二种没法再细分:读取失败的原因在宿主侧(`invocationOf` 只把结果压成 null),
|
|
4155
|
+
* 面板拿不到更细的话,就不编一个——正文页会把真正的失败原文摆出来。
|
|
4156
|
+
*/
|
|
4157
|
+
function invocationUnknownReason(row) {
|
|
4158
|
+
if (typeof row?.root !== 'string' || row.root === '')
|
|
4159
|
+
return '面板没能定位这条技能的目录(技能目录可能是符号链接),因此读不到它的调用权限——文件本身可能是好的';
|
|
4160
|
+
return 'SKILL.md 的 frontmatter 读不出来(宿主会跳过这条技能),无法判断它的调用权限';
|
|
4161
|
+
}
|
|
4162
|
+
|
|
4126
4163
|
/** 管理模式的右侧详情:元信息 + 正文 + 启停/删除(与 MCP 面同一张详情卡片)。 */
|
|
4127
4164
|
const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove, onModelInvocable, tab = 'overview', onTab }) => {
|
|
4128
4165
|
if (!row) return h('div', { className: 'dsh-mcp-detail' }, h('div', { className: 'dsh-mcp-d-empty' },
|
|
@@ -4165,7 +4202,7 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
|
|
|
4165
4202
|
h(Badge, { cls: disabled ? 's-off' : shadowedBy ? 's-warn' : 's-on' }, disabled ? '已停用' : shadowedBy ? '被遮蔽' : '已启用'),
|
|
4166
4203
|
invocationKnown
|
|
4167
4204
|
? h(Badge, { cls: row.modelInvocable ? null : 's-off' }, row.modelInvocable ? '模型可调用' : '模型不可调用')
|
|
4168
|
-
: h(Badge, { cls: 's-off', title:
|
|
4205
|
+
: h(Badge, { cls: 's-off', title: invocationUnknownReason(row) }, '调用权限未知'),
|
|
4169
4206
|
invocationKnown && !row.userInvocable ? h(Badge, { cls: 's-off' }, '用户不可调用') : null,
|
|
4170
4207
|
),
|
|
4171
4208
|
h('p', { className: 'dsh-mcp-d-desc' }, row.description || '(无说明)'),
|
|
@@ -4213,7 +4250,7 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
|
|
|
4213
4250
|
? '内置技能只读'
|
|
4214
4251
|
: invocationKnown
|
|
4215
4252
|
? '写入 SKILL.md 的 disable-model-invocation;宿主重新发现这条技能后才影响模型(重启 DSH 最稳妥)'
|
|
4216
|
-
:
|
|
4253
|
+
: invocationUnknownReason(row),
|
|
4217
4254
|
onChange: () => onModelInvocable(row, row.modelInvocable !== true),
|
|
4218
4255
|
}),
|
|
4219
4256
|
' ',
|
package/lib/skills-file-ops.js
CHANGED
|
@@ -131,6 +131,12 @@ export async function setSkillEnabled(target, enabled) {
|
|
|
131
131
|
* 宿主目录看不到它们(改名后按固定路径读不到即静默跳过),所以这是面板能展示
|
|
132
132
|
* 「已停用」并提供「启用」的唯一来源。
|
|
133
133
|
*
|
|
134
|
+
* ⚠️ 「是不是目录」必须用 `stat`(跟随链接),**不能**用 `readdir` 的 `Dirent.isDirectory()`:
|
|
135
|
+
* 技能目录是指向外部同步库的符号链接/junction 时,Dirent 报的是 `isSymbolicLink() === true`
|
|
136
|
+
* 而 `isDirectory() === false`(实测)。用 Dirent 会让这类条目掉进扁平分支、既不匹配 `.md.disabled`
|
|
137
|
+
* 又拿不到 `<dir>/SKILL.md.disabled` —— **链接技能一旦停用就从面板彻底消失,再也启用不回来**。
|
|
138
|
+
* 这与 `locateSkill` 处理的是同一个根因:判据要落在物理位置(跟随链接后的真相)上。
|
|
139
|
+
*
|
|
134
140
|
* @param {{ root: string, scope: 'global'|'project' }} input
|
|
135
141
|
* @returns {Promise<object[]>}
|
|
136
142
|
*/
|
|
@@ -145,7 +151,10 @@ export async function scanDisabledSkills(input) {
|
|
|
145
151
|
}
|
|
146
152
|
const rows = []
|
|
147
153
|
for (const entry of entries) {
|
|
148
|
-
if (entry.
|
|
154
|
+
if (typeof entry.name !== 'string') continue
|
|
155
|
+
// 跟随链接判目录(见函数头说明)。stat 失败(悬空链接)就当它不是目录,继续按扁平判。
|
|
156
|
+
const isDirectory = await stat(join(root, entry.name)).then((info) => info.isDirectory()).catch(() => false)
|
|
157
|
+
if (isDirectory) {
|
|
149
158
|
// 目录捆绑:<name>/SKILL.md.disabled
|
|
150
159
|
const name = entry.name
|
|
151
160
|
if (!SKILL_NAME.test(name)) continue
|
|
@@ -156,7 +165,7 @@ export async function scanDisabledSkills(input) {
|
|
|
156
165
|
continue
|
|
157
166
|
}
|
|
158
167
|
// 扁平:<name>.md.disabled
|
|
159
|
-
if (
|
|
168
|
+
if (entry.name.endsWith(`.md${DISABLED_SUFFIX}`)) {
|
|
160
169
|
const name = entry.name.slice(0, -`.md${DISABLED_SUFFIX}`.length)
|
|
161
170
|
if (!SKILL_NAME.test(name)) continue
|
|
162
171
|
rows.push({ name, scope, root, kind: 'flat', path: join(root, entry.name), disabled: true })
|
package/lib/skills-registry.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// 本层只读:不写盘、不做启停(那些在 lib/skills-file-ops.js)。
|
|
8
8
|
|
|
9
|
-
import { access, stat } from 'node:fs/promises'
|
|
9
|
+
import { access, realpath, stat } from 'node:fs/promises'
|
|
10
10
|
import { homedir } from 'node:os'
|
|
11
11
|
import { dirname, join, resolve } from 'node:path'
|
|
12
12
|
import { readSkillDocument, resolveSkillTarget, SkillNotFoundError } from './skills-file-ops.js'
|
|
@@ -23,21 +23,63 @@ function scopeOfSource(source) {
|
|
|
23
23
|
return PROJECT_SOURCES.has(source) ? 'project' : 'global'
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* 宿主六级 source 的 rank(与 provider 的 *_RANK 常量、docs/skills.md 的表一致)。
|
|
28
|
+
* 数值小者优先——这是宿主裁决同名技能的依据,面板的「谁生效」与去重都用它。
|
|
29
|
+
*/
|
|
30
|
+
const SKILL_SOURCE_RANK = {
|
|
31
|
+
'project-dsh': 100,
|
|
32
|
+
'project-agents': 200,
|
|
33
|
+
custom: 300,
|
|
34
|
+
'user-dsh': 400,
|
|
35
|
+
'user-agents': 500,
|
|
36
|
+
bundled: 600,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 统一分隔符后再比较:宿主给的是**本机原生**路径(Windows 上是反斜杠),而技能根也可能来自
|
|
41
|
+
* 用户配置的 customSkillDirs(两种分隔符都写得出来)。join() 不够用——它是平台相关的,
|
|
42
|
+
* 拿不到「另一种分隔符」的等价形式。只用于**比较与做键**,绝不用于喂给 fs。
|
|
43
|
+
*/
|
|
44
|
+
const toSlashes = (value) => String(value).replace(/\\/g, '/')
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 一个**目录**的物理身份(realpath,统一分隔符)。
|
|
48
|
+
*
|
|
49
|
+
* 目录不存在时退回字面值——这时它不可能与别的根重合(都不存在,谁也指不到谁),
|
|
50
|
+
* 而 realpath 会抛 ENOENT:既不能让它把整个列表读失败,也不该为它编一个值。
|
|
51
|
+
*/
|
|
52
|
+
async function physicalDirectory(path) {
|
|
53
|
+
return toSlashes(await realpath(path).catch(() => path))
|
|
54
|
+
}
|
|
55
|
+
|
|
26
56
|
/**
|
|
27
57
|
* 从宿主 summary 推出技能的**根目录**与**形态**。
|
|
28
58
|
*
|
|
29
59
|
* 为什么必须推而不是问:`ctx.skills` **不暴露 roots()**(实测公开面只有
|
|
30
60
|
* registerProvider/register/list/snapshot/get/collect/...;roots() 在 filesystem provider 内部)。
|
|
31
|
-
* 能用的线索只有 `resourceBase
|
|
61
|
+
* 能用的线索只有 `resourceBase`。
|
|
32
62
|
*
|
|
33
|
-
*
|
|
34
|
-
* 扁平文件:path = `<根>/<name>.md`, resourceBase.path = `<根>` → 根 = base
|
|
63
|
+
* 判别依据是宿主的 locator **构造规则**本身(dsh-skill-filesystem 的 discoverRoot):
|
|
35
64
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* 的根算成上一层(实测把 `~/.agents/skills` 算成了 `~/.agents`)。
|
|
65
|
+
* 目录捆绑:locator.path = `<技能目录>/SKILL.md`,locator.directory = `<技能目录>`
|
|
66
|
+
* 扁平文件:locator.path = `<根>/<name>.md`, locator.directory = `<根>`
|
|
39
67
|
*
|
|
40
|
-
*
|
|
68
|
+
* ⚠️ 关键:**形态由技能文件名决定**(`SKILL.md` vs `<name>.md`),而**根由 `resourceBase` 决定**。
|
|
69
|
+
* 不要去比较两个字段的路径——它们来自不同的路径口径,链接下必然分叉(真机缺陷,issue #5):
|
|
70
|
+
* `resourceBase.path` = `locator.directory` —— **展示路径**(配置原样,配置写链接就是链接)
|
|
71
|
+
* `path` = `fs.processPath(target)` —— **realpath**(fs-local 里就是 targetKey)
|
|
72
|
+
* 之前两版实现都栽在这里:先按字面比较,再改成「realpath 规范化后比较」——都还是**路径比较**,
|
|
73
|
+
* 于是「技能目录是链接」修好了,「**扁平技能的文件本身是链接**」照样失效(宿主自己的用例就钉着
|
|
74
|
+
* 这两种形状:skill-filesystem.spec.ts 的 linked-dir 与 linked-flat)。而符号链接指向外部同步库
|
|
75
|
+
* 正是本仓库用户的常见做法。
|
|
76
|
+
*
|
|
77
|
+
* 改名判据之后:文件名在两种口径下**完全一致**(realpath 不改变 basename),所以链接、别名、
|
|
78
|
+
* Windows 大小写规范化都不影响判别,一次覆盖全部形态,也不需要碰磁盘。
|
|
79
|
+
*
|
|
80
|
+
* 返回的 root 一律用**展示路径**(`resolveTargetReference` 要求它与 skillTargetsView 算出的
|
|
81
|
+
* 目标精确相等,返回 realpath 会让所有文件操作对不上)。
|
|
82
|
+
* 认不出形态就返回空:宁可不填,也不猜一个根——猜错会让启停/删除作用在错误的目录上。
|
|
41
83
|
*
|
|
42
84
|
* @param {object} summary - 宿主 SkillSummary。
|
|
43
85
|
* @returns {{ root?: string, kind?: 'bundle'|'flat' }}
|
|
@@ -47,18 +89,16 @@ function locateSkill(summary) {
|
|
|
47
89
|
const path = summary?.path
|
|
48
90
|
if (typeof base !== 'string' || base === '' || typeof path !== 'string' || path === '') return {}
|
|
49
91
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (dirname(pathSlashes) === baseSlashes) return { root: base, kind: 'flat' }
|
|
61
|
-
// 形态与两种已知 locator 都不符:不猜。
|
|
92
|
+
// 文件名取自宿主给的 realpath,只看最后一段(分隔符两种都要吃)。
|
|
93
|
+
const file = toSlashes(path).split('/').pop() ?? ''
|
|
94
|
+
|
|
95
|
+
// 扁平优先:`<根>/<name>.md`,用**技能名**核对文件名——这是精确判别式,
|
|
96
|
+
// 也是扁平技能的文件本身是链接时唯一还成立的那条线索。
|
|
97
|
+
if (file === `${summary.name}.md`) return { root: base, kind: 'flat' }
|
|
98
|
+
// 目录捆绑:`<技能目录>/SKILL.md`。目录名可能与技能名不同(宿主按 frontmatter 取名),
|
|
99
|
+
// 所以这里不看名字,只看那个固定文件名。
|
|
100
|
+
if (file === BUNDLE_FILE) return { root: dirname(base), kind: 'bundle' }
|
|
101
|
+
// 两种 locator 都不符:不猜。
|
|
62
102
|
return {}
|
|
63
103
|
}
|
|
64
104
|
|
|
@@ -205,6 +245,16 @@ async function queryScope(skills, options) {
|
|
|
205
245
|
* 进 cwd 分支,而用户根与 bundled 根的 push 在该分支之外无条件执行)。所以项目查询的
|
|
206
246
|
* 结果已经包含全局技能,直接 concat 会让每个全局技能在每个项目里各重复一份。
|
|
207
247
|
*
|
|
248
|
+
* ⚠️ 但「按 source 过滤」只挡住了**同一根被两种查询各报一次**这一种重复。还有第二种:
|
|
249
|
+
* 两个根算出来是**同一个物理目录**。真机形状(issue #5,2026-10-11 复现):`DSH_HOME=D:\DSH\.dsh`
|
|
250
|
+
* 而工作区 `D:\DSH` 本身是 git 根,于是 project-dsh 根 = `<DSH_HOME>/skills` = user-dsh 根;
|
|
251
|
+
* 实测同一个文件被列成 N+1 行(全局 1 份 + 每个工作区 1 份),名字相同 → 面板互判「被遮蔽」,
|
|
252
|
+
* 安装下拉里同一个目录也以两个身份各出现一次。
|
|
253
|
+
*
|
|
254
|
+
* 所以去重判据必须是**物理位置**,不是字符串也不只是 source:同一个文件只留一行,
|
|
255
|
+
* 留 rank 更前的那份(= 模型真正会收到的那份)。反过来,**不同文件**的同名技能必须保留
|
|
256
|
+
* 两行——那是真实的跨作用域遮蔽,压掉它等于把用户装的东西藏起来(看 docs/skills.md 的作用域表)。
|
|
257
|
+
*
|
|
208
258
|
* @param {object} ctx - 宿主上下文(或 { get } 替身)。
|
|
209
259
|
* @param {{ workspaces?: string[], signal?: AbortSignal }} options
|
|
210
260
|
* @returns {Promise<object[]>}
|
|
@@ -227,12 +277,66 @@ async function listSkillViews(ctx, options = {}) {
|
|
|
227
277
|
// 那份同名技能误当成重复而丢掉。
|
|
228
278
|
return rows.filter((row) => row.scope === 'project')
|
|
229
279
|
}))
|
|
230
|
-
return [...globalRows, ...projectGroups.flat()]
|
|
280
|
+
return dedupeByPhysicalLocation([...globalRows, ...projectGroups.flat()])
|
|
231
281
|
} finally {
|
|
232
282
|
await lease.dispose()
|
|
233
283
|
}
|
|
234
284
|
}
|
|
235
285
|
|
|
286
|
+
/**
|
|
287
|
+
* 同一个**物理文件**只留一行(rank 更前者胜)。
|
|
288
|
+
*
|
|
289
|
+
* 身份用行上的 `path`——那正是宿主给的 realpath(`fs.processPath`),所以链接与真实目录
|
|
290
|
+
* 会归一到同一个键。拿不到 path 的行(旧宿主/替身)退回它的字面身份:宁可少去重一次,
|
|
291
|
+
* 也不能把两行不同的技能误判成同一个。
|
|
292
|
+
*
|
|
293
|
+
* 为什么保留 rank 更前者:那一份才是宿主的胜出条目,也就是模型真正收到的那份;面板的
|
|
294
|
+
* 「谁生效」与文件操作都该指向它。落选的那一行是**同一个文件**(不是副本),留着只会让
|
|
295
|
+
* 用户看到两个都写着"已启用"、还互相说被遮蔽的行。
|
|
296
|
+
*
|
|
297
|
+
* @param {object[]} rows
|
|
298
|
+
* @returns {object[]} 去重后的行,保持输入顺序(全局在前、项目在后)。
|
|
299
|
+
*/
|
|
300
|
+
function dedupeByPhysicalLocation(rows) {
|
|
301
|
+
const winnerAt = new Map()
|
|
302
|
+
const result = []
|
|
303
|
+
for (const row of rows) {
|
|
304
|
+
const key = physicalKeyOf(row)
|
|
305
|
+
const seen = winnerAt.get(key)
|
|
306
|
+
if (seen === undefined) {
|
|
307
|
+
winnerAt.set(key, result.length)
|
|
308
|
+
result.push(row)
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
// 同一物理文件:rank 更前(数值更小)的胜出,替换掉先前那份但**留在原位**。
|
|
312
|
+
if (rankOfSource(row.source) < rankOfSource(result[seen].source)) result[seen] = row
|
|
313
|
+
}
|
|
314
|
+
return result
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 一行的**物理身份**:技能文件的真实路径(宿主给的 `path` 已经是 realpath)**+ 技能名**。
|
|
319
|
+
*
|
|
320
|
+
* 为什么必须带上名字:去重的意图是「同一个物理技能被两个根各报了一次」。而技能名来自
|
|
321
|
+
* 文件自己的 frontmatter——同一个文件只可能有一条技能,所以「同路径 + 同名」才是同一个技能。
|
|
322
|
+
* 只看路径会把「同路径但不同名」的两行也压掉,而那两行要么是替身数据不严谨,要么是
|
|
323
|
+
* 宿主行为出了别的问题;无论哪种,静默吞掉一行都比留着它更难查。
|
|
324
|
+
*
|
|
325
|
+
* 拿不到 path 时退回**行的完整身份**(source + cwd + name,与 skillRowKey 同一套):
|
|
326
|
+
* 那种情况下物理位置无从得知,而「不同项目里的同名技能」必须各占一行(它们真的是两份)。
|
|
327
|
+
*/
|
|
328
|
+
function physicalKeyOf(row) {
|
|
329
|
+
if (typeof row?.path === 'string' && row.path !== '') {
|
|
330
|
+
return `path\u0000${toSlashes(row.path)}\u0000${row?.name ?? ''}`
|
|
331
|
+
}
|
|
332
|
+
return `row\u0000${row?.source ?? ''}\u0000${row?.cwd ?? ''}\u0000${row?.name ?? ''}`
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** source → 宿主 rank(数值小者优先);表外的来源排在最后。 */
|
|
336
|
+
function rankOfSource(source) {
|
|
337
|
+
return SKILL_SOURCE_RANK[source] ?? Number.MAX_SAFE_INTEGER
|
|
338
|
+
}
|
|
339
|
+
|
|
236
340
|
/**
|
|
237
341
|
* 加载单个技能正文的旧路径:**已被 detail 的按根读取取代**(2026-10-10)。
|
|
238
342
|
*
|
|
@@ -259,15 +363,6 @@ async function listSkillViews(ctx, options = {}) {
|
|
|
259
363
|
// 3) `findProjectRoot` 照抄 provider 的实现:往上找含 `.git` 的目录,找不到就用原路径。
|
|
260
364
|
// ⚠️ provider 改算法时这里要一起改;语义由 test/skill-targets.test.mjs 钉住。
|
|
261
365
|
|
|
262
|
-
/** 宿主的 source 档位与 rank(与 provider 的 *_RANK 常量、docs/skills.md 的表一致)。 */
|
|
263
|
-
const SKILL_SOURCE_RANK = {
|
|
264
|
-
'project-dsh': 100,
|
|
265
|
-
'project-agents': 200,
|
|
266
|
-
custom: 300,
|
|
267
|
-
'user-dsh': 400,
|
|
268
|
-
'user-agents': 500,
|
|
269
|
-
bundled: 600,
|
|
270
|
-
}
|
|
271
366
|
const DSH_HOME_ENV = 'DSH_HOME'
|
|
272
367
|
const AGENTS_HOME_ENV = 'DSH_AGENTS_HOME'
|
|
273
368
|
const BUNDLED_SKILL_DIR_ENV = 'DSH_BUNDLED_SKILL_DIR'
|
|
@@ -390,8 +485,40 @@ async function skillTargetsView(ctx, options = {}) {
|
|
|
390
485
|
}
|
|
391
486
|
if (typeof bundledDir === 'string' && bundledDir !== '') await push('bundled', resolve(bundledDir))
|
|
392
487
|
|
|
393
|
-
|
|
394
|
-
|
|
488
|
+
// 同一个**物理目录**只列一次:DSH_HOME 与工作区重合时,project-dsh 与 user-dsh 算出来是
|
|
489
|
+
// 同一个目录(真机形状见 listSkillViews)。留着会让安装下拉里同一个位置出现两次,
|
|
490
|
+
// 用户以为有两个可选的落点。
|
|
491
|
+
//
|
|
492
|
+
// ⚠️ 在**排序之前**去重,并且留 rank 更前的那份:rank 决定宿主优先采纳哪个根,
|
|
493
|
+
// 而 `targets[0]` 正是安装弹窗的默认选项(见客户端的 installTargets)——留错那份会让
|
|
494
|
+
// 默认落点指向一个"名字对了、身份不对"的位置。
|
|
495
|
+
const unique = await dedupeTargetsByPhysicalLocation(targets)
|
|
496
|
+
unique.sort((left, right) => left.rank - right.rank || left.root.localeCompare(right.root))
|
|
497
|
+
return { targets: unique }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* 同一个物理目录的目标只留一个(rank 更前者胜)。
|
|
502
|
+
*
|
|
503
|
+
* 为什么单独一步而不是并进 push:`exists` 是**观察**(要问磁盘),去重是**身份**判断,
|
|
504
|
+
* 混在一处会让「先问盘再决定收不收」这种顺序依赖悄悄长出来。
|
|
505
|
+
*/
|
|
506
|
+
async function dedupeTargetsByPhysicalLocation(targets) {
|
|
507
|
+
const seenPhysical = new Map()
|
|
508
|
+
const result = []
|
|
509
|
+
for (const target of targets) {
|
|
510
|
+
const physical = await physicalDirectory(target.root)
|
|
511
|
+
const previous = seenPhysical.get(physical)
|
|
512
|
+
if (previous === undefined) {
|
|
513
|
+
seenPhysical.set(physical, result.length)
|
|
514
|
+
result.push(target)
|
|
515
|
+
continue
|
|
516
|
+
}
|
|
517
|
+
// 同一物理目录:rank 更前(数值更小)的胜出,就地替换先前那份(索引不变,
|
|
518
|
+
// 顺序由后面的排序定)。
|
|
519
|
+
if (target.rank < result[previous].rank) result[previous] = target
|
|
520
|
+
}
|
|
521
|
+
return result
|
|
395
522
|
}
|
|
396
523
|
|
|
397
524
|
/**
|
package/lib/whats-new.js
CHANGED
|
@@ -210,4 +210,38 @@ export const WHATS_NEW = [
|
|
|
210
210
|
},
|
|
211
211
|
],
|
|
212
212
|
},
|
|
213
|
+
{
|
|
214
|
+
version: '1.6.4',
|
|
215
|
+
items: [
|
|
216
|
+
{
|
|
217
|
+
text: '**技能目录是符号链接时不再「整行不可用」**:这种情况下面板以前显示「位置(未报告)/调用权限未知」,点开还读不出正文——悬浮提示把它说成 frontmatter 有问题,其实是面板没能定位到那个目录(文件一直是好的)。现在链接目录照常定位,位置与调用权限都正常显示,读正文与「模型自动调用」开关也可用。⚠️ 同一形态下的**停用/删除**当时还没修好,见 1.6.5',
|
|
218
|
+
where: '技能列表与详情',
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
text: '**同一个目录被两处指到时不再重复列出**:当 `DSH_HOME` 就在工作区里(例如 `DSH_HOME=D:\\DSH\\.dsh` 而工作区是 `D:\\DSH`),项目级与全局的技能目录其实是同一个。以前同一个技能会被列成好几行、还互相标「被遮蔽」,安装位置里同一个目录也出现两次。现在同一个文件只占一行(留优先级更高的那份,也就是模型实际收到的那份);**不同目录**里的同名技能仍然各占一行——那是真实的遮蔽,不是重复',
|
|
222
|
+
where: '技能列表、安装到',
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
text: '**「哪一份生效」以前说反了**:同名技能里,面板指着的其实是**模型收不到**的那份,于是你可能照着提示去操作了另一份。现在按 DSH 的优先级如实判定(项目级优先于全局),并说明生效的那份在哪',
|
|
226
|
+
where: '技能列表的「被遮蔽」徽章与诊断页',
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
version: '1.6.5',
|
|
232
|
+
items: [
|
|
233
|
+
{
|
|
234
|
+
text: '**修好「停用一条技能后它就从面板消失」**:技能目录是指向外部同步库的符号链接时,停用后那条技能既不在「已启用」也不在「已停用」里——磁盘上其实改名成功了,但面板再也看不到它、也点不到「启用」把它恢复。现在停用后照常列在「已停用」里,一键就能启用回来',
|
|
235
|
+
where: '技能列表(停用/启用)',
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
text: '**链接技能的「删除」不再报错**:同样的链接形态下,删除以前会失败并报一个目标引用错误,用户以为技能删不掉。现在删除正常工作,而且**只删掉那个链接**——技能本体在外部同步库里的内容一个字都不会动',
|
|
239
|
+
where: '技能详情的「删除技能」',
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
text: '**补齐「扁平技能的文件本身是链接」这种形态**:技能可以是 `<名字>.md` 单文件(而不是目录),这个文件同样可能是指向外部库的链接;上一版只修好了「目录是链接」,这一种仍会定位不到。现在四种形态(目录/单文件 × 普通/链接)都能正确定位,位置、调用权限、启停、删除、读正文全部可用',
|
|
243
|
+
where: '技能列表与详情',
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
213
247
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-toolbox-ui",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.5",
|
|
4
4
|
"description": "Responsive MCP management UI and skills manager for DeepSeek Harness Web — floating panel, JSON import, profile-backed Host Remote management, and a skill market with a hard license gate.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"@deepseek-ai/dsh-mcp-client": "^0.2.0-rc.2",
|
|
57
57
|
"@deepseek-ai/dsh-scope": "^0.2.0-rc.2",
|
|
58
58
|
"@deepseek-ai/dsh-skill": "^0.2.0-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-skill-filesystem": "0.2.0-rc.2",
|
|
59
60
|
"@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
|
|
60
61
|
"@deepseek-ai/dsh-typert-loader": "^0.2.0-rc.2",
|
|
61
62
|
"@deepseek-ai/dsh-typert-protocol": "^0.2.0-rc.2"
|