dsh-skill-tag 0.1.10 → 0.1.12
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.
|
@@ -208,3 +208,58 @@ mattpocock 的 25 个、archify 的 `archify`。
|
|
|
208
208
|
不是模拟模型视野。
|
|
209
209
|
2. `scope`:设置页没有 agent,只能拿 global 层。当前是「设置页列 global + 自扫描,
|
|
210
210
|
对话页列该对话 agent 的真实集合」这种分区口径。
|
|
211
|
+
## 8. 刷新技能列表 / 失效关联(2026-10-31)
|
|
212
|
+
|
|
213
|
+
### 8.1 症状
|
|
214
|
+
|
|
215
|
+
把 `mattpocock-skills-dsh` 插件 disable 之后,技能确实从模型视野里消失了,
|
|
216
|
+
但本插件的界面**仍然认为它们在**,数字也不降。
|
|
217
|
+
|
|
218
|
+
### 8.2 为什么没自动刷新
|
|
219
|
+
|
|
220
|
+
两层原因,都不是"缓存没清":
|
|
221
|
+
|
|
222
|
+
1. **服务端本来就是实时的**。`GET /skills` 每次都重新问宿主 `ctx.skills.snapshot()`
|
|
223
|
+
+ 补一次自扫描,没有任何缓存层。连问两次结果完全一致。
|
|
224
|
+
2. **前端把"幽灵技能"也算进了计数**。`skillLinks` 表里存的是**历史关联记录**,
|
|
225
|
+
技能消失后这些记录不会自己消失(这是对的:插件只是被临时关闭,不该丢掉
|
|
226
|
+
用户手工打的标签)。旧实现在 `SkillTagPage` 里把它们并进 `names`:
|
|
227
|
+
|
|
228
|
+
```js
|
|
229
|
+
// 旧实现:先扫描结果,再补"只存在连接记录"的幽灵
|
|
230
|
+
for (const l of links) if (!seen.has(l.skillId)) names.push({ name: l.skillId, ghost: true });
|
|
231
|
+
// …然后 `技能 (${names.length})` ← 幽灵也被数进去了
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
本机实测:真实 82 个 + 幽灵 25 个(全部来自被 disable 的 mattpocock 插件)=
|
|
235
|
+
界面显示 **107**。所以"刷新按钮"必须同时解决**计数口径**,否则点了数字还是不变。
|
|
236
|
+
|
|
237
|
+
### 8.3 改动
|
|
238
|
+
|
|
239
|
+
| 位置 | 改动 |
|
|
240
|
+
| --- | --- |
|
|
241
|
+
| `src/api.js` `GET /skills` | 额外返回 `stale: [{skillId, tagIds}]` —— 有标签关联但这次发现不到的技能。**只报告,不删**。 |
|
|
242
|
+
| `src/api.js` `POST /skills/purge-stale` | 只删这些失效关联。发现结果为 **0 时直接 409 拒绝**(`NO_SKILLS`),避免宿主未就绪时把全部关联当成失效。支持 `skillIds` 白名单只删勾选的。 |
|
|
243
|
+
| `lib/client.js` `Section` | 新增 state `stale`;新增 `refreshSkills()`(重新拉 `/state` + `/skills`)与 `purgeStale()`(二次确认后调 purge)。 |
|
|
244
|
+
| `lib/client.js` 主页面 | 导航栏加「刷新技能列表」;有 stale 时顶部橙色提示条 + 「清理失效关联」。 |
|
|
245
|
+
| `lib/client.js` `SkillTagPage` | **计数只算真实技能**:`技能 (N)` + `· 失效 M`;失效项在左列下方单列(带删除线、不显示 📁);选中失效技能时给解释条。 |
|
|
246
|
+
|
|
247
|
+
### 8.4 为什么不自动删
|
|
248
|
+
|
|
249
|
+
关联是用户手工打的标签。插件被临时关闭 ≠ 用户放弃这些标签 —— 重新启用后
|
|
250
|
+
应该恢复。所以:**默认只标记,删除必须显式点击**,且点击时列出前 8 个名字
|
|
251
|
+
并说明"重新启用插件后需要重新打标签"。
|
|
252
|
+
|
|
253
|
+
### 8.5 验收
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
1. 关掉贡献技能的插件 → 点「刷新技能列表」
|
|
257
|
+
→ 「技能 (N)」降到真实值;橙色条显示「有 25 个技能仍有标签关联,但当前发现不到」
|
|
258
|
+
2. 重新打开插件 → 再点刷新
|
|
259
|
+
→ 数字回升,橙色条消失,之前的标签关联**原样恢复**(因为一条都没删)
|
|
260
|
+
3. 点「清理失效关联」→ 二次确认 → 关联被删,橙色条消失
|
|
261
|
+
→ 再打开插件时这些技能不再带标签(预期行为,有文案提示)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
测试:`tests/__page-render.mjs`(6 条渲染断言)+ `tests/conversation.test.mjs`
|
|
265
|
+
(4 条 API 断言,含"GET 不删数据"与"发现为 0 时拒绝执行"两条保护性断言)。
|
package/lib/client.js
CHANGED
|
@@ -49,6 +49,44 @@ window.__ModuleLoader__.load({
|
|
|
49
49
|
+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// ── 失效技能(stale)的判定与分区 ──────────────────────────────
|
|
53
|
+
//
|
|
54
|
+
// 「失效」= 有标签关联,但**这次发现不到**(通常是贡献它的插件被关闭)。
|
|
55
|
+
// 服务端在 GET /skills 里把这类记录放在 stale 数组里报告,不删数据。
|
|
56
|
+
//
|
|
57
|
+
// 三处界面共用这一套判定,保证口径一致:
|
|
58
|
+
// · skill 联系 tag(技能清单):失效项置底 + 标红
|
|
59
|
+
// · 主页面标签列表(TagList):含失效技能的**标签名**标红
|
|
60
|
+
// · tag 联系 skill(左列标签):同上;右列技能列表失效项置底 + 标红
|
|
61
|
+
|
|
62
|
+
/** 失效技能名集合(来自 /skills 的 stale 报告)。 */
|
|
63
|
+
function staleSetOf(state) {
|
|
64
|
+
return new Set((state?.stale ?? []).map((s) => s?.skillId).filter(Boolean));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 这个标签是否含至少一个失效技能(决定标签名要不要标红)。 */
|
|
68
|
+
function tagHasStale(links, tagId, staleSet) {
|
|
69
|
+
if (!staleSet || staleSet.size === 0) return false;
|
|
70
|
+
return (links ?? []).some((l) => (l.tagIds ?? []).includes(tagId) && staleSet.has(l.skillId));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 技能名分区:存活在前、失效在后,**各分区内部保持原顺序**。
|
|
75
|
+
* 稳定分区(不是排序),所以用户看到的相对次序不会因为标红而跳动。
|
|
76
|
+
* @param {Array<{skillId:string}|string>} items
|
|
77
|
+
* @param {Set<string>} staleSet
|
|
78
|
+
* @param {(x:any)=>string} nameOf
|
|
79
|
+
*/
|
|
80
|
+
function partitionStaleSkills(items, staleSet, nameOf = (x) => (typeof x === "string" ? x : x?.skillId)) {
|
|
81
|
+
const alive = [];
|
|
82
|
+
const stale = [];
|
|
83
|
+
for (const it of items ?? []) (staleSet.has(nameOf(it)) ? stale : alive).push(it);
|
|
84
|
+
return { alive, stale };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 失效技能的统一配色(与「全局删除」的红一致,但更暗一点,避免抢眼)。 */
|
|
88
|
+
const STALE_COLOR = "#e74c3c";
|
|
89
|
+
|
|
52
90
|
// zoneOf: §7 four-zone classification of a conversation tag entry.
|
|
53
91
|
function zoneOf(entry) {
|
|
54
92
|
if (entry.blocked) return "blocked";
|
|
@@ -178,6 +216,9 @@ window.__ModuleLoader__.load({
|
|
|
178
216
|
function Section() {
|
|
179
217
|
const [state, setState] = react.useState({
|
|
180
218
|
loading: true, error: null, data: null, skills: [],
|
|
219
|
+
// stale = 有标签关联、但当前发现不到的技能(贡献它的插件被关了)。
|
|
220
|
+
// 只标记,不自动删除 —— 用户显式点「清理失效关联」才动数据。
|
|
221
|
+
stale: [],
|
|
181
222
|
newTagName: "", busy: false, notice: null,
|
|
182
223
|
// sub-page routing: "main" | "links" | "skilltags" | "templates"
|
|
183
224
|
view: "main",
|
|
@@ -203,6 +244,7 @@ window.__ModuleLoader__.load({
|
|
|
203
244
|
error: d?.ok ? null : JSON.stringify(d),
|
|
204
245
|
data: d?.ok ? d : null,
|
|
205
246
|
skills: sk?.ok ? sk.skills : [],
|
|
247
|
+
stale: sk?.ok ? (sk.stale ?? []) : [],
|
|
206
248
|
}));
|
|
207
249
|
} catch (e) {
|
|
208
250
|
setState((s) => ({ ...s, loading: false, error: String(e), data: null }));
|
|
@@ -224,6 +266,42 @@ window.__ModuleLoader__.load({
|
|
|
224
266
|
}
|
|
225
267
|
}, [reload]);
|
|
226
268
|
|
|
269
|
+
/**
|
|
270
|
+
* 刷新技能列表:重新问宿主一次当前真正可见的技能。
|
|
271
|
+
* 用于「贡献技能的插件被关掉/打开」之后同步界面 —— 这类技能不落在任何
|
|
272
|
+
* 文件系统根里,只能靠宿主注册表发现,插件启动后不会自动感知变化。
|
|
273
|
+
*/
|
|
274
|
+
const refreshSkills = react.useCallback(async () => {
|
|
275
|
+
setState((s) => ({ ...s, busy: true, notice: null }));
|
|
276
|
+
try {
|
|
277
|
+
await reload();
|
|
278
|
+
setState((s) => ({ ...s, busy: false, notice: `技能列表已刷新:${(s.skills ?? []).length} 个可见技能` }));
|
|
279
|
+
} catch (e) {
|
|
280
|
+
setState((s) => ({ ...s, busy: false, notice: "刷新失败: " + (e?.message ?? e) }));
|
|
281
|
+
}
|
|
282
|
+
}, [reload]);
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 清理失效关联(显式、可取消):只删「有标签、但当前发现不到」的记录。
|
|
286
|
+
* 服务端在发现结果为空时会拒绝执行(避免宿主未就绪时误删全部)。
|
|
287
|
+
*/
|
|
288
|
+
const purgeStale = react.useCallback(async () => {
|
|
289
|
+
const list = state.stale ?? [];
|
|
290
|
+
if (list.length === 0) return;
|
|
291
|
+
const names = list.map((s) => s.skillId);
|
|
292
|
+
const preview = names.slice(0, 8).join("\u3001") + (names.length > 8 ? ` 等 ${names.length} 个` : "");
|
|
293
|
+
if (!window.confirm(`清理 ${names.length} 条失效关联?\n\n${preview}\n\n这些技能当前发现不到(通常是贡献它的插件被关闭)。清理后它们的标签关联会删除;若之后重新启用那个插件,需要重新打标签。`)) return;
|
|
294
|
+
setState((s) => ({ ...s, busy: true, notice: null }));
|
|
295
|
+
try {
|
|
296
|
+
const d = await apiPost("/skills/purge-stale", { skillIds: names });
|
|
297
|
+
if (!d?.ok) throw new Error(d?.error ?? JSON.stringify(d));
|
|
298
|
+
await reload();
|
|
299
|
+
setState((s) => ({ ...s, busy: false, notice: `已清理 ${d.count ?? 0} 条失效关联` }));
|
|
300
|
+
} catch (e) {
|
|
301
|
+
setState((s) => ({ ...s, busy: false, notice: "清理失败: " + (e?.message ?? e) }));
|
|
302
|
+
}
|
|
303
|
+
}, [state.stale, reload]);
|
|
304
|
+
|
|
227
305
|
const { loading, error, data, busy, notice } = state;
|
|
228
306
|
if (loading) return react.createElement("div", { style: pad }, "加载中…");
|
|
229
307
|
if (error) return react.createElement("div", { style: pad }, "加载失败: " + error);
|
|
@@ -259,7 +337,32 @@ window.__ModuleLoader__.load({
|
|
|
259
337
|
react.createElement("button", {
|
|
260
338
|
style: { ...btn, padding: "6px 16px", fontSize: "13px" },
|
|
261
339
|
onClick: () => setState((s) => ({ ...s, view: "templates", tplId: null, tplIsNew: false, tplName: "", tplExpression: "" })),
|
|
262
|
-
}, "编辑标签组")
|
|
340
|
+
}, "编辑标签组"),
|
|
341
|
+
// 刷新技能列表:贡献技能的插件被关闭/打开后界面不会自动感知,
|
|
342
|
+
// 这里显式重新问宿主一次(这类技能不落在任何文件系统根里)。
|
|
343
|
+
react.createElement("button", {
|
|
344
|
+
key: "refresh",
|
|
345
|
+
style: { ...btn, padding: "6px 16px", fontSize: "13px" },
|
|
346
|
+
disabled: busy,
|
|
347
|
+
title: "重新向宿主查询当前真正可用的技能(插件被关闭后用它同步列表)",
|
|
348
|
+
"data-skill-tag-refresh": "1",
|
|
349
|
+
onClick: () => void refreshSkills(),
|
|
350
|
+
}, "刷新技能列表")),
|
|
351
|
+
// 失效关联提示条:有标签、但当前发现不到的技能。
|
|
352
|
+
// 只提示不自动删;给一个显式清理入口(点击会二次确认)。
|
|
353
|
+
!loading && (state.stale ?? []).length > 0 && react.createElement("div", {
|
|
354
|
+
key: "stale",
|
|
355
|
+
style: { background: "rgba(230,126,34,0.12)", border: "1px solid rgba(230,126,34,0.4)", borderRadius: "8px", padding: "8px 10px", marginBottom: "12px", fontSize: "12px", color: "#e67e22", display: "flex", gap: "10px", alignItems: "center", flexWrap: "wrap" },
|
|
356
|
+
}, [
|
|
357
|
+
react.createElement("span", { key: "t", style: { flex: 1 } },
|
|
358
|
+
`有 ${(state.stale ?? []).length} 个技能仍有标签关联,但当前发现不到(通常是贡献它的插件被关闭)。`),
|
|
359
|
+
react.createElement("button", {
|
|
360
|
+
key: "p",
|
|
361
|
+
style: { ...btn, padding: "2px 10px", fontSize: "12px", color: "#e67e22" },
|
|
362
|
+
disabled: busy,
|
|
363
|
+
"data-skill-tag-purge": "1",
|
|
364
|
+
onClick: () => void purgeStale(),
|
|
365
|
+
}, "清理失效关联")]),
|
|
263
366
|
react.createElement(TagCreateForm, { key: "create", state, setState, act, busy }),
|
|
264
367
|
react.createElement(TagList, { key: "list", data, act, busy, state, setState }),
|
|
265
368
|
]);
|
|
@@ -295,6 +398,8 @@ window.__ModuleLoader__.load({
|
|
|
295
398
|
const links = data.skillLinks ?? [];
|
|
296
399
|
const workspaces = data.workspaces ?? [];
|
|
297
400
|
const catalog = state.skills ?? [];
|
|
401
|
+
// 失效技能集合:决定"哪些标签名要标红"以及"展开后哪些名字置底标红"。
|
|
402
|
+
const staleSet = staleSetOf(state);
|
|
298
403
|
// 每个标签默认折叠,只显示数量;点「▸」才展开下面的技能名。
|
|
299
404
|
// 本机 96 个技能 / 34 个标签,全展开会让这一页变成几百行的墙。
|
|
300
405
|
const open = state.openTags ?? {};
|
|
@@ -307,6 +412,9 @@ window.__ModuleLoader__.load({
|
|
|
307
412
|
tags.length === 0 && react.createElement("div", { key: "empty", style: { color: "#888" } }, "暂无标签"),
|
|
308
413
|
...tags.map((t) => {
|
|
309
414
|
const skills = links.filter((l) => l.tagIds.includes(t.tagId)).map((l) => l.skillId);
|
|
415
|
+
// 标签内技能分区:存活在前、失效在后(分区稳定,内部保序)
|
|
416
|
+
const split = partitionStaleSkills(skills, staleSet, (x) => x);
|
|
417
|
+
const hasStale = split.stale.length > 0;
|
|
310
418
|
const usedBy = workspaces.filter((w) => (w.config?.orTags ?? []).includes(t.tagId) || (w.config?.andGroups ?? []).some((g) => g.tagIds.includes(t.tagId))).map((w) => w.workspaceId);
|
|
311
419
|
const isOpen = !!open[t.tagId];
|
|
312
420
|
return react.createElement("div", { key: t.tagId, style: { padding: "4px 0", borderBottom: "1px solid var(--theme-border,#222)" } }, [
|
|
@@ -320,12 +428,21 @@ window.__ModuleLoader__.load({
|
|
|
320
428
|
"data-skill-tag-toggle": t.tagId,
|
|
321
429
|
onClick: () => toggleOpen(t.tagId),
|
|
322
430
|
}, isOpen ? "▾" : "▸"),
|
|
323
|
-
|
|
431
|
+
// 标签名:含失效技能时标红(图二/图三的要求 —— 只标红标签本身,
|
|
432
|
+
// 让用户一眼看出"这个标签关联到了已消失的技能")。
|
|
433
|
+
react.createElement("span", {
|
|
434
|
+
key: "n",
|
|
435
|
+
style: { fontWeight: 500, ...(hasStale ? { color: STALE_COLOR } : {}) },
|
|
436
|
+
title: hasStale ? `该标签含 ${split.stale.length} 个失效技能(当前发现不到)` : undefined,
|
|
437
|
+
"data-skill-tag-stale-tag": hasStale ? t.tagId : undefined,
|
|
438
|
+
}, t.name),
|
|
324
439
|
react.createElement("span", { key: "id", style: { color: "#888", fontSize: "11px" } }, t.tagId),
|
|
325
440
|
react.createElement("span", {
|
|
326
441
|
key: "cnt", style: { color: "#888", fontSize: "11px" },
|
|
327
442
|
"data-skill-tag-count": t.tagId,
|
|
328
|
-
}, skills.length === 0
|
|
443
|
+
}, skills.length === 0
|
|
444
|
+
? "无 Skill 关联"
|
|
445
|
+
: `${skills.length} 个 Skill` + (hasStale ? `(${split.stale.length} 失效)` : "")),
|
|
329
446
|
react.createElement("button", {
|
|
330
447
|
key: "del", style: { ...btn, color: "#e74c3c" }, disabled: busy,
|
|
331
448
|
onClick: () => {
|
|
@@ -336,10 +453,18 @@ window.__ModuleLoader__.load({
|
|
|
336
453
|
]),
|
|
337
454
|
// 展开后才列出技能(每行仍带「打开文件夹」,这里也是"列出 skill 的界面")
|
|
338
455
|
isOpen && skills.length > 0 && react.createElement("div", { key: "sk", style: { display: "flex", gap: "6px", alignItems: "center", flexWrap: "wrap", fontSize: "11px", color: "#888", padding: "4px 0 2px 22px" } }, [
|
|
339
|
-
|
|
456
|
+
// 存活技能在前
|
|
457
|
+
...split.alive.map((sid, i) => react.createElement("span", { key: sid + i, style: { display: "inline-flex", gap: "2px", alignItems: "center" } }, [
|
|
340
458
|
sid,
|
|
341
459
|
react.createElement(RevealButton, { key: "rev", skillName: sid, path: skillPathOf(catalog, sid), label: "📁", style: { padding: "0 4px", fontSize: "10px" } }),
|
|
342
460
|
])),
|
|
461
|
+
// 失效技能置底:标红、删除线、不给 📁(本机没有该文件,点了必然失败)
|
|
462
|
+
...split.stale.map((sid, i) => react.createElement("span", {
|
|
463
|
+
key: "st" + i,
|
|
464
|
+
style: { display: "inline-flex", gap: "2px", alignItems: "center", color: STALE_COLOR, textDecoration: "line-through" },
|
|
465
|
+
title: "失效技能:当前发现不到(通常是贡献它的插件被关闭)。标签关联仍保留。",
|
|
466
|
+
"data-skill-tag-stale-skill": sid,
|
|
467
|
+
}, [sid])),
|
|
343
468
|
]),
|
|
344
469
|
]);
|
|
345
470
|
}),
|
|
@@ -414,7 +539,10 @@ window.__ModuleLoader__.load({
|
|
|
414
539
|
const sel = tags.find((t) => t.tagId === selId) ?? null;
|
|
415
540
|
const tagMatch = (t) => !state.tagSearch.trim() || t.name.toLowerCase().includes(state.tagSearch.trim().toLowerCase());
|
|
416
541
|
const skillMatch = (s) => !state.skillSearch.trim() || s.skillId.toLowerCase().includes(state.skillSearch.trim().toLowerCase());
|
|
542
|
+
// 失效技能集合:标签名标红 + 右列技能列表分区(存活在前、失效置底标红)
|
|
543
|
+
const staleSet = staleSetOf(state);
|
|
417
544
|
const linkedSkills = skillsLinkedTo(links, selId).filter(skillMatch);
|
|
545
|
+
const linkedSplit = partitionStaleSkills(linkedSkills, staleSet);
|
|
418
546
|
const createTag = async () => {
|
|
419
547
|
const name = state.newTagName.trim();
|
|
420
548
|
if (!name) return;
|
|
@@ -451,12 +579,18 @@ window.__ModuleLoader__.load({
|
|
|
451
579
|
tags.filter(tagMatch).length === 0 && react.createElement("div", { key: "e", style: { color: "#888", fontSize: "12px", padding: "6px 2px" } }, "无匹配标签"),
|
|
452
580
|
...tags.filter(tagMatch).map((t) => {
|
|
453
581
|
const n = links.filter((l) => l.tagIds.includes(t.tagId)).length;
|
|
582
|
+
// 含失效技能的标签:只把**标签名**标红(图三的要求),其余样式不变
|
|
583
|
+
const hasStale = tagHasStale(links, t.tagId, staleSet);
|
|
454
584
|
return react.createElement("div", {
|
|
455
585
|
key: t.tagId, style: t.tagId === selId ? selRowStyle : rowStyle,
|
|
456
586
|
onClick: () => setState((s) => ({ ...s, linkTagId: t.tagId })),
|
|
457
|
-
title: "点击选中",
|
|
587
|
+
title: hasStale ? "该标签含失效技能(当前发现不到),点开可见" : "点击选中",
|
|
458
588
|
}, [
|
|
459
|
-
react.createElement("span", {
|
|
589
|
+
react.createElement("span", {
|
|
590
|
+
key: "n",
|
|
591
|
+
style: { fontWeight: t.tagId === selId ? 600 : 400, flex: 1, cursor: "pointer", ...(hasStale ? { color: STALE_COLOR } : {}) },
|
|
592
|
+
"data-skill-tag-stale-tag": hasStale ? t.tagId : undefined,
|
|
593
|
+
}, t.name),
|
|
460
594
|
react.createElement("span", { key: "c", style: { color: "#888", fontSize: "11px" } }, n ? `${n} skill` : ""),
|
|
461
595
|
react.createElement("button", {
|
|
462
596
|
key: "del", style: { ...btn, color: "#e74c3c", padding: "1px 8px" }, disabled: busy,
|
|
@@ -494,7 +628,8 @@ window.__ModuleLoader__.load({
|
|
|
494
628
|
}),
|
|
495
629
|
react.createElement("div", { key: "rows", style: { maxHeight: "340px", overflowY: "auto" } }, [
|
|
496
630
|
linkedSkills.length === 0 && react.createElement("div", { key: "e", style: { color: "#888", fontSize: "12px", padding: "6px 2px" } }, "该标签暂未关联任何 Skill"),
|
|
497
|
-
|
|
631
|
+
// 存活技能在前
|
|
632
|
+
...linkedSplit.alive.map((s) => {
|
|
498
633
|
const ghost = !skills.some((x) => x.name === s.skillId);
|
|
499
634
|
return react.createElement("div", { key: s.skillId, style: rowStyle, title: ghost ? "未在本机扫描到(关联仍生效)" : "" }, [
|
|
500
635
|
react.createElement("span", { key: "n", style: { flex: 1 } }, s.skillId + (ghost ? " *" : "")),
|
|
@@ -505,6 +640,20 @@ window.__ModuleLoader__.load({
|
|
|
505
640
|
}, "移除"),
|
|
506
641
|
]);
|
|
507
642
|
}),
|
|
643
|
+
// 失效技能置底:名字标红 + 删除线;不给 📁(本机没有该文件)
|
|
644
|
+
...linkedSplit.stale.map((s) => react.createElement("div", {
|
|
645
|
+
key: "st" + s.skillId,
|
|
646
|
+
style: { ...rowStyle, opacity: 0.85 },
|
|
647
|
+
title: "失效技能:当前发现不到(通常是贡献它的插件被关闭)。标签关联仍保留。",
|
|
648
|
+
"data-skill-tag-stale-skill": s.skillId,
|
|
649
|
+
}, [
|
|
650
|
+
react.createElement("span", { key: "n", style: { flex: 1, color: STALE_COLOR, textDecoration: "line-through" } }, s.skillId),
|
|
651
|
+
// 仍允许移除(用户可能就是想清掉它)
|
|
652
|
+
react.createElement("button", {
|
|
653
|
+
key: "rm", style: { ...btn, color: "#e67e22", padding: "1px 8px" }, disabled: busy,
|
|
654
|
+
onClick: () => void removeSkill(s.skillId),
|
|
655
|
+
}, "移除"),
|
|
656
|
+
])),
|
|
508
657
|
]),
|
|
509
658
|
react.createElement("div", { key: "add", style: { display: "flex", gap: "8px", marginTop: "10px", paddingTop: "8px", borderTop: "1px solid var(--theme-border,#222)" } }, [
|
|
510
659
|
react.createElement("button", {
|
|
@@ -533,11 +682,18 @@ window.__ModuleLoader__.load({
|
|
|
533
682
|
const scanned = state.skills ?? [];
|
|
534
683
|
const selName = state.linkSkillName;
|
|
535
684
|
|
|
536
|
-
//
|
|
685
|
+
// 技能清单:**只列当前真正存在的技能**(scanned = 这次刷新发现到的)。
|
|
686
|
+
//
|
|
687
|
+
// 2026-10-31 改:此前会把"只存在连接记录"的幽灵技能也算进 `技能 (N)`,
|
|
688
|
+
// 于是关掉贡献技能的插件后,数字仍然不变(本机 82 真 + 25 幽灵 = 107)。
|
|
689
|
+
// 现在幽灵单独归类为 stale,计数只算真技能 —— 用户说的"更新列表和数目"。
|
|
537
690
|
const names = [];
|
|
538
691
|
const seen = new Set();
|
|
539
692
|
for (const s of scanned) { if (s?.name && !seen.has(s.name)) { names.push({ name: s.name, ghost: false }); seen.add(s.name); } }
|
|
540
|
-
|
|
693
|
+
const stale = (state.stale ?? []).filter((s) => s?.skillId && !seen.has(s.skillId));
|
|
694
|
+
const staleIds = new Set(stale.map((s) => s.skillId));
|
|
695
|
+
// 已选中但当前失效的技能:仍然显示,否则右侧会突然空白且没有解释。
|
|
696
|
+
const selIsStale = Boolean(selName && staleIds.has(selName));
|
|
541
697
|
const match = (n) => !state.skillPickSearch.trim() || n.toLowerCase().includes(state.skillPickSearch.trim().toLowerCase());
|
|
542
698
|
const shown = names.filter((n) => match(n.name));
|
|
543
699
|
|
|
@@ -567,12 +723,28 @@ window.__ModuleLoader__.load({
|
|
|
567
723
|
react.createElement("button", { key: "back", style: btn, disabled: busy, onClick: () => setState((s) => ({ ...s, view: "main", linkSkillName: null })) }, "← 返回"),
|
|
568
724
|
react.createElement("h3", { key: "t", style: { margin: 0 } }, "skill 联系 tag"),
|
|
569
725
|
selName && react.createElement("span", { key: "cur", style: { color: "#888", fontSize: "12px" } }, `当前技能: ${selName}`),
|
|
726
|
+
// 刷新技能列表:插件关停/启用后手动同步(服务端每次都是实时查询,
|
|
727
|
+
// 这里只是把前端那份缓存重新拉一遍)。
|
|
728
|
+
react.createElement("button", {
|
|
729
|
+
key: "refresh",
|
|
730
|
+
style: { ...btn, marginLeft: "auto" },
|
|
731
|
+
disabled: busy,
|
|
732
|
+
title: "重新向宿主查询当前真正可用的技能",
|
|
733
|
+
"data-skill-tag-refresh": "1",
|
|
734
|
+
onClick: () => void refreshSkills(),
|
|
735
|
+
}, "刷新技能列表"),
|
|
570
736
|
]),
|
|
571
737
|
notice && react.createElement("div", { key: "n", style: noteStyle }, notice),
|
|
738
|
+
// 选中了一个当前已经失效的技能:右侧标签仍可看/可改,但要讲清原因。
|
|
739
|
+
selIsStale && react.createElement("div", {
|
|
740
|
+
key: "staleSel",
|
|
741
|
+
style: { background: "rgba(230,126,34,0.12)", border: "1px solid rgba(230,126,34,0.4)", borderRadius: "8px", padding: "6px 10px", marginBottom: "10px", fontSize: "12px", color: "#e67e22" },
|
|
742
|
+
}, `「${selName}」当前发现不到(通常是贡献它的插件被关闭)。标签关联仍保留,重新启用插件后自动恢复可见。`),
|
|
572
743
|
react.createElement("div", { key: "cols", style: { display: "flex", gap: "12px", alignItems: "flex-start" } }, [
|
|
573
744
|
// ── 左列:技能 ──────────────────────────────────────────
|
|
574
745
|
react.createElement("div", { key: "left", style: colStyle }, [
|
|
575
|
-
react.createElement("div", { key: "lt", style: { fontWeight: 600, marginBottom: "6px" } },
|
|
746
|
+
react.createElement("div", { key: "lt", style: { fontWeight: 600, marginBottom: "6px" } },
|
|
747
|
+
`技能 (${names.length})` + (stale.length ? ` · 失效 ${stale.length}` : "")),
|
|
576
748
|
react.createElement("input", {
|
|
577
749
|
key: "search", style: { ...input, width: "100%", marginBottom: "6px" }, placeholder: "搜索技能…",
|
|
578
750
|
value: state.skillPickSearch, onChange: (e) => setState((s) => ({ ...s, skillPickSearch: e.target.value })),
|
|
@@ -593,6 +765,31 @@ window.__ModuleLoader__.load({
|
|
|
593
765
|
]);
|
|
594
766
|
}),
|
|
595
767
|
]),
|
|
768
|
+
// 失效技能单列:只在有标签关联、但当前发现不到时出现。
|
|
769
|
+
// 位置固定在最底部(图一的要求),名字标红 + 删除线;
|
|
770
|
+
// 计数不算进上面的「技能 (N)」;默认不删,给一个显式清理入口。
|
|
771
|
+
stale.length > 0 && react.createElement("div", { key: "staleBox", style: { marginTop: "10px", paddingTop: "8px", borderTop: "1px solid var(--theme-border,#222)" } }, [
|
|
772
|
+
react.createElement("div", { key: "h", style: { display: "flex", alignItems: "center", gap: "8px", fontSize: "12px", color: "#e67e22", marginBottom: "4px" } }, [
|
|
773
|
+
react.createElement("span", { key: "t", style: { flex: 1 } }, `失效 ${stale.length} 个(有标签关联,但当前发现不到)`),
|
|
774
|
+
react.createElement("button", {
|
|
775
|
+
key: "p",
|
|
776
|
+
style: { ...btn, padding: "1px 8px", fontSize: "11px", color: "#e67e22" },
|
|
777
|
+
disabled: busy,
|
|
778
|
+
"data-skill-tag-purge": "1",
|
|
779
|
+
onClick: () => void purgeStale(),
|
|
780
|
+
}, "清理失效关联")]),
|
|
781
|
+
...stale.slice(0, 30).map((s) => react.createElement("div", {
|
|
782
|
+
key: s.skillId,
|
|
783
|
+
style: { ...rowStyle, cursor: "pointer" },
|
|
784
|
+
title: "失效技能:当前发现不到(通常是贡献它的插件被关闭)。点击查看它保留的标签关联。",
|
|
785
|
+
"data-skill-tag-stale-skill": s.skillId,
|
|
786
|
+
onClick: () => setState((st) => ({ ...st, linkSkillName: s.skillId })),
|
|
787
|
+
}, [
|
|
788
|
+
react.createElement("span", { key: "n", style: { flex: 1, color: STALE_COLOR, textDecoration: "line-through" } }, s.skillId),
|
|
789
|
+
react.createElement("span", { key: "c", style: { fontSize: "11px" } }, `${(s.tagIds ?? []).length} tag`),
|
|
790
|
+
])),
|
|
791
|
+
stale.length > 30 && react.createElement("div", { key: "more", style: { color: "#888", fontSize: "11px", padding: "2px 4px" } }, `…还有 ${stale.length - 30} 个`),
|
|
792
|
+
]),
|
|
596
793
|
]),
|
|
597
794
|
// ── 右列:该技能的标签 ──────────────────────────────────
|
|
598
795
|
react.createElement("div", { key: "right", style: colStyle }, [
|
|
@@ -1708,6 +1905,8 @@ window.__ModuleLoader__.load({
|
|
|
1708
1905
|
workspaceIdOfRowKey, isWorkspaceMenu, manage, manageSet,
|
|
1709
1906
|
manageScope, registerComposerEntry,
|
|
1710
1907
|
skillPathOf, RevealButton, ownedTagsFirst,
|
|
1908
|
+
// 失效技能判定(三处界面共用同一套口径,单独测)
|
|
1909
|
+
staleSetOf, tagHasStale, partitionStaleSkills, STALE_COLOR,
|
|
1711
1910
|
};
|
|
1712
1911
|
return module.exports;
|
|
1713
1912
|
},
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -229,7 +229,46 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
|
|
|
229
229
|
workspaceId: q("workspaceId") ?? null,
|
|
230
230
|
})) ?? []
|
|
231
231
|
: [];
|
|
232
|
-
|
|
232
|
+
// stale = 有标签关联、但这次发现不到的技能(通常是贡献它的插件被关了)。
|
|
233
|
+
// 只**报告**,不删:见 /skills/purge-stale 的注释。
|
|
234
|
+
const live = new Set(skills.map((s) => (typeof s === "string" ? s : s?.name)));
|
|
235
|
+
const stale = [...services._caches.skillLinks.values()]
|
|
236
|
+
.filter((l) => !live.has(l.skillId))
|
|
237
|
+
.map((l) => ({ skillId: l.skillId, tagIds: [...(l.tagIds ?? [])] }));
|
|
238
|
+
return json(res, 200, { ok: true, skills, stale });
|
|
239
|
+
}
|
|
240
|
+
// ── 「刷新技能列表」+「清理失效关联」 ─────────────────────────────
|
|
241
|
+
//
|
|
242
|
+
// 场景:某些技能由**别的插件**贡献(provider 注册进 ctx.skills,根本不落在
|
|
243
|
+
// 任何文件系统根里)。那个插件被 disable 之后,技能立刻消失,但插件这边
|
|
244
|
+
// 的 skillLinks 记录还留着 —— 界面上就表现为"技能还在、数字偏大"。
|
|
245
|
+
//
|
|
246
|
+
// 这里的口径:
|
|
247
|
+
// GET /skills → 实时问宿主,顺带算出 stale(失效关联)
|
|
248
|
+
// POST /skills/purge-stale → 只删这些失效关联记录(用户在界面上显式点)
|
|
249
|
+
//
|
|
250
|
+
// 为什么不在 GET 里顺手删:关联是用户手工打的标签,插件的临时缺席不等于
|
|
251
|
+
// 用户放弃它。默认只标记,删除必须显式触发。
|
|
252
|
+
if (path === "/skills/purge-stale" && method === "POST") {
|
|
253
|
+
const live = new Set(
|
|
254
|
+
((listSkills ? await listSkills(q("cwd") ?? undefined, {
|
|
255
|
+
conversationId: q("conversationId") ?? null,
|
|
256
|
+
workspaceId: q("workspaceId") ?? null,
|
|
257
|
+
}) : []) ?? []).map((s) => (typeof s === "string" ? s : s?.name)),
|
|
258
|
+
);
|
|
259
|
+
// 发现结果为空 = 宿主没就绪,此时"全是失效"是误判,绝不动数据。
|
|
260
|
+
if (live.size === 0) {
|
|
261
|
+
return json(res, 409, { ok: false, code: "NO_SKILLS", error: "当前发现到 0 个技能,拒绝清理(可能是宿主未就绪)" });
|
|
262
|
+
}
|
|
263
|
+
const wanted = Array.isArray(body?.skillIds) ? new Set(body.skillIds.map(String)) : null;
|
|
264
|
+
const removed = [];
|
|
265
|
+
for (const link of [...services._caches.skillLinks.values()]) {
|
|
266
|
+
if (live.has(link.skillId)) continue; // 还活着 → 不动
|
|
267
|
+
if (wanted && !wanted.has(link.skillId)) continue; // 用户只勾了部分
|
|
268
|
+
removed.push(link.skillId);
|
|
269
|
+
}
|
|
270
|
+
for (const skillId of removed) await services.tags.unlinkSkill(skillId);
|
|
271
|
+
return json(res, 200, { ok: true, removed, count: removed.length });
|
|
233
272
|
}
|
|
234
273
|
// ── 「打开技能所在文件夹」 ─────────────────────────────────────
|
|
235
274
|
// 宿主没有暴露 shell/reveal 能力,由 src/reveal.js 自己拉起文件管理器。
|