dsh-skill-tag 0.1.10 → 0.1.13
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 }, [
|
|
@@ -1112,6 +1309,98 @@ window.__ModuleLoader__.load({
|
|
|
1112
1309
|
const listRow = { display: "flex", alignItems: "center", gap: "8px", padding: "5px 2px", borderBottom: "1px solid var(--theme-border,#222)", fontSize: "12px" };
|
|
1113
1310
|
const monoStyle = { fontFamily: "ui-monospace,monospace" };
|
|
1114
1311
|
|
|
1312
|
+
// ── 「一键下发」确认框(工作区版专用) ───────────────────────────
|
|
1313
|
+
//
|
|
1314
|
+
// 两步式(preview → push),因为这是**批量破坏性**操作:
|
|
1315
|
+
// · 打开时先 preview,看清"未配置 N 个 / 已配置 M 个"再决定;
|
|
1316
|
+
// · 默认只下发未配置的(overwrite=false);
|
|
1317
|
+
// · 要覆盖已配置的必须勾选复选框(overwrite=true);
|
|
1318
|
+
// · 工作区为空时明确警告「下发后这些对话将掌握全部技能」。
|
|
1319
|
+
function PushToConversationsDialog({ workspaceId, workspaceEmpty, busy, onClose, onDone }) {
|
|
1320
|
+
const [loading, setLoading] = react.useState(true);
|
|
1321
|
+
const [info, setInfo] = react.useState(null);
|
|
1322
|
+
const [err, setErr] = react.useState(null);
|
|
1323
|
+
const [overwrite, setOverwrite] = react.useState(false);
|
|
1324
|
+
const [running, setRunning] = react.useState(false);
|
|
1325
|
+
|
|
1326
|
+
react.useEffect(() => {
|
|
1327
|
+
let alive = true;
|
|
1328
|
+
(async () => {
|
|
1329
|
+
try {
|
|
1330
|
+
const d = await apiPost("/workspaces/push-preview", { workspaceId });
|
|
1331
|
+
if (!d?.ok) throw new Error(d?.error ?? "预览失败");
|
|
1332
|
+
if (alive) setInfo(d);
|
|
1333
|
+
} catch (e) {
|
|
1334
|
+
if (alive) setErr(String(e?.message ?? e));
|
|
1335
|
+
} finally {
|
|
1336
|
+
if (alive) setLoading(false);
|
|
1337
|
+
}
|
|
1338
|
+
})();
|
|
1339
|
+
return () => { alive = false; };
|
|
1340
|
+
}, [workspaceId]);
|
|
1341
|
+
|
|
1342
|
+
const fresh = info?.fresh ?? [];
|
|
1343
|
+
const configured = info?.configured ?? [];
|
|
1344
|
+
const targetCount = fresh.length + (overwrite ? configured.length : 0);
|
|
1345
|
+
const isEmpty = info?.workspaceEmpty ?? workspaceEmpty;
|
|
1346
|
+
|
|
1347
|
+
const run = async () => {
|
|
1348
|
+
setRunning(true);
|
|
1349
|
+
try {
|
|
1350
|
+
const d = await apiPost("/workspaces/push", { workspaceId, overwrite });
|
|
1351
|
+
if (!d?.ok) throw new Error(d?.error ?? "下发失败");
|
|
1352
|
+
onDone(d);
|
|
1353
|
+
} catch (e) {
|
|
1354
|
+
setErr(String(e?.message ?? e));
|
|
1355
|
+
} finally {
|
|
1356
|
+
setRunning(false);
|
|
1357
|
+
}
|
|
1358
|
+
};
|
|
1359
|
+
|
|
1360
|
+
const rowStyle = { display: "flex", gap: "8px", alignItems: "center", padding: "4px 0", fontSize: "12px" };
|
|
1361
|
+
return react.createElement("div", { style: overlayStyle, onMouseDown: (e) => { if (e.target === e.currentTarget) onClose(); } },
|
|
1362
|
+
react.createElement("div", { style: { ...modalBox, width: "600px" }, onClick: (e) => e.stopPropagation() }, [
|
|
1363
|
+
react.createElement("div", { key: "h", style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "10px" } }, [
|
|
1364
|
+
react.createElement("strong", { key: "t" }, "下发到该工作区下的所有对话"),
|
|
1365
|
+
react.createElement("button", { key: "x", style: btn, onClick: onClose }, "✕"),
|
|
1366
|
+
]),
|
|
1367
|
+
loading && react.createElement("div", { key: "l", style: { color: "#888", padding: "14px 0" } }, "正在统计受影响的对话…"),
|
|
1368
|
+
err && react.createElement("div", { key: "e", style: { color: "#e74c3c", padding: "8px 0" } }, err),
|
|
1369
|
+
!loading && !err && info && react.createElement(react.Fragment, { key: "body" }, [
|
|
1370
|
+
// 工作区为空时:必须让用户看见"这等于全部技能"
|
|
1371
|
+
isEmpty && react.createElement("div", { key: "warn", style: { background: "rgba(230,126,34,0.14)", border: "1px solid rgba(230,126,34,0.45)", borderRadius: "8px", padding: "8px 10px", marginBottom: "10px", color: "#e67e22", fontSize: "12px" } },
|
|
1372
|
+
"⚠ 当前工作区的标签条件为空、且没有手动添加/禁用的技能。下发后这些对话会变成「空条件 = 掌握全部技能」。"),
|
|
1373
|
+
react.createElement("div", { key: "sum", style: { fontSize: "12px", marginBottom: "8px", lineHeight: 1.7 } }, [
|
|
1374
|
+
react.createElement("div", { key: "a" }, `该工作区下共 ${info.total ?? 0} 个对话`),
|
|
1375
|
+
react.createElement("div", { key: "b", style: { color: "#2ecc71" } }, `· 从未配置过:${fresh.length} 个(默认下发)`),
|
|
1376
|
+
react.createElement("div", { key: "c", style: { color: "#888" } }, `· 已有自己的配置:${configured.length} 个(默认跳过)`),
|
|
1377
|
+
]),
|
|
1378
|
+
configured.length > 0 && react.createElement("label", { key: "ow", style: { ...rowStyle, cursor: "pointer", padding: "6px 0", borderTop: "1px solid var(--theme-border,#222)" } }, [
|
|
1379
|
+
react.createElement("input", {
|
|
1380
|
+
key: "c", type: "checkbox", checked: overwrite,
|
|
1381
|
+
"data-skill-tag-push-overwrite": "1",
|
|
1382
|
+
onChange: (e) => setOverwrite(e.target.checked),
|
|
1383
|
+
}),
|
|
1384
|
+
react.createElement("span", { key: "t", style: { color: "#e67e22" } },
|
|
1385
|
+
`同时覆盖那 ${configured.length} 个已有配置的对话(会抹掉它们各自的调整)`),
|
|
1386
|
+
]),
|
|
1387
|
+
react.createElement("div", { key: "cnt", style: { fontSize: "12px", color: "#888", marginTop: "8px" } },
|
|
1388
|
+
targetCount === 0
|
|
1389
|
+
? "没有需要下发的对话。勾选上面的复选框可以覆盖已配置的对话。"
|
|
1390
|
+
: `将实际写入 ${targetCount} 个对话`),
|
|
1391
|
+
]),
|
|
1392
|
+
react.createElement("div", { key: "f", style: { display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "14px" } }, [
|
|
1393
|
+
react.createElement("button", { key: "c", style: btn, disabled: running, onClick: onClose }, "取消"),
|
|
1394
|
+
react.createElement("button", {
|
|
1395
|
+
key: "go", style: { ...btn, background: "#e67e22", color: "#fff", borderColor: "#e67e22" },
|
|
1396
|
+
disabled: running || busy || loading || !!err || targetCount === 0,
|
|
1397
|
+
"data-skill-tag-push-confirm": "1",
|
|
1398
|
+
onClick: () => void run(),
|
|
1399
|
+
}, running ? "下发中…" : `确认下发(${targetCount})`),
|
|
1400
|
+
]),
|
|
1401
|
+
]),
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1115
1404
|
// ── 双列主界面(工作区版 / 对话版共用) ─────────────────────────
|
|
1116
1405
|
//
|
|
1117
1406
|
// 两个作用域的唯一差别是四组端点 + 一个「继承工作区配置」按钮:
|
|
@@ -1158,6 +1447,8 @@ window.__ModuleLoader__.load({
|
|
|
1158
1447
|
const [pickOpen, setPickOpen] = react.useState(false);
|
|
1159
1448
|
const [disabledOpen, setDisabledOpen] = react.useState(false);
|
|
1160
1449
|
const [tagOfSkill, setTagOfSkill] = react.useState(null);
|
|
1450
|
+
/** 「一键下发」确认框(只在工作区版用) */
|
|
1451
|
+
const [pushOpen, setPushOpen] = react.useState(false);
|
|
1161
1452
|
/** 右列技能搜索词(对话版 / 工作区版共用;只影响列表显示,不影响下发结果) */
|
|
1162
1453
|
const [skillSearch, setSkillSearch] = react.useState("");
|
|
1163
1454
|
|
|
@@ -1275,6 +1566,14 @@ window.__ModuleLoader__.load({
|
|
|
1275
1566
|
key: "set", style: btn, disabled: busy,
|
|
1276
1567
|
onClick: () => setSettingsOpen(true),
|
|
1277
1568
|
}, "tag 设置"),
|
|
1569
|
+
// 「一键下发」只在工作区版出现:把当前工作区配置推给该工作区下所有对话。
|
|
1570
|
+
// 与对话版的「继承」是同一个数据动作,区别是批量 + 带选择性覆盖。
|
|
1571
|
+
!scope.isConv && react.createElement("button", {
|
|
1572
|
+
key: "push", style: { ...btn, color: "#e67e22" }, disabled: busy,
|
|
1573
|
+
title: "把本工作区的标签条件 / 手动添加 / 禁用一次性下发到该工作区下的所有对话",
|
|
1574
|
+
"data-skill-tag-push": "1",
|
|
1575
|
+
onClick: () => setPushOpen(true),
|
|
1576
|
+
}, "下发到所有对话"),
|
|
1278
1577
|
// 「继承工作区配置」只在对话版出现:把当前工作区的三件套整体搬过来
|
|
1279
1578
|
scope.isConv && react.createElement("button", {
|
|
1280
1579
|
key: "inh", style: { ...btn, color: "#3498db" }, disabled: busy,
|
|
@@ -1376,6 +1675,25 @@ window.__ModuleLoader__.load({
|
|
|
1376
1675
|
},
|
|
1377
1676
|
}),
|
|
1378
1677
|
|
|
1678
|
+
// ── 弹窗:一键下发到所有对话(工作区版) ──
|
|
1679
|
+
!scope.isConv && pushOpen && react.createElement(PushToConversationsDialog, {
|
|
1680
|
+
key: "push",
|
|
1681
|
+
workspaceId: scope.id,
|
|
1682
|
+
workspaceEmpty: !view.hasFilter && (ws?.manualSkills ?? []).length === 0 && (ws?.disabledSkills ?? []).length === 0,
|
|
1683
|
+
busy,
|
|
1684
|
+
onClose: () => setPushOpen(false),
|
|
1685
|
+
onDone: (d) => {
|
|
1686
|
+
setPushOpen(false);
|
|
1687
|
+
// 服务端返回的是 id 数组(updated / overwritten),界面只关心条数
|
|
1688
|
+
const n = (v) => (Array.isArray(v) ? v.length : (typeof v === "number" ? v : 0));
|
|
1689
|
+
const parts = ["已下发 " + n(d.updated) + " 个对话"];
|
|
1690
|
+
if (n(d.overwritten)) parts.push("其中覆盖 " + n(d.overwritten) + " 个");
|
|
1691
|
+
if (n(d.skipped)) parts.push("跳过 " + n(d.skipped) + " 个已有配置的");
|
|
1692
|
+
setNotice(parts.join(","));
|
|
1693
|
+
void load();
|
|
1694
|
+
},
|
|
1695
|
+
}),
|
|
1696
|
+
|
|
1379
1697
|
// ── 弹窗:被禁用的技能 ──
|
|
1380
1698
|
disabledOpen && react.createElement(DisabledSkillsDialog, {
|
|
1381
1699
|
key: "dis",
|
|
@@ -1708,6 +2026,8 @@ window.__ModuleLoader__.load({
|
|
|
1708
2026
|
workspaceIdOfRowKey, isWorkspaceMenu, manage, manageSet,
|
|
1709
2027
|
manageScope, registerComposerEntry,
|
|
1710
2028
|
skillPathOf, RevealButton, ownedTagsFirst,
|
|
2029
|
+
// 失效技能判定(三处界面共用同一套口径,单独测)
|
|
2030
|
+
staleSetOf, tagHasStale, partitionStaleSkills, STALE_COLOR,
|
|
1711
2031
|
};
|
|
1712
2032
|
return module.exports;
|
|
1713
2033
|
},
|
package/lib/index.js
CHANGED
|
@@ -952,6 +952,14 @@ export async function apply(ctx, config) {
|
|
|
952
952
|
const entity = registry?.get?.(workspaceId);
|
|
953
953
|
return entity?.path ?? null;
|
|
954
954
|
},
|
|
955
|
+
// 工作区 id → 该工作区下的会话 id 列表(「一键下发」要知道发给谁)。
|
|
956
|
+
// 宿主把归属记在 workspace entity 的 sessionIds 上(dsh-workspace)。
|
|
957
|
+
(workspaceId) => {
|
|
958
|
+
const registry = getWorkspaceRegistry(ctx);
|
|
959
|
+
const entity = registry?.get?.(workspaceId);
|
|
960
|
+
const ids = entity?.sessionIds;
|
|
961
|
+
return Array.isArray(ids) ? [...ids] : [];
|
|
962
|
+
},
|
|
955
963
|
);
|
|
956
964
|
} else {
|
|
957
965
|
ctx.logger?.warn?.("[skill-tag] webServer missing — settings API not mounted");
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -33,8 +33,11 @@ function wsShape(ws) {
|
|
|
33
33
|
* @param {((workspaceId: string) => string|null) | null} [resolveWorkspacePath]
|
|
34
34
|
* 工作区 id → 目录路径(宿主 workspaceRegistry entity.path)。用于把
|
|
35
35
|
* 项目级技能根目录扫进来;为 null 时只能用插件自己记录过的 path。
|
|
36
|
+
* @param {((workspaceId: string) => string[]) | null} [resolveWorkspaceSessions]
|
|
37
|
+
* 工作区 id → 该工作区下的会话 id 列表(宿主 workspaceRegistry entity.sessionIds)。
|
|
38
|
+
* 「一键下发」要知道发给谁;为 null 时退回对话自己记录的 sourceWorkspaceId。
|
|
36
39
|
*/
|
|
37
|
-
export function createApiHandler(services, log, listSkills = null, resolveWorkspaceId = null, resolveWorkspacePath = null) {
|
|
40
|
+
export function createApiHandler(services, log, listSkills = null, resolveWorkspaceId = null, resolveWorkspacePath = null, resolveWorkspaceSessions = null) {
|
|
38
41
|
const json = (res, code, obj) => {
|
|
39
42
|
res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
|
|
40
43
|
res.end(JSON.stringify(obj));
|
|
@@ -205,6 +208,20 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
|
|
|
205
208
|
};
|
|
206
209
|
}
|
|
207
210
|
|
|
211
|
+
/**
|
|
212
|
+
* 工作区配置是否"全空"(无条件 + 无手动 + 无禁用)。
|
|
213
|
+
* 下发全空配置 = 让对话掌握全部技能,必须在界面上明确警告。
|
|
214
|
+
*/
|
|
215
|
+
function isEmptyWorkspaceConfig(ws) {
|
|
216
|
+
if (!ws) return true;
|
|
217
|
+
const ors = (ws.config?.orTags ?? []).length;
|
|
218
|
+
const groups = (ws.config?.andGroups ?? []).length;
|
|
219
|
+
const expr = String(ws.expression ?? "").trim();
|
|
220
|
+
return ors === 0 && groups === 0 && expr === ""
|
|
221
|
+
&& (ws.manualSkills ?? []).length === 0
|
|
222
|
+
&& (ws.disabledSkills ?? []).length === 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
208
225
|
/** 对话绑定到哪个工作区(优先宿主 registry,其次对话自己记录的)。 */
|
|
209
226
|
function workspaceIdForConversation(conversationId) {
|
|
210
227
|
const conv = services.conversations.get(conversationId);
|
|
@@ -229,7 +246,46 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
|
|
|
229
246
|
workspaceId: q("workspaceId") ?? null,
|
|
230
247
|
})) ?? []
|
|
231
248
|
: [];
|
|
232
|
-
|
|
249
|
+
// stale = 有标签关联、但这次发现不到的技能(通常是贡献它的插件被关了)。
|
|
250
|
+
// 只**报告**,不删:见 /skills/purge-stale 的注释。
|
|
251
|
+
const live = new Set(skills.map((s) => (typeof s === "string" ? s : s?.name)));
|
|
252
|
+
const stale = [...services._caches.skillLinks.values()]
|
|
253
|
+
.filter((l) => !live.has(l.skillId))
|
|
254
|
+
.map((l) => ({ skillId: l.skillId, tagIds: [...(l.tagIds ?? [])] }));
|
|
255
|
+
return json(res, 200, { ok: true, skills, stale });
|
|
256
|
+
}
|
|
257
|
+
// ── 「刷新技能列表」+「清理失效关联」 ─────────────────────────────
|
|
258
|
+
//
|
|
259
|
+
// 场景:某些技能由**别的插件**贡献(provider 注册进 ctx.skills,根本不落在
|
|
260
|
+
// 任何文件系统根里)。那个插件被 disable 之后,技能立刻消失,但插件这边
|
|
261
|
+
// 的 skillLinks 记录还留着 —— 界面上就表现为"技能还在、数字偏大"。
|
|
262
|
+
//
|
|
263
|
+
// 这里的口径:
|
|
264
|
+
// GET /skills → 实时问宿主,顺带算出 stale(失效关联)
|
|
265
|
+
// POST /skills/purge-stale → 只删这些失效关联记录(用户在界面上显式点)
|
|
266
|
+
//
|
|
267
|
+
// 为什么不在 GET 里顺手删:关联是用户手工打的标签,插件的临时缺席不等于
|
|
268
|
+
// 用户放弃它。默认只标记,删除必须显式触发。
|
|
269
|
+
if (path === "/skills/purge-stale" && method === "POST") {
|
|
270
|
+
const live = new Set(
|
|
271
|
+
((listSkills ? await listSkills(q("cwd") ?? undefined, {
|
|
272
|
+
conversationId: q("conversationId") ?? null,
|
|
273
|
+
workspaceId: q("workspaceId") ?? null,
|
|
274
|
+
}) : []) ?? []).map((s) => (typeof s === "string" ? s : s?.name)),
|
|
275
|
+
);
|
|
276
|
+
// 发现结果为空 = 宿主没就绪,此时"全是失效"是误判,绝不动数据。
|
|
277
|
+
if (live.size === 0) {
|
|
278
|
+
return json(res, 409, { ok: false, code: "NO_SKILLS", error: "当前发现到 0 个技能,拒绝清理(可能是宿主未就绪)" });
|
|
279
|
+
}
|
|
280
|
+
const wanted = Array.isArray(body?.skillIds) ? new Set(body.skillIds.map(String)) : null;
|
|
281
|
+
const removed = [];
|
|
282
|
+
for (const link of [...services._caches.skillLinks.values()]) {
|
|
283
|
+
if (live.has(link.skillId)) continue; // 还活着 → 不动
|
|
284
|
+
if (wanted && !wanted.has(link.skillId)) continue; // 用户只勾了部分
|
|
285
|
+
removed.push(link.skillId);
|
|
286
|
+
}
|
|
287
|
+
for (const skillId of removed) await services.tags.unlinkSkill(skillId);
|
|
288
|
+
return json(res, 200, { ok: true, removed, count: removed.length });
|
|
233
289
|
}
|
|
234
290
|
// ── 「打开技能所在文件夹」 ─────────────────────────────────────
|
|
235
291
|
// 宿主没有暴露 shell/reveal 能力,由 src/reveal.js 自己拉起文件管理器。
|
|
@@ -464,6 +520,52 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
|
|
|
464
520
|
});
|
|
465
521
|
}
|
|
466
522
|
|
|
523
|
+
// ── 「一键下发工作区配置到该工作区下所有对话」 ────────────────
|
|
524
|
+
//
|
|
525
|
+
// 动机:工作区配置改完,散落的对话各自为政。逐个点「继承」太麻烦,
|
|
526
|
+
// 于是提供一个批量下发。但**批量破坏性操作必须有护栏**:
|
|
527
|
+
// · preview(GET/POST /workspaces/push-preview)只试算,不写;
|
|
528
|
+
// · push 默认 overwrite=false,只填「从未配置过」的对话;
|
|
529
|
+
// · 已配置的对话必须显式 overwrite=true 才覆盖(界面二次勾选确认)。
|
|
530
|
+
//
|
|
531
|
+
// 这是 0.1.9 那轮修复的直接延续:用户改的东西不能被无差别覆盖。
|
|
532
|
+
if ((path === "/workspaces/push-preview" || path === "/workspaces/push") && (method === "GET" || method === "POST")) {
|
|
533
|
+
const wid = q("workspaceId");
|
|
534
|
+
if (!wid) return json(res, 400, { ok: false, error: "缺少 workspaceId" });
|
|
535
|
+
// 该工作区下的对话:宿主 sessionIds 是权威来源。
|
|
536
|
+
// resolveWorkspaceSessions 没注入时退回对话自己记录的 sourceWorkspaceId。
|
|
537
|
+
let ids = [];
|
|
538
|
+
if (typeof resolveWorkspaceSessions === "function") {
|
|
539
|
+
try { ids = resolveWorkspaceSessions(wid) ?? []; } catch { ids = []; }
|
|
540
|
+
}
|
|
541
|
+
if (!Array.isArray(ids)) ids = [];
|
|
542
|
+
// 并入已存在的 own.sourceWorkspaceId 指向本工作区的对话(宿主列表可能不含历史会话)
|
|
543
|
+
for (const c of services._caches.conversations.values()) {
|
|
544
|
+
if (c?.own?.sourceWorkspaceId === wid) ids.push(c.conversationId);
|
|
545
|
+
}
|
|
546
|
+
ids = [...new Set(ids.filter(Boolean))];
|
|
547
|
+
|
|
548
|
+
if (path === "/workspaces/push-preview") {
|
|
549
|
+
const preview = services.conversations.previewPushWorkspace(ids);
|
|
550
|
+
const ws = services.workspaces.get(wid);
|
|
551
|
+
return json(res, 200, {
|
|
552
|
+
ok: true,
|
|
553
|
+
workspaceId: wid,
|
|
554
|
+
...preview,
|
|
555
|
+
// 工作区当前是否为空(空 = 下发等于"掌握全部技能",界面要明确警告)
|
|
556
|
+
workspaceEmpty: isEmptyWorkspaceConfig(ws),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const overwrite = body?.overwrite === true;
|
|
561
|
+
try {
|
|
562
|
+
const out = await services.conversations.pushWorkspaceToConversations(wid, ids, { overwrite });
|
|
563
|
+
return json(res, 200, { ok: true, workspaceId: wid, ...out });
|
|
564
|
+
} catch (error) {
|
|
565
|
+
return json(res, 400, { ok: false, error: String(error?.message ?? error) });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
467
569
|
// ── 「对话 → 管理标签」双列界面 ────────────────────────────────
|
|
468
570
|
// 对话拥有**自己的**配置快照(own):标签条件 + 手动添加 + 禁用。
|
|
469
571
|
// 工作区配置不会自动流入对话,除非:新建对话(自动继承一次)或
|
|
@@ -525,13 +627,13 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
|
|
|
525
627
|
}
|
|
526
628
|
|
|
527
629
|
/** Mount the handler on a cordis ctx (inject webServer). */
|
|
528
|
-
export function mountApi(ctx, services, listSkills = null, resolveWorkspaceId = null, resolveWorkspacePath = null) {
|
|
630
|
+
export function mountApi(ctx, services, listSkills = null, resolveWorkspaceId = null, resolveWorkspacePath = null, resolveWorkspaceSessions = null) {
|
|
529
631
|
ctx.effect(
|
|
530
632
|
() =>
|
|
531
633
|
ctx.webServer.register({
|
|
532
634
|
kind: "prefix",
|
|
533
635
|
path: "/skill-tag/api",
|
|
534
|
-
handler: createApiHandler(services, ctx.logger, listSkills, resolveWorkspaceId, resolveWorkspacePath),
|
|
636
|
+
handler: createApiHandler(services, ctx.logger, listSkills, resolveWorkspaceId, resolveWorkspacePath, resolveWorkspaceSessions),
|
|
535
637
|
}),
|
|
536
638
|
"skill-tag: api",
|
|
537
639
|
);
|
package/src/service.js
CHANGED
|
@@ -701,6 +701,54 @@ export function createServices(repo, opts = {}) {
|
|
|
701
701
|
return Boolean(conversations.get(conversationId)?.own);
|
|
702
702
|
},
|
|
703
703
|
|
|
704
|
+
/**
|
|
705
|
+
* 「一键下发到工作区下所有对话」:把工作区当前三件套批量写进一批对话的 own。
|
|
706
|
+
*
|
|
707
|
+
* 与 inheritFromWorkspace 的区别是**批量**且带**选择性覆盖**:
|
|
708
|
+
* overwrite=false(默认)→ 只填 own 为 null 的对话(从未配置过);
|
|
709
|
+
* overwrite=true → 连已配置的也一起覆盖(界面要求二次确认)。
|
|
710
|
+
*
|
|
711
|
+
* 为什么默认不覆盖:已配置的对话要么是出生时继承的快照,要么是用户手工
|
|
712
|
+
* 调过的。无差别覆盖会抹掉用户意志 —— 0.1.9 那次修复的核心教训。
|
|
713
|
+
*
|
|
714
|
+
* @param {string} workspaceId
|
|
715
|
+
* @param {string[]} conversationIds 该工作区下的对话(由宿主 sessionIds 解析)
|
|
716
|
+
* @param {{overwrite?: boolean}} [opts]
|
|
717
|
+
* @returns {Promise<{updated: string[], skipped: string[], overwritten: string[]}>}
|
|
718
|
+
*/
|
|
719
|
+
async pushWorkspaceToConversations(workspaceId, conversationIds, opts = {}) {
|
|
720
|
+
const overwrite = opts.overwrite === true;
|
|
721
|
+
const ws = workspaces.get(workspaceId);
|
|
722
|
+
if (!ws) throw new Error(`workspace '${workspaceId}' not found`);
|
|
723
|
+
const updated = [];
|
|
724
|
+
const skipped = [];
|
|
725
|
+
const overwritten = [];
|
|
726
|
+
for (const cid of [...new Set(conversationIds ?? [])].filter(Boolean)) {
|
|
727
|
+
const conv = await this.ensureOwn(cid);
|
|
728
|
+
const hadOwn = Boolean(conv.own);
|
|
729
|
+
if (hadOwn && !overwrite) { skipped.push(cid); continue; }
|
|
730
|
+
conv.own = ownConfigFromWorkspace(ws);
|
|
731
|
+
if (!conv.inherited.workspaceId) conv.inherited.workspaceId = ws.workspaceId;
|
|
732
|
+
await repo.putConversation(serializeConversation(conv));
|
|
733
|
+
updated.push(cid);
|
|
734
|
+
if (hadOwn) overwritten.push(cid);
|
|
735
|
+
}
|
|
736
|
+
return { updated, skipped, overwritten };
|
|
737
|
+
},
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* 试算「一键下发」会影响哪些对话(不写任何数据)。
|
|
741
|
+
* 界面在确认框里展示这个结果,用户看清范围再决定。
|
|
742
|
+
*/
|
|
743
|
+
previewPushWorkspace(conversationIds) {
|
|
744
|
+
const fresh = [];
|
|
745
|
+
const configured = [];
|
|
746
|
+
for (const cid of [...new Set(conversationIds ?? [])].filter(Boolean)) {
|
|
747
|
+
(this.isConfigured(cid) ? configured : fresh).push(cid);
|
|
748
|
+
}
|
|
749
|
+
return { fresh, configured, total: fresh.length + configured.length };
|
|
750
|
+
},
|
|
751
|
+
|
|
704
752
|
/** 对话自己的配置快照(只读视图;保证字段完整) */
|
|
705
753
|
own(conversationId) {
|
|
706
754
|
const conv = conversations.get(conversationId);
|