dsh-skill-tag 0.1.4 → 0.1.6
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.md +19 -9
- package/lib/client.js +46 -6
- package/package.json +3 -4
- package/src/storage/domain-spec.js +73 -0
- package/src/storage/schema.js +7 -4
package/README.md
CHANGED
|
@@ -57,6 +57,8 @@ office * plugin # 只要 office 包带来的、且是插件相关
|
|
|
57
57
|
<img src="https://raw.githubusercontent.com/aawef/dsh-skill-manager-tags/main/images/skill-to-tags.png" width="640" alt="skill 联系 tag:左列技能、右列标签勾选">
|
|
58
58
|
|
|
59
59
|
左列 `技能 (105)` 每行标着它有几个标签;右边勾选**即时保存**,不用点确认。
|
|
60
|
+
右列会把该技能**已有的标签排在列表最前面**(并保持它们原来的相对顺序),所以不用滚动
|
|
61
|
+
去列表里找;顶部搜索框过滤之后,"已拥有优先"依然成立。
|
|
60
62
|
这是配条件前最常开的一页:先看清楚"这个技能到底被哪些标签命中"。
|
|
61
63
|
|
|
62
64
|
### 图 5 · 工作区「管理标签」:左写条件,右就是下发名单
|
|
@@ -65,7 +67,8 @@ office * plugin # 只要 office 包带来的、且是插件相关
|
|
|
65
67
|
|
|
66
68
|
左列是标签条件,下面实时显示**解析结果**和状态「有条件筛选(严格子集)」;
|
|
67
69
|
右列 `技能 (54)` 就是该工作区下发给模型的全部技能,每行可以「查看 tag」「禁用」,
|
|
68
|
-
📁
|
|
70
|
+
📁 直接打开技能所在文件夹。列表顶部的**搜索框**只过滤显示(标题会变成 `技能 (匹配/总数)`),
|
|
71
|
+
不改动实际下发的技能集合。底部还能「+ 精细化添加技能」「查看被禁用的技能」。
|
|
69
72
|
|
|
70
73
|
### 图 6 · 对话「管理标签(对话)」:可以有自己的一份
|
|
71
74
|
|
|
@@ -110,15 +113,22 @@ dsh plugin --profile <your-profile> add github:aawef/dsh-skill-manager-tags
|
|
|
110
113
|
|
|
111
114
|
> 依赖 Node ≥ 20;需要 DSH `0.2.0-rc.1` 及以上(见 `engines.dsh`)。
|
|
112
115
|
>
|
|
113
|
-
> 宿主的 `@deepseek-ai/cordis`
|
|
114
|
-
>
|
|
115
|
-
>
|
|
116
|
-
>
|
|
116
|
+
> 宿主的 `@deepseek-ai/cordis` 声明为 **peerDependency**,由 DSH 本体提供,**不要自己安装**。
|
|
117
|
+
>
|
|
118
|
+
> 存储域 spec 由本插件 `src/storage/domain-spec.js` 本地构造(等价于宿主的 `defineDomain` /
|
|
119
|
+
> `domainTable`),运行时通过 `ctx.storageDomain.open()` 交给宿主打开,**不 import 任何宿主内核包**。
|
|
120
|
+
> 这样 profile 里不会出现与宿主同名的第二份模块副本:早期版本曾 `import`
|
|
121
|
+
> `@deepseek-ai/dsh-storage-domain`,宿主升级(如 `0.2.1-rc.2` → `rc.3`)时那份旧副本会被判定
|
|
122
|
+
> 不兼容并拒绝加载,导致存储域整体不启动、会话与工作区列表全空(详见 CHANGELOG 2026-10-24)。
|
|
117
123
|
|
|
118
124
|
### 本地开发式安装
|
|
119
125
|
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
运行时加载的是 profile 里 `node_modules/<插件名>` 那一份,不是你的源码目录。两种接法:
|
|
127
|
+
|
|
128
|
+
- **junction(推荐)**:让 `~/.dsh/profiles/web/my-plugins/<插件名>` 指向你的源码目录,
|
|
129
|
+
pnpm 会把它硬链接进 `node_modules` —— 三个路径是**同一个 inode、同一份文件**,
|
|
130
|
+
改完内容立刻生效,不需要复制;只有**新增/删除文件**才要在 profile 里重跑一次 `pnpm install`。
|
|
131
|
+
- **物理拷贝**:如果当初是手动 `cp` 进去的,那就是两份独立文件,改完必须再 `cp` 一次并校验哈希:
|
|
122
132
|
|
|
123
133
|
```bash
|
|
124
134
|
S=/path/to/your/dsh-skill-tag
|
|
@@ -249,7 +259,7 @@ src/storage/ storage domain schema + repository
|
|
|
249
259
|
src/reveal.js 拉起系统文件管理器
|
|
250
260
|
skills/tagging-skills/ 随插件注册给宿主的一个 skill
|
|
251
261
|
tools/auto-tag.mjs 一键打标签脚本(dry-run 默认)
|
|
252
|
-
tests/ node:test,
|
|
262
|
+
tests/ node:test,194 项
|
|
253
263
|
docs/ 设计文档(见下)
|
|
254
264
|
images/ README 用的界面截图(不进 npm 包)
|
|
255
265
|
```
|
|
@@ -271,7 +281,7 @@ images/ README 用的界面截图(不进 npm 包)
|
|
|
271
281
|
|
|
272
282
|
```bash
|
|
273
283
|
npm install
|
|
274
|
-
npm test #
|
|
284
|
+
npm test # 194 项
|
|
275
285
|
```
|
|
276
286
|
|
|
277
287
|
测试分两类,都在 `tests/` 下:
|
package/lib/client.js
CHANGED
|
@@ -103,6 +103,20 @@ window.__ModuleLoader__.load({
|
|
|
103
103
|
return out;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* 「技能已有的标签排前面」:稳定分区,不改变各分区内部的原有顺序。
|
|
108
|
+
* 只排显示顺序——勾选状态与写库内容都不受影响。
|
|
109
|
+
* @param {Array<{tagId:string}>} tags 全部标签(顺序来自服务端)
|
|
110
|
+
* @param {Iterable<string>} ownedIds 该技能已拥有的 tagId
|
|
111
|
+
*/
|
|
112
|
+
function ownedTagsFirst(tags, ownedIds) {
|
|
113
|
+
const owned = new Set(ownedIds ?? []);
|
|
114
|
+
const yes = [];
|
|
115
|
+
const no = [];
|
|
116
|
+
for (const t of tags ?? []) (owned.has(t?.tagId) ? yes : no).push(t);
|
|
117
|
+
return [...yes, ...no];
|
|
118
|
+
}
|
|
119
|
+
|
|
106
120
|
// ── 「打开技能所在文件夹」 ────────────────────────────────────
|
|
107
121
|
// 宿主没有给插件 reveal API,服务端由 src/reveal.js 拉起文件管理器。
|
|
108
122
|
// 这里只负责:名字 → 绝对路径(扫描结果自带 path)、发请求、缺路径时
|
|
@@ -515,6 +529,9 @@ window.__ModuleLoader__.load({
|
|
|
515
529
|
const ownedIds = selName ? (links.find((l) => l.skillId === selName)?.tagIds ?? []) : [];
|
|
516
530
|
const owned = new Set(ownedIds);
|
|
517
531
|
const tagMatch = (t) => !state.skillTagSearch.trim() || t.name.toLowerCase().includes(state.skillTagSearch.trim().toLowerCase());
|
|
532
|
+
// 已拥有的标签排在前面(不再是纯粹按服务端返回的字母序),便于确认"这个技能到底挂了什么"
|
|
533
|
+
const tagsOrdered = ownedTagsFirst(tags, ownedIds);
|
|
534
|
+
const tagsShown = tagsOrdered.filter(tagMatch);
|
|
518
535
|
|
|
519
536
|
/** 整组覆盖该技能的标签集合(勾选/取消都走这里,立即落库) */
|
|
520
537
|
const saveTags = async (nextIds) => {
|
|
@@ -576,9 +593,9 @@ window.__ModuleLoader__.load({
|
|
|
576
593
|
}),
|
|
577
594
|
react.createElement("div", { key: "rows", style: { maxHeight: "360px", overflowY: "auto" } }, [
|
|
578
595
|
tags.length === 0 && react.createElement("div", { key: "e", style: { color: "#888", fontSize: "12px", padding: "6px 2px" } }, "还没有任何标签,请先在主页面新建"),
|
|
579
|
-
|
|
596
|
+
tagsShown.length === 0 && tags.length > 0 &&
|
|
580
597
|
react.createElement("div", { key: "e2", style: { color: "#888", fontSize: "12px", padding: "6px 2px" } }, "无匹配标签"),
|
|
581
|
-
...
|
|
598
|
+
...tagsShown.map((t) => react.createElement("label", { key: t.tagId, style: { ...rowStyle, cursor: "pointer" } }, [
|
|
582
599
|
react.createElement("input", {
|
|
583
600
|
key: "c", type: "checkbox", checked: owned.has(t.tagId), disabled: busy,
|
|
584
601
|
onChange: () => toggleTag(t.tagId),
|
|
@@ -1126,6 +1143,8 @@ window.__ModuleLoader__.load({
|
|
|
1126
1143
|
const [pickOpen, setPickOpen] = react.useState(false);
|
|
1127
1144
|
const [disabledOpen, setDisabledOpen] = react.useState(false);
|
|
1128
1145
|
const [tagOfSkill, setTagOfSkill] = react.useState(null);
|
|
1146
|
+
/** 右列技能搜索词(对话版 / 工作区版共用;只影响列表显示,不影响下发结果) */
|
|
1147
|
+
const [skillSearch, setSkillSearch] = react.useState("");
|
|
1129
1148
|
|
|
1130
1149
|
const load = react.useCallback(async () => {
|
|
1131
1150
|
try {
|
|
@@ -1190,6 +1209,10 @@ window.__ModuleLoader__.load({
|
|
|
1190
1209
|
/** 可添加的技能:排除已有(可见)与被禁用的 */
|
|
1191
1210
|
const addable = (ws?.universe ?? []).filter((n) => !visibleSet.has(n) && !disabledSet.has(n));
|
|
1192
1211
|
|
|
1212
|
+
// 右列搜索:仅过滤显示,不影响「技能 (总)」这个语义数字
|
|
1213
|
+
const skillQuery = skillSearch.trim().toLowerCase();
|
|
1214
|
+
const shownSkills = (view.visible ?? []).filter((n) => !skillQuery || String(n).toLowerCase().includes(skillQuery));
|
|
1215
|
+
|
|
1193
1216
|
// 技能名 → SKILL.md 路径(服务端把扫描结果的 path 一并带回来了)
|
|
1194
1217
|
const catalog = ws?.catalog ?? [];
|
|
1195
1218
|
const pathOf = (name) => catalog.find((s) => s.name === name)?.path ?? null;
|
|
@@ -1236,11 +1259,27 @@ window.__ModuleLoader__.load({
|
|
|
1236
1259
|
|
|
1237
1260
|
// ── 右列:技能 ──
|
|
1238
1261
|
react.createElement("div", { key: "right", style: colStyle }, [
|
|
1239
|
-
react.createElement("div", { key: "h", style: { fontWeight: 600, marginBottom: "8px" } },
|
|
1262
|
+
react.createElement("div", { key: "h", style: { fontWeight: 600, marginBottom: "8px" } },
|
|
1263
|
+
skillQuery
|
|
1264
|
+
? `技能 (${shownSkills.length}/${(view.visible ?? []).length})`
|
|
1265
|
+
: `技能 (${(view.visible ?? []).length})`),
|
|
1266
|
+
// 搜索框:放列表顶部,只过滤显示(不改变实际下发的技能集合)
|
|
1267
|
+
react.createElement("div", { key: "search", style: { display: "flex", gap: "6px", alignItems: "center", marginBottom: "6px" } }, [
|
|
1268
|
+
react.createElement("input", {
|
|
1269
|
+
key: "i", style: { ...input, flex: 1, minWidth: 0 }, type: "search", placeholder: "搜索技能…",
|
|
1270
|
+
value: skillSearch, onChange: (e) => setSkillSearch(e.target.value),
|
|
1271
|
+
}),
|
|
1272
|
+
skillSearch.trim() !== "" && react.createElement("button", {
|
|
1273
|
+
key: "c", style: { ...btn, padding: "1px 7px", fontSize: "11px" },
|
|
1274
|
+
onClick: () => setSkillSearch(""),
|
|
1275
|
+
}, "清除"),
|
|
1276
|
+
]),
|
|
1240
1277
|
react.createElement("div", { key: "list", style: { flex: 1, overflowY: "auto", minHeight: "80px" } }, [
|
|
1241
1278
|
(view.visible ?? []).length === 0 && react.createElement("div", { key: "e", style: { color: "#888", fontSize: "12px" } },
|
|
1242
1279
|
"没有技能。可在下方「+ 精细化添加技能」手动加入,或清空标签条件回到全部技能。"),
|
|
1243
|
-
|
|
1280
|
+
(view.visible ?? []).length > 0 && shownSkills.length === 0 && react.createElement("div", { key: "nf", style: { color: "#888", fontSize: "12px" } },
|
|
1281
|
+
`没有匹配「${skillSearch.trim()}」的技能。`),
|
|
1282
|
+
...shownSkills.map((name) => react.createElement("div", { key: name, style: { ...listRow, flexWrap: "wrap" } }, [
|
|
1244
1283
|
react.createElement("span", { key: "n", style: { flex: 1 } }, name),
|
|
1245
1284
|
manualSet.has(name) && react.createElement("span", { key: "m", style: { color: "#3498db", fontSize: "11px" } }, "手动"),
|
|
1246
1285
|
react.createElement(RevealButton, { key: "rev", skillName: name, path: pathOf(name), label: "📁" }),
|
|
@@ -1266,7 +1305,8 @@ window.__ModuleLoader__.load({
|
|
|
1266
1305
|
};
|
|
1267
1306
|
|
|
1268
1307
|
return react.createElement("div", { style: overlayStyle, onMouseDown: (e) => { if (e.target === e.currentTarget) onClose(); } },
|
|
1269
|
-
|
|
1308
|
+
// 固定高度:搜索让右列行数变少时窗口不能跟着缩(否则每敲一个字母窗口就抖一下)
|
|
1309
|
+
react.createElement("div", { style: { ...modalBox, height: "82vh" }, onClick: (e) => e.stopPropagation() }, [
|
|
1270
1310
|
react.createElement("div", { key: "head", style: { display: "flex", alignItems: "center", gap: "10px", marginBottom: "10px" } }, [
|
|
1271
1311
|
react.createElement("strong", { key: "t", style: { fontSize: "14px" } }, scope.title),
|
|
1272
1312
|
react.createElement("span", { key: "w", style: { color: "#888", fontSize: "11px", ...monoStyle } }, scope.id),
|
|
@@ -1640,7 +1680,7 @@ window.__ModuleLoader__.load({
|
|
|
1640
1680
|
skillsLinkedTo, skillsNotLinkedTo, ZONE_LABEL,
|
|
1641
1681
|
workspaceIdOfRowKey, isWorkspaceMenu, manage, manageSet,
|
|
1642
1682
|
manageScope, registerComposerEntry,
|
|
1643
|
-
skillPathOf, RevealButton,
|
|
1683
|
+
skillPathOf, RevealButton, ownedTagsFirst,
|
|
1644
1684
|
};
|
|
1645
1685
|
return module.exports;
|
|
1646
1686
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skill-tag",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "给 DSH 的技能(skill)打标签,用一条布尔表达式在「工作区 / 对话」两级选出要下发给模型的技能子集",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -52,14 +52,13 @@
|
|
|
52
52
|
"test:unit": "node --test \"tests/*.test.mjs\""
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"@deepseek-ai/cordis": ">=4.0.2 <5"
|
|
56
|
-
"@deepseek-ai/dsh-storage-domain": "0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-rc.1 || 0.2.1-rc.2 || >=0.3.0 <1.0.0"
|
|
55
|
+
"@deepseek-ai/cordis": ">=4.0.2 <5"
|
|
57
56
|
},
|
|
58
57
|
"dependencies": {
|
|
59
58
|
"zod": "^3.25.76"
|
|
60
59
|
},
|
|
61
60
|
"engines": {
|
|
62
61
|
"node": ">=20",
|
|
63
|
-
"dsh": "0.2.0-rc.1
|
|
62
|
+
"dsh": ">=0.2.0-rc.1 <1.0.0"
|
|
64
63
|
}
|
|
65
64
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// skill-tag-plugin — 本地 domain spec 构造器
|
|
2
|
+
//
|
|
3
|
+
// 为什么不用 @deepseek-ai/dsh-storage-domain:
|
|
4
|
+
//
|
|
5
|
+
// 宿主已经通过 ctx.storageDomain.open(spec) 把 domain 能力注入进来了,插件真正
|
|
6
|
+
// 需要的只是把 spec 构造宿主认识的形状:
|
|
7
|
+
//
|
|
8
|
+
// { name, version, tables: { <tableName>: { valueSchema } }, global?, layout?, ... }
|
|
9
|
+
//
|
|
10
|
+
// 而宿主的 defineDomain 只做「校验 + 原样返回」,domainTable 只做
|
|
11
|
+
// `{ valueSchema: schema }`。为了这两个纯函数去 import 内核包,会让 pnpm 必须
|
|
12
|
+
// 在 profile 里再装一份 @deepseek-ai/dsh-storage-domain 实体,与宿主内置的同名
|
|
13
|
+
// 模块形成「同一模块两份不同版本」——宿主每次升级(rc.2 → rc.3)都会因为旧副本
|
|
14
|
+
// 被运行时判定不兼容而拒绝加载该插件,导致存储域整体不启动、会话与工作区列表
|
|
15
|
+
// 全空。该故障在 0.2.0-rc.2 时代已经发生过一次(见 profiles/desktop/
|
|
16
|
+
// pnpm-workspace.yaml 的 historical 注释),当时的做法是挪版本号 + 人工提醒,
|
|
17
|
+
// 属于在地基上缝补。
|
|
18
|
+
//
|
|
19
|
+
// 现在改为本地等价实现,插件对宿主内核包零 import 依赖:升级宿主不再需要同步
|
|
20
|
+
// 任何版本号,也不再有双份模块。
|
|
21
|
+
//
|
|
22
|
+
// 校验规则与宿主保持一致(宿主 @deepseek-ai/dsh-storage 的 UNIT_NAME_RE 为
|
|
23
|
+
// /^[a-z][a-z0-9_]*$/;domain 名与表名都必须匹配)。宿主在 open() 时仍会二次
|
|
24
|
+
// 校验,这里的校验只是让配置错误在模块加载时就暴露。
|
|
25
|
+
|
|
26
|
+
const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 声明一张表:宿主只要求 { valueSchema } 这一层包装。
|
|
30
|
+
* @param {import("zod").ZodTypeAny} schema - 行记录的 zod schema
|
|
31
|
+
*/
|
|
32
|
+
export function domainTable(schema) {
|
|
33
|
+
return { valueSchema: schema };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 声明一个 domain:校验后原样返回 spec(与宿主 defineDomain 行为一致)。
|
|
38
|
+
* @param {object} spec - { name, version, tables, global?, layout?, compatibleVersions?, invalidRecords? }
|
|
39
|
+
*/
|
|
40
|
+
export function defineDomain(spec) {
|
|
41
|
+
if (!UNIT_NAME_RE.test(spec.name)) {
|
|
42
|
+
throw new Error(`domain name '${spec.name}' must match ${UNIT_NAME_RE}`);
|
|
43
|
+
}
|
|
44
|
+
if (!Number.isInteger(spec.version) || spec.version < 0) {
|
|
45
|
+
throw new Error(`domain '${spec.name}' version must be a non-negative integer, got ${spec.version}`);
|
|
46
|
+
}
|
|
47
|
+
for (const compat of spec.compatibleVersions ?? []) {
|
|
48
|
+
if (!Number.isInteger(compat) || compat < 0 || compat >= spec.version) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`domain '${spec.name}' compatibleVersions entries must be non-negative integers below version ${spec.version}, got ${compat}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (spec.layout !== void 0 && spec.layout !== "single" && spec.layout !== "per-record") {
|
|
55
|
+
throw new Error(`domain '${spec.name}' layout must be 'single' or 'per-record', got ${spec.layout}`);
|
|
56
|
+
}
|
|
57
|
+
if (spec.invalidRecords !== void 0 && spec.invalidRecords !== "backup-and-skip") {
|
|
58
|
+
throw new Error(`domain '${spec.name}' invalidRecords must be 'backup-and-skip' when present, got ${spec.invalidRecords}`);
|
|
59
|
+
}
|
|
60
|
+
for (const table of Object.keys(spec.tables)) {
|
|
61
|
+
if (!UNIT_NAME_RE.test(table)) {
|
|
62
|
+
throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// 宿主的 global schema 不接受 null:null 是介质的「从未写入」哨兵,
|
|
66
|
+
// 存进去的 null 无法与「不存在」区分,重新打开会静默回落到 initial。
|
|
67
|
+
if (spec.global !== void 0 && spec.global.schema.safeParse(null).success) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`domain '${spec.name}' global schema must not accept null: null is the medium's "never written" sentinel`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return spec;
|
|
73
|
+
}
|
package/src/storage/schema.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// skill-tag-plugin — storage domain schema (spec §12: 9 entity classes)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Spec 形状由本仓库 src/storage/domain-spec.js 本地构造,刻意不 import 宿主内核包
|
|
4
|
+
// @deepseek-ai/dsh-storage-domain(原因见该文件头部注释)。运行时通过
|
|
5
|
+
// ctx.storageDomain.open(skillTagDomain) 交给宿主打开。
|
|
6
|
+
//
|
|
7
|
+
// KvTable 能力(get/put/delete/update/entries、domain/changed 事件、caller closes)
|
|
8
|
+
// 来自宿主注入的 domain handle,见 docs/evidence/extract-dsh-storage-domain.js。
|
|
6
9
|
//
|
|
7
10
|
// Entity → table mapping (§12 table, one row per entity unless noted):
|
|
8
11
|
// tags 全局标签 {tagId, name, createdAt} key=tagId
|
|
@@ -15,7 +18,7 @@
|
|
|
15
18
|
// "不要求必须用四张独立数据表"; they must persist, which they do here.)
|
|
16
19
|
|
|
17
20
|
import { z } from "zod";
|
|
18
|
-
import { defineDomain, domainTable } from "
|
|
21
|
+
import { defineDomain, domainTable } from "./domain-spec.js";
|
|
19
22
|
|
|
20
23
|
// ── record schemas ──────────────────────────────────────────────────────────
|
|
21
24
|
|