dsh-skill-tag 0.1.8 → 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.
- package/README.md +2 -2
- package/docs//345/257/271/350/257/235/347/256/241/347/220/206/346/240/207/347/255/276.md +49 -18
- package/docs//346/212/200/350/203/275/345/217/221/347/216/260/344/270/216/345/256/277/344/270/273/345/257/271/351/275/220.md +55 -0
- package/lib/client.js +241 -15
- package/lib/index.js +106 -33
- package/package.json +1 -1
- package/src/api.js +64 -3
- package/src/service.js +141 -24
- package/src/storage/repository.js +6 -0
- package/src/storage/schema.js +10 -0
package/README.md
CHANGED
|
@@ -20,8 +20,8 @@ office * plugin # 只要 office 包带来的、且是插件相关
|
|
|
20
20
|
| **全局标签** | 建/改名/删除标签,标签之间无层级 |
|
|
21
21
|
| **标签组模板** | 把表达式存成模板(`前端组`、`只留本机技能`…),可复用、可求值 |
|
|
22
22
|
| **布尔表达式** | `+`=OR、`*`=AND、`*` 优先、支持括号;改标签名会自动改写所有引用它的表达式 |
|
|
23
|
-
| **工作区级筛选** |
|
|
24
|
-
| **对话级筛选** |
|
|
23
|
+
| **工作区级筛选** | 每个工作区一套条件,作为该工作区**新建对话**的初始模板;改工作区不影响已存在的对话 |
|
|
24
|
+
| **对话级筛选** | 每个对话有**自己的**快照:新建时继承一次,之后冻结;未配置过的对话 = 空条件 = 全部技能;可一键「继承工作区配置」重新拉取 |
|
|
25
25
|
| **禁用 / 手动添加** | 在标签命中之外额外放行某个技能,或强行屏蔽某个(即使它命中) |
|
|
26
26
|
| **技能 ↔ 标签双向编辑** | 「tag 联系 skill」和「skill 联系 tag」两个方向都能改 |
|
|
27
27
|
| **打开技能所在文件夹** | 任何列出技能的地方都有 📁 按钮,一点即在文件管理器里定位 |
|
|
@@ -20,10 +20,35 @@
|
|
|
20
20
|
| 对话自己的禁用/手动添加 | **存在对话自己身上**(每对话独立,不影响工作区、不影响其他对话) |
|
|
21
21
|
| 继承的覆盖范围 | **标签条件 + 手动添加 + 禁用,整体覆盖**(不是合并) |
|
|
22
22
|
| 可见性规则 | 与工作区版相同:空条件 = 全部技能;有条件 = 标签命中 ∪ 手动添加 − 被禁用 |
|
|
23
|
+
| 打开管理标签 | **纯读,不写入任何配置**(2026-10-31 修正,见 §1.1) |
|
|
23
24
|
|
|
24
25
|
> **解耦原则**:对话一旦有了自己的配置快照(`own`),它就是这个对话的权威口径,
|
|
25
26
|
> 哪怕这份快照是"空的"——空代表用户主动清空过条件,此时再跟着工作区漂移就是错的。
|
|
26
|
-
> 见 `lib/index.js` 的 `effectiveSkillsConfig(own
|
|
27
|
+
> 见 `lib/index.js` 的 `effectiveSkillsConfig(own)`。
|
|
28
|
+
|
|
29
|
+
### 1.1 三种对话的统一口径(2026-10-31 用户确认)
|
|
30
|
+
|
|
31
|
+
运行时只存在三种对话,判定与行为如下:
|
|
32
|
+
|
|
33
|
+
| # | 类型 | 判据 | `own` | 可见技能 | 工作区再改时 |
|
|
34
|
+
| --- | --- | --- | --- | --- | --- |
|
|
35
|
+
| ① | 插件**安装前**就存在 | `session.header.createdAt < installed_at` | 保持 `null` | 空条件 = **全部技能** | 不受影响 |
|
|
36
|
+
| ② | 刚创建的新对话 | 首次被观察时 `createdAt >= installed_at` | 出生时继承一次 | 继承工作区三件套 | 不再跟随 |
|
|
37
|
+
| ③ | 安装后创建、已存在一阵 | 同 ②,但已播过种 | 已冻结 | 冻结值不变 | 不受影响 |
|
|
38
|
+
|
|
39
|
+
**两条不变量**(改代码时不可破坏):
|
|
40
|
+
|
|
41
|
+
1. **`own` 只在两个时刻写入**:出生播种、用户点「继承工作区配置」。
|
|
42
|
+
用户手动编辑(tag 设置 / 手动添加 / 禁用)也算显式写入,但只动已有快照。
|
|
43
|
+
**打开「管理标签(对话)」不是写入时机** —— 旧实现会在这里用当前工作区播种,
|
|
44
|
+
导致"看一眼就被冻结",且冻结值取决于点开的先后顺序。
|
|
45
|
+
2. **`own === null` 的语义固定为「从未配置过 = 空条件 = 掌握全部技能」**,
|
|
46
|
+
运行时**绝不**回退到工作区实时配置。工作区退化为「新建对话的初始模板 +
|
|
47
|
+
继承按钮的数据源」,不再实时下发。
|
|
48
|
+
|
|
49
|
+
`installed_at` 存在 `sync_meta` 表(键 `installed_at`),本版首次运行时写入、永不覆盖。
|
|
50
|
+
判定用 `createdAt` 而不是 `agent/created` 事件 —— 宿主对**恢复**已有会话同样会发该事件
|
|
51
|
+
(`dsh-agent` 的 `announce(agent, "startup")` 是唯一路径),事件本身区分不了新建与打开。
|
|
27
52
|
|
|
28
53
|
---
|
|
29
54
|
|
|
@@ -52,10 +77,15 @@ conversationRecord = z.object({
|
|
|
52
77
|
```
|
|
53
78
|
|
|
54
79
|
`own` 是**新增且独立**的一份快照,不复用 `config`(后者是旧的四区派生结果),
|
|
55
|
-
|
|
80
|
+
以免与既有「对话标签」逻辑互相污染。
|
|
81
|
+
|
|
82
|
+
**`own` 缺失时不再补任何东西**(2026-10-31 起):
|
|
83
|
+
|
|
84
|
+
- `ensureOwn()` 只保证对话行存在,**不写 own**;
|
|
85
|
+
- 打开「管理标签(对话)」仍是纯读;
|
|
86
|
+
- 只有 `seedOwnOnBirth()`(出生)与 `inheritFromWorkspace()`(用户点继承)会写入。
|
|
56
87
|
|
|
57
|
-
|
|
58
|
-
- 没绑定工作区 → 空快照(`emptyOwnConfig()`)
|
|
88
|
+
历史行 `own === null` 就保持 `null`,运行时按「空条件 = 全部技能」处理。
|
|
59
89
|
|
|
60
90
|
### 2.2 领域函数(`src/domain/conv.js`)
|
|
61
91
|
|
|
@@ -245,14 +275,10 @@ ctx.inject(["commandUi"], (scope) => scope.effect(() => scope.commandUi.register
|
|
|
245
275
|
`lib/index.js` 的 `refreshBySession(sessionId, cwd)` → `recomputeDenials(...)`:
|
|
246
276
|
|
|
247
277
|
```js
|
|
248
|
-
const convId = sessionId;
|
|
249
|
-
const own = conv?.own ?? null;
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
?? workspaceIdOfPath(registry, cwd) // ← 第一轮兜底,见 5.1
|
|
253
|
-
?? conv?.inherited?.workspaceId;
|
|
254
|
-
const effective = effectiveSkillsConfig(own, ws); // own 优先,即使空快照
|
|
255
|
-
const candidates = candidatesFor({ // ← 不绕 conversations.candidates()
|
|
278
|
+
const convId = sessionId; // 对话主键 = sessionId
|
|
279
|
+
const own = conv?.own ?? null; // 只认 own,**不解析工作区**
|
|
280
|
+
const effective = effectiveSkillsConfig(own); // own 为 null → 空条件(全部技能)
|
|
281
|
+
const candidates = candidatesFor({ // ← 不绕 conversations.candidates()
|
|
256
282
|
skillTagLinks: links, config: effective.config, active: activeTagsOf(effective.config),
|
|
257
283
|
});
|
|
258
284
|
const denied = workspaceDeniedSkills({ ...effective, universe, candidates });
|
|
@@ -360,12 +386,12 @@ ctx.on("domain/changed", (change) => {
|
|
|
360
386
|
| # | 情况 | 期望行为 |
|
|
361
387
|
| --- | --- | --- |
|
|
362
388
|
| 1 | 新建对话且绑定了工作区 | 自动继承一次工作区的条件/手动/禁用 |
|
|
363
|
-
| 2 | 新建对话但没绑工作区 |
|
|
364
|
-
| 3 | 工作区后来改了配置 |
|
|
389
|
+
| 2 | 新建对话但没绑工作区 | `own` 保持 `null`(= 无条件 = 全部技能) |
|
|
390
|
+
| 3 | 工作区后来改了配置 | 已存在的对话**不跟随**(只有点继承才拉取);未配置过的也不跟随 |
|
|
365
391
|
| 4 | 点「继承工作区配置」 | 整体覆盖条件+手动+禁用(不是合并),工作区不受影响 |
|
|
366
392
|
| 5 | 对话没有任何工作区可继承 | HTTP 400「没有可继承的工作区」+ 界面提示 |
|
|
367
|
-
| 6 | 历史对话(无 `own` 字段)首次打开 | `
|
|
368
|
-
| 7 | 历史对话且没有工作区 |
|
|
393
|
+
| 6 | 历史对话(无 `own` 字段)首次打开 | **纯读,不播种**;`own` 仍是 `null` |
|
|
394
|
+
| 7 | 历史对话且没有工作区 | 同上,不写数据 |
|
|
369
395
|
| 8 | 表达式为空 | 合法(无条件),不报 `EMPTY`,可直接保存 |
|
|
370
396
|
| 9 | 表达式只有空格 | 同 8(`trim()` 后为空) |
|
|
371
397
|
| 10 | 表达式非法(`A+`) | 保存按钮禁用 + 错误「定位 第N字符」;HTTP 400 |
|
|
@@ -413,16 +439,21 @@ ctx.on("domain/changed", (change) => {
|
|
|
413
439
|
| B14 | 工作区后续改动不流入已存在的对话 | 同上 |
|
|
414
440
|
| B15 | 继承整体覆盖三件套、且不回写工作区 | 同上 |
|
|
415
441
|
| B16 | 两对话配置互相隔离 | 同上 |
|
|
416
|
-
| B17 |
|
|
442
|
+
| B17 | 落盘跨实例恢复 / 历史行打开后 own 仍为 null | 同上 |
|
|
417
443
|
| B18 | HTTP 全链路(读→继承→改条件→加技能→禁用→恢复) | 同上最后一项 |
|
|
418
444
|
| B19 | 无工作区时继承返回 400 | 同上 |
|
|
419
|
-
| B20 | `effectiveSkillsConfig
|
|
445
|
+
| B20 | `effectiveSkillsConfig`:own 优先 / null → 空条件(**不退回工作区**) | `tests/runtime.test.mjs` |
|
|
420
446
|
| B21 | `workspaceIdOfSession` / `workspaceIdOfPath` 正常与异常 | 同上 |
|
|
421
447
|
| B22 | `getWorkspaceRegistry` 只走 `ctx.get`,不碰 `ctx.workspaceRegistry` 属性 | `tests/runtime.test.mjs` |
|
|
422
448
|
| B23 | 服务缺失 / 属性访问抛异常时 `getWorkspaceRegistry` 返回 `null`,不抛 | 同上 |
|
|
423
449
|
| B24 | `apply()` 在无 `workspaceRegistry` 的最小 cordis 面不抛 | 同上 |
|
|
424
450
|
| B25 | **`domain/changed` 监听器内部抛错不得逃逸**(否则 host 直接崩) | 同上 |
|
|
425
451
|
| B26 | `createFilterProvider` 上游抛错 → `list` 回 `[]`、`get` 回 `undefined` | 同上 |
|
|
452
|
+
| B27 | 出生播种幂等:已有 own 绝不覆盖;工作区不存在时不写 | `tests/conversation.test.mjs` |
|
|
453
|
+
| B28 | 改标签名 → 所有对话快照的表达式同步改写 | 同上 |
|
|
454
|
+
| B29 | 全局删标签 → 对话快照不留悬空 tagId | 同上 |
|
|
455
|
+
| B30 | `installed_at` 首次写入后永不覆盖、跨实例恢复 | 同上 |
|
|
456
|
+
| B31 | 界面:从未配置显示「尚未配置过」;表达式失效显示只读提示 | `tests/__conv-render.mjs` |
|
|
426
457
|
| B27 | `createUpstreamAdapter` 的扫描/host 各自挂掉时仍返回另一边 | 同上 |
|
|
427
458
|
| B28 | **新对话首次 `list()` 就必须已过滤**(`ensureFresh` 在读路径上被调用) | 同上(变异验证:去掉则红) |
|
|
428
459
|
| B29 | `ensureFresh` 抛错时降级沿用旧集合,不冒回宿主 | 同上 |
|
|
@@ -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
|
@@ -33,7 +33,60 @@ window.__ModuleLoader__.load({
|
|
|
33
33
|
return r.json();
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
// ── pure view-model helpers
|
|
36
|
+
// ── pure view-model helpers ────────────────────────────────────
|
|
37
|
+
//
|
|
38
|
+
// fmtLocalTime:把服务端存的 ISO-8601(**始终是 UTC,带 Z**)渲染成本地
|
|
39
|
+
// 时间。曾经这里直接 `String(iso).replace("T", " ").slice(0, 19)`——
|
|
40
|
+
// slice(0,19) 把结尾的 Z 连同时区信息一起切掉了,于是 UTC 被当成本地
|
|
41
|
+
// 时间显示,东八区整整偏 8 小时("凌晨 2 点"其实是上午 10 点)。
|
|
42
|
+
// 一律走 Date 解析 + 本地字段拼接,不做字符串截断。
|
|
43
|
+
function fmtLocalTime(iso) {
|
|
44
|
+
if (iso === null || iso === undefined || iso === "") return "";
|
|
45
|
+
const d = new Date(iso);
|
|
46
|
+
if (Number.isNaN(d.getTime())) return String(iso); // 非法值原样显示,不编造时间
|
|
47
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
48
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
|
|
49
|
+
+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
50
|
+
}
|
|
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
|
+
|
|
37
90
|
// zoneOf: §7 four-zone classification of a conversation tag entry.
|
|
38
91
|
function zoneOf(entry) {
|
|
39
92
|
if (entry.blocked) return "blocked";
|
|
@@ -163,6 +216,9 @@ window.__ModuleLoader__.load({
|
|
|
163
216
|
function Section() {
|
|
164
217
|
const [state, setState] = react.useState({
|
|
165
218
|
loading: true, error: null, data: null, skills: [],
|
|
219
|
+
// stale = 有标签关联、但当前发现不到的技能(贡献它的插件被关了)。
|
|
220
|
+
// 只标记,不自动删除 —— 用户显式点「清理失效关联」才动数据。
|
|
221
|
+
stale: [],
|
|
166
222
|
newTagName: "", busy: false, notice: null,
|
|
167
223
|
// sub-page routing: "main" | "links" | "skilltags" | "templates"
|
|
168
224
|
view: "main",
|
|
@@ -188,6 +244,7 @@ window.__ModuleLoader__.load({
|
|
|
188
244
|
error: d?.ok ? null : JSON.stringify(d),
|
|
189
245
|
data: d?.ok ? d : null,
|
|
190
246
|
skills: sk?.ok ? sk.skills : [],
|
|
247
|
+
stale: sk?.ok ? (sk.stale ?? []) : [],
|
|
191
248
|
}));
|
|
192
249
|
} catch (e) {
|
|
193
250
|
setState((s) => ({ ...s, loading: false, error: String(e), data: null }));
|
|
@@ -209,6 +266,42 @@ window.__ModuleLoader__.load({
|
|
|
209
266
|
}
|
|
210
267
|
}, [reload]);
|
|
211
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
|
+
|
|
212
305
|
const { loading, error, data, busy, notice } = state;
|
|
213
306
|
if (loading) return react.createElement("div", { style: pad }, "加载中…");
|
|
214
307
|
if (error) return react.createElement("div", { style: pad }, "加载失败: " + error);
|
|
@@ -244,7 +337,32 @@ window.__ModuleLoader__.load({
|
|
|
244
337
|
react.createElement("button", {
|
|
245
338
|
style: { ...btn, padding: "6px 16px", fontSize: "13px" },
|
|
246
339
|
onClick: () => setState((s) => ({ ...s, view: "templates", tplId: null, tplIsNew: false, tplName: "", tplExpression: "" })),
|
|
247
|
-
}, "编辑标签组")
|
|
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
|
+
}, "清理失效关联")]),
|
|
248
366
|
react.createElement(TagCreateForm, { key: "create", state, setState, act, busy }),
|
|
249
367
|
react.createElement(TagList, { key: "list", data, act, busy, state, setState }),
|
|
250
368
|
]);
|
|
@@ -280,6 +398,8 @@ window.__ModuleLoader__.load({
|
|
|
280
398
|
const links = data.skillLinks ?? [];
|
|
281
399
|
const workspaces = data.workspaces ?? [];
|
|
282
400
|
const catalog = state.skills ?? [];
|
|
401
|
+
// 失效技能集合:决定"哪些标签名要标红"以及"展开后哪些名字置底标红"。
|
|
402
|
+
const staleSet = staleSetOf(state);
|
|
283
403
|
// 每个标签默认折叠,只显示数量;点「▸」才展开下面的技能名。
|
|
284
404
|
// 本机 96 个技能 / 34 个标签,全展开会让这一页变成几百行的墙。
|
|
285
405
|
const open = state.openTags ?? {};
|
|
@@ -292,6 +412,9 @@ window.__ModuleLoader__.load({
|
|
|
292
412
|
tags.length === 0 && react.createElement("div", { key: "empty", style: { color: "#888" } }, "暂无标签"),
|
|
293
413
|
...tags.map((t) => {
|
|
294
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;
|
|
295
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);
|
|
296
419
|
const isOpen = !!open[t.tagId];
|
|
297
420
|
return react.createElement("div", { key: t.tagId, style: { padding: "4px 0", borderBottom: "1px solid var(--theme-border,#222)" } }, [
|
|
@@ -305,12 +428,21 @@ window.__ModuleLoader__.load({
|
|
|
305
428
|
"data-skill-tag-toggle": t.tagId,
|
|
306
429
|
onClick: () => toggleOpen(t.tagId),
|
|
307
430
|
}, isOpen ? "▾" : "▸"),
|
|
308
|
-
|
|
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),
|
|
309
439
|
react.createElement("span", { key: "id", style: { color: "#888", fontSize: "11px" } }, t.tagId),
|
|
310
440
|
react.createElement("span", {
|
|
311
441
|
key: "cnt", style: { color: "#888", fontSize: "11px" },
|
|
312
442
|
"data-skill-tag-count": t.tagId,
|
|
313
|
-
}, skills.length === 0
|
|
443
|
+
}, skills.length === 0
|
|
444
|
+
? "无 Skill 关联"
|
|
445
|
+
: `${skills.length} 个 Skill` + (hasStale ? `(${split.stale.length} 失效)` : "")),
|
|
314
446
|
react.createElement("button", {
|
|
315
447
|
key: "del", style: { ...btn, color: "#e74c3c" }, disabled: busy,
|
|
316
448
|
onClick: () => {
|
|
@@ -321,10 +453,18 @@ window.__ModuleLoader__.load({
|
|
|
321
453
|
]),
|
|
322
454
|
// 展开后才列出技能(每行仍带「打开文件夹」,这里也是"列出 skill 的界面")
|
|
323
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" } }, [
|
|
324
|
-
|
|
456
|
+
// 存活技能在前
|
|
457
|
+
...split.alive.map((sid, i) => react.createElement("span", { key: sid + i, style: { display: "inline-flex", gap: "2px", alignItems: "center" } }, [
|
|
325
458
|
sid,
|
|
326
459
|
react.createElement(RevealButton, { key: "rev", skillName: sid, path: skillPathOf(catalog, sid), label: "📁", style: { padding: "0 4px", fontSize: "10px" } }),
|
|
327
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])),
|
|
328
468
|
]),
|
|
329
469
|
]);
|
|
330
470
|
}),
|
|
@@ -399,7 +539,10 @@ window.__ModuleLoader__.load({
|
|
|
399
539
|
const sel = tags.find((t) => t.tagId === selId) ?? null;
|
|
400
540
|
const tagMatch = (t) => !state.tagSearch.trim() || t.name.toLowerCase().includes(state.tagSearch.trim().toLowerCase());
|
|
401
541
|
const skillMatch = (s) => !state.skillSearch.trim() || s.skillId.toLowerCase().includes(state.skillSearch.trim().toLowerCase());
|
|
542
|
+
// 失效技能集合:标签名标红 + 右列技能列表分区(存活在前、失效置底标红)
|
|
543
|
+
const staleSet = staleSetOf(state);
|
|
402
544
|
const linkedSkills = skillsLinkedTo(links, selId).filter(skillMatch);
|
|
545
|
+
const linkedSplit = partitionStaleSkills(linkedSkills, staleSet);
|
|
403
546
|
const createTag = async () => {
|
|
404
547
|
const name = state.newTagName.trim();
|
|
405
548
|
if (!name) return;
|
|
@@ -436,12 +579,18 @@ window.__ModuleLoader__.load({
|
|
|
436
579
|
tags.filter(tagMatch).length === 0 && react.createElement("div", { key: "e", style: { color: "#888", fontSize: "12px", padding: "6px 2px" } }, "无匹配标签"),
|
|
437
580
|
...tags.filter(tagMatch).map((t) => {
|
|
438
581
|
const n = links.filter((l) => l.tagIds.includes(t.tagId)).length;
|
|
582
|
+
// 含失效技能的标签:只把**标签名**标红(图三的要求),其余样式不变
|
|
583
|
+
const hasStale = tagHasStale(links, t.tagId, staleSet);
|
|
439
584
|
return react.createElement("div", {
|
|
440
585
|
key: t.tagId, style: t.tagId === selId ? selRowStyle : rowStyle,
|
|
441
586
|
onClick: () => setState((s) => ({ ...s, linkTagId: t.tagId })),
|
|
442
|
-
title: "点击选中",
|
|
587
|
+
title: hasStale ? "该标签含失效技能(当前发现不到),点开可见" : "点击选中",
|
|
443
588
|
}, [
|
|
444
|
-
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),
|
|
445
594
|
react.createElement("span", { key: "c", style: { color: "#888", fontSize: "11px" } }, n ? `${n} skill` : ""),
|
|
446
595
|
react.createElement("button", {
|
|
447
596
|
key: "del", style: { ...btn, color: "#e74c3c", padding: "1px 8px" }, disabled: busy,
|
|
@@ -479,7 +628,8 @@ window.__ModuleLoader__.load({
|
|
|
479
628
|
}),
|
|
480
629
|
react.createElement("div", { key: "rows", style: { maxHeight: "340px", overflowY: "auto" } }, [
|
|
481
630
|
linkedSkills.length === 0 && react.createElement("div", { key: "e", style: { color: "#888", fontSize: "12px", padding: "6px 2px" } }, "该标签暂未关联任何 Skill"),
|
|
482
|
-
|
|
631
|
+
// 存活技能在前
|
|
632
|
+
...linkedSplit.alive.map((s) => {
|
|
483
633
|
const ghost = !skills.some((x) => x.name === s.skillId);
|
|
484
634
|
return react.createElement("div", { key: s.skillId, style: rowStyle, title: ghost ? "未在本机扫描到(关联仍生效)" : "" }, [
|
|
485
635
|
react.createElement("span", { key: "n", style: { flex: 1 } }, s.skillId + (ghost ? " *" : "")),
|
|
@@ -490,6 +640,20 @@ window.__ModuleLoader__.load({
|
|
|
490
640
|
}, "移除"),
|
|
491
641
|
]);
|
|
492
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
|
+
])),
|
|
493
657
|
]),
|
|
494
658
|
react.createElement("div", { key: "add", style: { display: "flex", gap: "8px", marginTop: "10px", paddingTop: "8px", borderTop: "1px solid var(--theme-border,#222)" } }, [
|
|
495
659
|
react.createElement("button", {
|
|
@@ -518,11 +682,18 @@ window.__ModuleLoader__.load({
|
|
|
518
682
|
const scanned = state.skills ?? [];
|
|
519
683
|
const selName = state.linkSkillName;
|
|
520
684
|
|
|
521
|
-
//
|
|
685
|
+
// 技能清单:**只列当前真正存在的技能**(scanned = 这次刷新发现到的)。
|
|
686
|
+
//
|
|
687
|
+
// 2026-10-31 改:此前会把"只存在连接记录"的幽灵技能也算进 `技能 (N)`,
|
|
688
|
+
// 于是关掉贡献技能的插件后,数字仍然不变(本机 82 真 + 25 幽灵 = 107)。
|
|
689
|
+
// 现在幽灵单独归类为 stale,计数只算真技能 —— 用户说的"更新列表和数目"。
|
|
522
690
|
const names = [];
|
|
523
691
|
const seen = new Set();
|
|
524
692
|
for (const s of scanned) { if (s?.name && !seen.has(s.name)) { names.push({ name: s.name, ghost: false }); seen.add(s.name); } }
|
|
525
|
-
|
|
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));
|
|
526
697
|
const match = (n) => !state.skillPickSearch.trim() || n.toLowerCase().includes(state.skillPickSearch.trim().toLowerCase());
|
|
527
698
|
const shown = names.filter((n) => match(n.name));
|
|
528
699
|
|
|
@@ -552,12 +723,28 @@ window.__ModuleLoader__.load({
|
|
|
552
723
|
react.createElement("button", { key: "back", style: btn, disabled: busy, onClick: () => setState((s) => ({ ...s, view: "main", linkSkillName: null })) }, "← 返回"),
|
|
553
724
|
react.createElement("h3", { key: "t", style: { margin: 0 } }, "skill 联系 tag"),
|
|
554
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
|
+
}, "刷新技能列表"),
|
|
555
736
|
]),
|
|
556
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}」当前发现不到(通常是贡献它的插件被关闭)。标签关联仍保留,重新启用插件后自动恢复可见。`),
|
|
557
743
|
react.createElement("div", { key: "cols", style: { display: "flex", gap: "12px", alignItems: "flex-start" } }, [
|
|
558
744
|
// ── 左列:技能 ──────────────────────────────────────────
|
|
559
745
|
react.createElement("div", { key: "left", style: colStyle }, [
|
|
560
|
-
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}` : "")),
|
|
561
748
|
react.createElement("input", {
|
|
562
749
|
key: "search", style: { ...input, width: "100%", marginBottom: "6px" }, placeholder: "搜索技能…",
|
|
563
750
|
value: state.skillPickSearch, onChange: (e) => setState((s) => ({ ...s, skillPickSearch: e.target.value })),
|
|
@@ -578,6 +765,31 @@ window.__ModuleLoader__.load({
|
|
|
578
765
|
]);
|
|
579
766
|
}),
|
|
580
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
|
+
]),
|
|
581
793
|
]),
|
|
582
794
|
// ── 右列:该技能的标签 ──────────────────────────────────
|
|
583
795
|
react.createElement("div", { key: "right", style: colStyle }, [
|
|
@@ -1222,6 +1434,10 @@ window.__ModuleLoader__.load({
|
|
|
1222
1434
|
|
|
1223
1435
|
const inheritedFrom = ws?.sourceWorkspaceId;
|
|
1224
1436
|
const inheritedAt = ws?.inheritedAt;
|
|
1437
|
+
// configured:该对话是否有自己的配置快照(own)。false = 从未配置过。
|
|
1438
|
+
// expressionIssue:服务端只读体检结果,用于提示「引用了已删除的标签」。
|
|
1439
|
+
const configured = ws?.configured;
|
|
1440
|
+
const expressionIssue = ws?.expressionIssue ?? null;
|
|
1225
1441
|
|
|
1226
1442
|
const body = () => {
|
|
1227
1443
|
if (loading) return react.createElement("div", { style: { color: "#888", padding: "20px 0" } }, "加载中…");
|
|
@@ -1237,11 +1453,19 @@ window.__ModuleLoader__.load({
|
|
|
1237
1453
|
"解析结果: " + parsed),
|
|
1238
1454
|
react.createElement("div", { key: "st", style: { color: "#888", fontSize: "11px", marginTop: "2px" } },
|
|
1239
1455
|
`状态: ${view.hasFilter ? "有条件筛选(严格子集)" : "无筛选(全部技能)"}`),
|
|
1240
|
-
//
|
|
1456
|
+
// 继承状态:让用户知道「这份条件是从哪来的、什么时候来的」
|
|
1457
|
+
// configured=false(own 为 null)= 从未配置过 = 空条件 = 掌握全部技能。
|
|
1458
|
+
// 这不是「继承来源 null」,而是「你还没配过」,所以单独一条文案。
|
|
1241
1459
|
scope.isConv && react.createElement("div", { key: "inh", style: { color: "#888", fontSize: "11px", marginTop: "2px" } },
|
|
1242
|
-
|
|
1243
|
-
?
|
|
1244
|
-
:
|
|
1460
|
+
configured === false
|
|
1461
|
+
? "尚未配置过(未继承工作区;当前为空条件 = 掌握全部技能)"
|
|
1462
|
+
: (inheritedFrom
|
|
1463
|
+
? `继承来源: ${inheritedFrom}${inheritedAt ? " · " + fmtLocalTime(inheritedAt) : ""}(改动不影响工作区)`
|
|
1464
|
+
: "该对话有独立配置(未继承工作区)")),
|
|
1465
|
+
// 只读提示:已冻结的表达式引用了不存在的标签(旧版 bug 遗留)。
|
|
1466
|
+
// 不自动修复 —— 数据是用户意志,告诉他原因即可。
|
|
1467
|
+
scope.isConv && expressionIssue && !expressionIssue.ok && react.createElement("div", { key: "isq", style: { color: "#e67e22", fontSize: "11px", marginTop: "2px" } },
|
|
1468
|
+
`⚠ 表达式引用了已不存在的标签(${expressionIssue.message})。点「tag 设置」可重新选择;保存前需修正。`),
|
|
1245
1469
|
react.createElement("div", { key: "sp", style: { flex: 1 } }),
|
|
1246
1470
|
react.createElement("div", { key: "f", style: { display: "flex", gap: "8px", flexWrap: "wrap", paddingTop: "8px", borderTop: "1px solid var(--theme-border,#222)" } }, [
|
|
1247
1471
|
react.createElement("button", {
|
|
@@ -1681,6 +1905,8 @@ window.__ModuleLoader__.load({
|
|
|
1681
1905
|
workspaceIdOfRowKey, isWorkspaceMenu, manage, manageSet,
|
|
1682
1906
|
manageScope, registerComposerEntry,
|
|
1683
1907
|
skillPathOf, RevealButton, ownedTagsFirst,
|
|
1908
|
+
// 失效技能判定(三处界面共用同一套口径,单独测)
|
|
1909
|
+
staleSetOf, tagHasStale, partitionStaleSkills, STALE_COLOR,
|
|
1684
1910
|
};
|
|
1685
1911
|
return module.exports;
|
|
1686
1912
|
},
|
package/lib/index.js
CHANGED
|
@@ -294,24 +294,40 @@ export function computeDeniedSkillIds(wsConfig, allLinkedSkillIds, computeCandid
|
|
|
294
294
|
/**
|
|
295
295
|
* 运行时生效口径:这个会话到底按哪份配置筛技能?
|
|
296
296
|
*
|
|
297
|
-
* own 存在 →
|
|
298
|
-
* own 为空 →
|
|
299
|
-
* 两者都没有 → null(不筛)
|
|
297
|
+
* own 存在 → 用它(「对话 → 管理标签」的独立配置,已冻结)
|
|
298
|
+
* own 为空 → **空配置 = 掌握全部技能**,绝不回退到工作区实时配置
|
|
300
299
|
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
300
|
+
* ## 为什么不再回退工作区(2026-10-31 起的统一口径)
|
|
301
|
+
*
|
|
302
|
+
* 旧实现是 `pick(own) ?? pick(ws)`:own 为 null 的对话会实时跟着工作区走。
|
|
303
|
+
* 而 own 只有「打开过管理标签」或「点过继承」才会产生,于是同一个操作
|
|
304
|
+
* "改工作区标签"在不同对话上表现完全不同:
|
|
305
|
+
*
|
|
306
|
+
* - 有 own(历史点开过)→ 冻结,不变;
|
|
307
|
+
* - 无 own(从没点开过)→ 实时跟随,跟着改 ← 用户报告的 bug。
|
|
308
|
+
*
|
|
309
|
+
* 用户口径(三种对话):
|
|
310
|
+
* ① 插件安装前就存在 → 未配置过 = 空 = 全部技能,改工作区不影响
|
|
311
|
+
* ② 刚创建的新对话 → 出生时继承一次工作区(own 被写入)
|
|
312
|
+
* ③ 安装后创建、已存在一阵 → ②的 own 已冻结,工作区再改不影响
|
|
313
|
+
*
|
|
314
|
+
* 三者共同点:**own 一旦存在即权威;own 为 null 即"没有筛选"**。
|
|
315
|
+
* 工作区配置退化为「新建对话的初始模板 + 继承按钮的数据源」,不再实时下发。
|
|
303
316
|
*
|
|
304
317
|
* @param {{config?:object, manualSkills?:string[], disabledSkills?:string[]}|null} own
|
|
305
|
-
* @param {
|
|
306
|
-
* @returns {{config:object, manualSkills:string[], disabledSkills:string[]}
|
|
318
|
+
* @param {object|null} [_ws] 已废弃,仅为兼容既有调用签名;**不再参与计算**
|
|
319
|
+
* @returns {{config:object, manualSkills:string[], disabledSkills:string[]}}
|
|
307
320
|
*/
|
|
308
|
-
export function effectiveSkillsConfig(own,
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
321
|
+
export function effectiveSkillsConfig(own, _ws = null) {
|
|
322
|
+
if (!own) {
|
|
323
|
+
// 未配置过 → 空条件 → 全部技能(workspaceSkillView 对空 config 返回 universe)
|
|
324
|
+
return { config: { orTags: [], andGroups: [] }, manualSkills: [], disabledSkills: [] };
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
config: own.config ?? { orTags: [], andGroups: [] },
|
|
328
|
+
manualSkills: own.manualSkills ?? [],
|
|
329
|
+
disabledSkills: own.disabledSkills ?? [],
|
|
313
330
|
};
|
|
314
|
-
return pick(own) ?? pick(ws) ?? null;
|
|
315
331
|
}
|
|
316
332
|
|
|
317
333
|
/**
|
|
@@ -583,6 +599,14 @@ export async function apply(ctx, config) {
|
|
|
583
599
|
const services = createServices(repo, { log: (m) => ctx.logger?.info?.(m) });
|
|
584
600
|
await services.ready();
|
|
585
601
|
|
|
602
|
+
// 里程碑:本版**首次运行**的时刻。三种对话口径的判定基准(见 maybeSeedOwn)。
|
|
603
|
+
// 幂等且永不覆盖:升级/重启不会把老对话误判成"新出生"。
|
|
604
|
+
try {
|
|
605
|
+
await services.meta.ensureInstalledAt();
|
|
606
|
+
} catch (error) {
|
|
607
|
+
ctx.logger?.warn?.(`[skill-tag] installed_at not recorded: ${error?.message ?? error}`);
|
|
608
|
+
}
|
|
609
|
+
|
|
586
610
|
// 3. Session→denied-skill-names live map. Denials derive from the boolean
|
|
587
611
|
// config + per-conversation four-zone state (§11). Recomputed on demand.
|
|
588
612
|
const sessionDenials = new Map(); // sessionId -> Set<string>
|
|
@@ -647,12 +671,73 @@ export async function apply(ctx, config) {
|
|
|
647
671
|
if (refreshing.has(sessionId)) return;
|
|
648
672
|
refreshing.add(sessionId);
|
|
649
673
|
try {
|
|
674
|
+
// 先判「是不是新出生的对话」并播种(只写一次);再算拒绝集。
|
|
675
|
+
// 播种会写库 → 触发 domain/changed → 下一轮 recomputeDenials 用上 own。
|
|
676
|
+
await maybeSeedOwn(sessionId, cwd, scope);
|
|
650
677
|
await recomputeDenials(sessionId, cwd, scope);
|
|
651
678
|
} finally {
|
|
652
679
|
refreshing.delete(sessionId);
|
|
653
680
|
}
|
|
654
681
|
}
|
|
655
682
|
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* 会话的创建时刻(毫秒)。宿主保证 session.header 恒存在且 createdAt 是
|
|
686
|
+
* 非负安全整数(dsh-session/lib/types 校验),所以读不到只可能是极早期
|
|
687
|
+
* 的空壳 agent —— 那种情况一律当作「看不了」,不播种(不写数据最安全)。
|
|
688
|
+
* @returns {number|null}
|
|
689
|
+
*/
|
|
690
|
+
function sessionCreatedAt(agent) {
|
|
691
|
+
try {
|
|
692
|
+
const v = agent?.session?.header?.createdAt;
|
|
693
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
694
|
+
if (typeof v === "string") {
|
|
695
|
+
const t = Date.parse(v);
|
|
696
|
+
if (Number.isFinite(t)) return t;
|
|
697
|
+
}
|
|
698
|
+
} catch { /* header 取不到就当未知 */ }
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* 「出生播种」:**只在新对话的第一次被观察到时**继承一次工作区配置。
|
|
704
|
+
*
|
|
705
|
+
* 三种对话口径(2026-10-31 用户确认)都靠这一个函数落地:
|
|
706
|
+
* ① 插件安装前就存在的对话 → createdAt < installed_at → 直接返回,
|
|
707
|
+
* own 永远保持 null → 空条件 → 掌握全部技能;
|
|
708
|
+
* ② 刚创建的新对话 → createdAt >= installed_at 且 own 为 null → 播种一次;
|
|
709
|
+
* ③ 安装后创建、已存在一阵的对话 → 出生时已播过种,own 存在 → 永不重播。
|
|
710
|
+
*
|
|
711
|
+
* 为什么用 createdAt 而不是 agent/created 事件:宿主对**恢复**已有会话同样
|
|
712
|
+
* 发 agent/created(dsh-agent 的 announce(agent, startup) 是唯一路径),
|
|
713
|
+
* 事件本身区分不了「新建」和「打开旧对话」。用 header 里的出生时刻才可靠。
|
|
714
|
+
*
|
|
715
|
+
* 幂等:seedOwnOnBirth 内部再看一次 own,已有配置绝不覆盖。
|
|
716
|
+
*/
|
|
717
|
+
async function maybeSeedOwn(sessionId, cwd, scope) {
|
|
718
|
+
if (!sessionId || !enabled) return;
|
|
719
|
+
try {
|
|
720
|
+
const installed = services.meta.installedAt();
|
|
721
|
+
if (!installed) return; // 里程碑还没写(极早期),下一轮再说
|
|
722
|
+
const bornAt = sessionCreatedAt(scope);
|
|
723
|
+
if (bornAt === null) return;
|
|
724
|
+
const installedMs = Date.parse(installed);
|
|
725
|
+
if (!Number.isFinite(installedMs)) return;
|
|
726
|
+
if (bornAt < installedMs) return; // ① 安装前就存在 → 永不播种
|
|
727
|
+
if (services.conversations.isConfigured(sessionId)) return; // ③ 已冻结
|
|
728
|
+
// 工作区解析:解析不到就先不写(留空 = 全部技能,语义与「没播」一致,
|
|
729
|
+
// 而且不会把对话钉死)。宿主是在会话跑起来**之后**才 attachSession 的,
|
|
730
|
+
// 出生瞬间常常还查不到,所以这里宁可等下一轮。
|
|
731
|
+
const registry = getWorkspaceRegistry(ctx);
|
|
732
|
+
const wsId = workspaceIdOfSession(registry, sessionId) ?? workspaceIdOfPath(registry, cwd) ?? null;
|
|
733
|
+
if (!wsId) return;
|
|
734
|
+
const seeded = await services.conversations.seedOwnOnBirth(sessionId, wsId);
|
|
735
|
+
if (seeded) ctx.logger?.info?.(`[skill-tag] own seeded on birth session=${sessionId} workspace=${wsId}`);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
try { ctx.logger?.warn?.(`[skill-tag] maybeSeedOwn failed: ${error?.message ?? error}`); } catch { /* ignore */ }
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
656
741
|
async function recomputeDenials(sessionId, cwd, scope = null) {
|
|
657
742
|
const denied = deniedFor(sessionId);
|
|
658
743
|
const before = [...denied].join(",");
|
|
@@ -666,27 +751,15 @@ export async function apply(ctx, config) {
|
|
|
666
751
|
conv = services.conversations.get(convId);
|
|
667
752
|
}
|
|
668
753
|
const own = conv?.own ?? null;
|
|
669
|
-
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
// 3. **cwd → workspace.path 反查**(新加)
|
|
674
|
-
// 4. 对话记录里的 inherited.workspaceId(旧数据兜底)
|
|
754
|
+
// 生效口径(2026-10-31 统一):**只看 own,永不回退到工作区实时配置**。
|
|
755
|
+
//
|
|
756
|
+
// own 存在 → 该对话自己的冻结快照;
|
|
757
|
+
// own 为 null → 从未配置过 = 空条件 = 掌握全部技能。
|
|
675
758
|
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
|
|
679
|
-
//
|
|
680
|
-
// 所以用路径兜底能把第一轮也纳管。
|
|
681
|
-
const wsId = own?.sourceWorkspaceId
|
|
682
|
-
?? workspaceIdOfSession(registry, sessionId)
|
|
683
|
-
?? workspaceIdOfPath(registry, cwd)
|
|
684
|
-
?? conv?.inherited?.workspaceId
|
|
685
|
-
?? null;
|
|
686
|
-
const ws = wsId ? services.workspaces.get(wsId) : null;
|
|
687
|
-
// 生效口径:对话自己的快照优先(own 存在即代表该对话有独立配置,
|
|
688
|
-
// 即使是"空快照"也是用户选择过/新建时继承过的结果);否则退回工作区。
|
|
689
|
-
const effective = effectiveSkillsConfig(own, ws);
|
|
759
|
+
// 工作区只在两个地方还参与:出生播种(maybeSeedOwn)与「继承」按钮。
|
|
760
|
+
// 所以这里不再解析 ws,也不再需要 wsId。
|
|
761
|
+
const effective = effectiveSkillsConfig(own);
|
|
762
|
+
// 空配置 = 不筛 = denied 为空;仍然走一遍(禁用列表非空时还要生效)。
|
|
690
763
|
if (effective) {
|
|
691
764
|
let universe = [];
|
|
692
765
|
try {
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -158,6 +158,20 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
|
|
|
158
158
|
};
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* 该对话已保存的表达式是否仍然有效(引用的标签是否还存在)。
|
|
163
|
+
* 只用于**提示**,绝不修改数据:被冻结的历史快照可能引用了已删除/改名的
|
|
164
|
+
* 标签,用户有权保留原样,但保存时必须能看到原因。
|
|
165
|
+
* @returns {{ok:boolean, message:string|null}}
|
|
166
|
+
*/
|
|
167
|
+
function ownExpressionIssue(c) {
|
|
168
|
+
const expr = String(c?.expression ?? "");
|
|
169
|
+
if (expr.trim() === "") return { ok: true, message: null };
|
|
170
|
+
const v = services.templates.validate(expr, { allowEmpty: true });
|
|
171
|
+
if (v.ok) return { ok: true, message: null };
|
|
172
|
+
return { ok: false, message: v.errors?.[0]?.message ?? "表达式非法" };
|
|
173
|
+
}
|
|
174
|
+
|
|
161
175
|
/**
|
|
162
176
|
* 对话版双列界面的统一载荷(与 /workspaces/skills 同形,前端复用同一个面板)。
|
|
163
177
|
*/
|
|
@@ -172,12 +186,18 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
|
|
|
172
186
|
ok: true,
|
|
173
187
|
conversationId,
|
|
174
188
|
workspaceId,
|
|
189
|
+
// configured=false 表示该对话**从未配置过**(own 为 null):
|
|
190
|
+
// 语义是「空 = 掌握全部技能」,且界面上不该显示成"继承来源为 null"。
|
|
191
|
+
configured: services.conversations.isConfigured(conversationId),
|
|
175
192
|
expression: c?.expression ?? "",
|
|
176
193
|
config: c?.config ?? { orTags: [], andGroups: [] },
|
|
177
194
|
manualSkills: c?.manualSkills ?? [],
|
|
178
195
|
disabledSkills: c?.disabledSkills ?? [],
|
|
179
196
|
inheritedAt: c?.inheritedAt ?? null,
|
|
180
197
|
sourceWorkspaceId: c?.sourceWorkspaceId ?? null,
|
|
198
|
+
// 只读提示:已冻结的表达式引用了不存在的标签(旧版 bug 遗留)。
|
|
199
|
+
// 不自动修复 —— 数据是用户意志,界面上告诉他原因即可。
|
|
200
|
+
expressionIssue: ownExpressionIssue(c),
|
|
181
201
|
candidates: ctxInfo.candidates,
|
|
182
202
|
universe: ctxInfo.universe,
|
|
183
203
|
catalog: ctxInfo.catalog,
|
|
@@ -209,7 +229,46 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
|
|
|
209
229
|
workspaceId: q("workspaceId") ?? null,
|
|
210
230
|
})) ?? []
|
|
211
231
|
: [];
|
|
212
|
-
|
|
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 });
|
|
213
272
|
}
|
|
214
273
|
// ── 「打开技能所在文件夹」 ─────────────────────────────────────
|
|
215
274
|
// 宿主没有暴露 shell/reveal 能力,由 src/reveal.js 自己拉起文件管理器。
|
|
@@ -451,8 +510,10 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
|
|
|
451
510
|
if (path === "/conversations/config" && (method === "GET" || method === "POST")) {
|
|
452
511
|
const cid = q("conversationId");
|
|
453
512
|
const wid = workspaceIdForConversation(cid);
|
|
454
|
-
//
|
|
455
|
-
|
|
513
|
+
// 打开「管理标签(对话)」是**纯读**:只保证对话行存在,绝不播种 own。
|
|
514
|
+
// (旧实现在这里用当前工作区播种,导致"看一眼就被冻结",且冻结值
|
|
515
|
+
// 取决于点开的先后顺序 —— 是「改工作区标签波及部分对话」的根因之一。)
|
|
516
|
+
await services.conversations.ensureOwn(cid);
|
|
456
517
|
return json(res, 200, await convPayload(cid, wid));
|
|
457
518
|
}
|
|
458
519
|
if (path === "/conversations/inherit" && method === "POST") {
|
package/src/service.js
CHANGED
|
@@ -48,6 +48,7 @@ export function createServices(repo, opts = {}) {
|
|
|
48
48
|
const templates = new Map();
|
|
49
49
|
const workspaces = new Map();
|
|
50
50
|
const conversations = new Map(); // conversationId -> live ConversationState
|
|
51
|
+
const syncMeta = new Map(); // key -> {key, rev, updatedAt}
|
|
51
52
|
|
|
52
53
|
async function loadAll() {
|
|
53
54
|
for (const [k, v] of await repo.listTags()) tags.set(k, v);
|
|
@@ -55,6 +56,7 @@ export function createServices(repo, opts = {}) {
|
|
|
55
56
|
for (const [k, v] of await repo.listTemplates()) templates.set(k, v);
|
|
56
57
|
for (const [k, v] of await repo.listWorkspaces()) workspaces.set(k, v);
|
|
57
58
|
for (const [k, v] of await repo.listConversations()) conversations.set(k, deserializeConversation(v));
|
|
59
|
+
for (const [k, v] of await repo.listSyncMeta()) syncMeta.set(k, v);
|
|
58
60
|
// 旧数据回填:历史模板只有 config、没有 expression —— 反推出表达式并回写,
|
|
59
61
|
// 保证"再次编辑必须回显"对存量数据同样成立。
|
|
60
62
|
try {
|
|
@@ -72,6 +74,50 @@ export function createServices(repo, opts = {}) {
|
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
// ── 对话快照(own)的联动维护 ────────────────────────────────────────
|
|
78
|
+
//
|
|
79
|
+
// own 是"冻结的配置",但**标签实体本身**是全局的:改名/删除标签必须同步
|
|
80
|
+
// 写回 own,否则冻结住的表达式会指向一个不存在的标签名,用户下次打开
|
|
81
|
+
// 「tag 设置」就会看到"标签不存在",且保存被拒。
|
|
82
|
+
//
|
|
83
|
+
// 注意这**不是**"自动继承工作区":改的是标签的名字/存废,不是条件本身。
|
|
84
|
+
// 条件(哪些标签、手动列表、禁用列表)一律保持调用方原样。
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 对每个带 own 的对话快照应用一个纯变换,有实际变化才落库。
|
|
88
|
+
* @param {(own: object) => string|null} transform 返回新表达式或 null 表示不改
|
|
89
|
+
*/
|
|
90
|
+
async function renameTagInOwnConfigs(transform) {
|
|
91
|
+
for (const [cid, conv] of conversations) {
|
|
92
|
+
if (!conv.own) continue;
|
|
93
|
+
const next = transform(conv.own);
|
|
94
|
+
if (typeof next !== "string" || next === conv.own.expression) continue;
|
|
95
|
+
conv.own.expression = next;
|
|
96
|
+
await repo.putConversation(serializeConversation(conv));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 全局删除标签后,把该标签从每个对话快照的 config/expression 里摘掉。
|
|
102
|
+
* 与 workspaces 的处理保持一致(domainRemoveTagEntirely + removeTagFromExpression),
|
|
103
|
+
* 差别只是数据挂在 own 上。
|
|
104
|
+
*/
|
|
105
|
+
async function dropTagFromOwnConfigs(tagId, removedName) {
|
|
106
|
+
for (const [cid, conv] of conversations) {
|
|
107
|
+
if (!conv.own) continue;
|
|
108
|
+
let changed = false;
|
|
109
|
+
if (conv.own.config) {
|
|
110
|
+
domainRemoveTagEntirely(conv.own.config, tagId);
|
|
111
|
+
changed = true;
|
|
112
|
+
}
|
|
113
|
+
if (removedName && conv.own.expression) {
|
|
114
|
+
const next = removeTagFromExpression(conv.own.expression, removedName);
|
|
115
|
+
if (next !== conv.own.expression) { conv.own.expression = next; changed = true; }
|
|
116
|
+
}
|
|
117
|
+
if (changed) await repo.putConversation(serializeConversation(conv));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
75
121
|
// ── TagStore (spec §4) ───────────────────────────────────────────────────
|
|
76
122
|
const tagStore = {
|
|
77
123
|
async create(tagId, name) {
|
|
@@ -113,6 +159,10 @@ export function createServices(repo, opts = {}) {
|
|
|
113
159
|
await repo.putWorkspace(ws);
|
|
114
160
|
}
|
|
115
161
|
}
|
|
162
|
+
// 对话自己的快照(own)也按名称引用 → 必须一并改写。
|
|
163
|
+
// 2026-10-31 补:此前只写 templates/workspaces,冻结的 own 会留下
|
|
164
|
+
// 指向旧名的表达式,下次打开「tag 设置」直接报"标签不存在"。
|
|
165
|
+
await renameTagInOwnConfigs((own) => renameTagInExpression(own.expression ?? "", oldName, newName));
|
|
116
166
|
}
|
|
117
167
|
},
|
|
118
168
|
list() { return [...tags.values()]; },
|
|
@@ -167,6 +217,11 @@ export function createServices(repo, opts = {}) {
|
|
|
167
217
|
}
|
|
168
218
|
await repo.putWorkspace(ws);
|
|
169
219
|
}
|
|
220
|
+
// 对话自己的快照(own):同样摘除该标签。
|
|
221
|
+
// 2026-10-31 补:此前漏了这一段,导致 own.config 里残留已经删除的
|
|
222
|
+
// tagId(本机实测 3 条),而 hasFilter 仍为 true、candidates 变空
|
|
223
|
+
// → 那条对话会把**全部技能**都屏蔽掉,且界面上看不出原因。
|
|
224
|
+
await dropTagFromOwnConfigs(tagId, removedName);
|
|
170
225
|
// conversations: tag state + OR/AND + inherited groups + block/exempt records
|
|
171
226
|
for (const [cid, conv] of conversations) {
|
|
172
227
|
const removed = domainRemoveTagEntirely(conv.config, tagId);
|
|
@@ -177,7 +232,7 @@ export function createServices(repo, opts = {}) {
|
|
|
177
232
|
}
|
|
178
233
|
pruneGroups(conv.inherited.groups);
|
|
179
234
|
conv.inherited.groups = conv.inherited.groups.filter((g) => new Set(g.tagIds).size >= 2);
|
|
180
|
-
await repo.putConversation(serializeConversation(
|
|
235
|
+
await repo.putConversation(serializeConversation(conv));
|
|
181
236
|
}
|
|
182
237
|
tags.delete(tagId);
|
|
183
238
|
await repo.deleteTag(tagId);
|
|
@@ -553,20 +608,53 @@ export function createServices(repo, opts = {}) {
|
|
|
553
608
|
return new Map([...tags.values()].map((t) => [t.tagId, t.name]));
|
|
554
609
|
}
|
|
555
610
|
|
|
611
|
+
// ── MetaService:插件级里程碑(当前只有 installed_at)────────────────
|
|
612
|
+
//
|
|
613
|
+
// installed_at = 本版**首次运行**的时刻,一旦写下永不改变。
|
|
614
|
+
// 三种对话的判定依赖它(见 lib/index.js seedOwnOnBirth)。
|
|
615
|
+
const INSTALLED_AT_KEY = "installed_at";
|
|
616
|
+
const metaService = {
|
|
617
|
+
/** 读已记录的安装时刻(ISO 字符串),没有则 null。 */
|
|
618
|
+
installedAt() {
|
|
619
|
+
return syncMeta.get(INSTALLED_AT_KEY)?.value ?? null;
|
|
620
|
+
},
|
|
621
|
+
/** 首次运行时写入;已有则原样返回(幂等,永不覆盖)。 */
|
|
622
|
+
async ensureInstalledAt(now = new Date().toISOString()) {
|
|
623
|
+
const existing = syncMeta.get(INSTALLED_AT_KEY);
|
|
624
|
+
if (existing?.value) return existing.value;
|
|
625
|
+
const rec = { key: INSTALLED_AT_KEY, rev: 1, updatedAt: now, value: now };
|
|
626
|
+
syncMeta.set(INSTALLED_AT_KEY, rec);
|
|
627
|
+
await repo.putSyncMeta(rec);
|
|
628
|
+
appLog?.(`[skill-tag] installed_at recorded: ${now}`);
|
|
629
|
+
return now;
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
|
|
556
633
|
|
|
557
634
|
// ── ConversationService (spec §7/§8) ─────────────────────────────────────
|
|
635
|
+
//
|
|
636
|
+
// 「own 只写一次」不变量(2026-10-31 统一口径):
|
|
637
|
+
//
|
|
638
|
+
// 一条对话的 own 快照只允许在**两个**时刻产生:
|
|
639
|
+
// ① 出生(新建对话 / 首次观察到 createdAt ≥ installedAt 的会话)→ 继承一次
|
|
640
|
+
// ② 用户点「继承工作区配置」→ 覆盖
|
|
641
|
+
//
|
|
642
|
+
// 除此之外永不自动写入。尤其:**打开「管理标签(对话)」不是写入时机**
|
|
643
|
+
// (旧实现会在打开瞬间用当前工作区播种,导致"看一眼就被冻结",且冻结值
|
|
644
|
+
// 取决于看的先后顺序 —— 这就是 0.1.8 之前「改工作区标签有些对话会被改」
|
|
645
|
+
// 的根因之一)。
|
|
646
|
+
//
|
|
647
|
+
// own === null 的语义固定为「从未配置过 = 空 = 掌握全部技能」,运行时
|
|
648
|
+
// 绝不再回退到工作区实时配置(见 lib/index.js effectiveSkillsConfig)。
|
|
558
649
|
const conversationService = {
|
|
559
|
-
/**
|
|
650
|
+
/** 新建对话(出生):绑工作区则继承一次。 */
|
|
560
651
|
async create(conversationId, workspaceId = null) {
|
|
561
652
|
const conv = createConversationState();
|
|
562
653
|
conv.conversationId = conversationId;
|
|
563
|
-
conv.own = emptyOwnConfig();
|
|
564
654
|
if (workspaceId) {
|
|
565
655
|
const ws = workspaces.get(workspaceId);
|
|
566
656
|
if (!ws) throw new Error(`workspace '${workspaceId}' not found`);
|
|
567
657
|
initFromWorkspace(conv, ws.config, workspaceId);
|
|
568
|
-
// 新创建对话 = 自动继承工作区配置(用户口径:新建时才自动继承,
|
|
569
|
-
// 之后工作区再改不会波及本对话,要重新同步必须点「继承工作区配置」)
|
|
570
658
|
conv.own = ownConfigFromWorkspace(ws);
|
|
571
659
|
}
|
|
572
660
|
conversations.set(conversationId, conv);
|
|
@@ -576,26 +664,43 @@ export function createServices(repo, opts = {}) {
|
|
|
576
664
|
get(conversationId) { return conversations.get(conversationId); },
|
|
577
665
|
|
|
578
666
|
/**
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
* 快照(= 用户口径里的「默认继承」)。播种只发生一次:之后工作区再改,
|
|
583
|
-
* 本对话不受影响;要重新同步只能点「继承工作区配置」按钮。
|
|
667
|
+
* 取(必要时**建行**)对话记录。**不写 own**:own 只由出生播种与
|
|
668
|
+
* 「继承工作区配置」写。历史对话第一次被打开时 own 仍是 null(= 全部技能),
|
|
669
|
+
* 直到用户显式继承或编辑。
|
|
584
670
|
*/
|
|
585
|
-
async ensureOwn(conversationId
|
|
586
|
-
// 播种只在「新建这一行」时有意义:先看能不能拿到工作区记录,
|
|
587
|
-
// 拿不到就别硬绑(避免 create 抛错),退回空快照。
|
|
588
|
-
const seed = seedWorkspaceId ? workspaces.get(seedWorkspaceId) ?? null : null;
|
|
671
|
+
async ensureOwn(conversationId) {
|
|
589
672
|
let conv = conversations.get(conversationId);
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
673
|
+
if (!conv) {
|
|
674
|
+
conv = createConversationState();
|
|
675
|
+
conv.conversationId = conversationId;
|
|
676
|
+
conversations.set(conversationId, conv);
|
|
594
677
|
await repo.putConversation(serializeConversation(conv));
|
|
595
678
|
}
|
|
596
679
|
return conv;
|
|
597
680
|
},
|
|
598
681
|
|
|
682
|
+
/**
|
|
683
|
+
* 出生播种(幂等):只在 own 为 null 且能解析到工作区时继承一次。
|
|
684
|
+
* 调用方负责「是不是新出生」的判定(见 lib/index.js seedOwnOnBirth)。
|
|
685
|
+
* @returns {Promise<boolean>} 是否真的播种了
|
|
686
|
+
*/
|
|
687
|
+
async seedOwnOnBirth(conversationId, workspaceId) {
|
|
688
|
+
if (!conversationId || !workspaceId) return false;
|
|
689
|
+
const ws = workspaces.get(workspaceId);
|
|
690
|
+
if (!ws) return false; // 工作区还没登记 → 留空,下次再说(留空 = 全部技能,与"没播种"一致)
|
|
691
|
+
const conv = await this.ensureOwn(conversationId);
|
|
692
|
+
if (conv.own) return false; // 已有配置,绝不覆盖
|
|
693
|
+
conv.own = ownConfigFromWorkspace(ws);
|
|
694
|
+
if (!conv.inherited.workspaceId) conv.inherited.workspaceId = ws.workspaceId;
|
|
695
|
+
await repo.putConversation(serializeConversation(conv));
|
|
696
|
+
return true;
|
|
697
|
+
},
|
|
698
|
+
|
|
699
|
+
/** 该对话是否已经有自己的配置快照(own 存在即"配置过")。 */
|
|
700
|
+
isConfigured(conversationId) {
|
|
701
|
+
return Boolean(conversations.get(conversationId)?.own);
|
|
702
|
+
},
|
|
703
|
+
|
|
599
704
|
/** 对话自己的配置快照(只读视图;保证字段完整) */
|
|
600
705
|
own(conversationId) {
|
|
601
706
|
const conv = conversations.get(conversationId);
|
|
@@ -632,9 +737,20 @@ export function createServices(repo, opts = {}) {
|
|
|
632
737
|
return conv;
|
|
633
738
|
},
|
|
634
739
|
|
|
740
|
+
/**
|
|
741
|
+
* 写路径专用:取对话并保证 own 是一个可编辑对象。
|
|
742
|
+
* 只有**用户在对话页里动手编辑**才会走到这里 —— 这是除「出生」与
|
|
743
|
+
* 「继承」之外的第三种合法写入时机(用户显式操作,不是自动同步)。
|
|
744
|
+
*/
|
|
745
|
+
async ownForEdit(conversationId) {
|
|
746
|
+
const conv = await this.ensureOwn(conversationId);
|
|
747
|
+
if (!conv.own) conv.own = emptyOwnConfig();
|
|
748
|
+
return conv;
|
|
749
|
+
},
|
|
750
|
+
|
|
635
751
|
/** 保存对话的标签条件表达式(空串 = 无条件 = 掌握全部技能)。 */
|
|
636
752
|
async setOwnExpression(conversationId, expression) {
|
|
637
|
-
const conv = await this.
|
|
753
|
+
const conv = await this.ownForEdit(conversationId);
|
|
638
754
|
const v = validateExpression(expression, { tags: [...tags.values()], allowEmpty: true });
|
|
639
755
|
if (!v.ok) {
|
|
640
756
|
const err = new Error(v.errors[0]?.message ?? "表达式非法");
|
|
@@ -650,7 +766,7 @@ export function createServices(repo, opts = {}) {
|
|
|
650
766
|
|
|
651
767
|
// ── 对话页右列:手动添加 / 禁用 / 恢复 ──────────────────────────
|
|
652
768
|
async addOwnManualSkill(conversationId, skillName) {
|
|
653
|
-
const conv = await this.
|
|
769
|
+
const conv = await this.ownForEdit(conversationId);
|
|
654
770
|
const name = String(skillName ?? "").trim();
|
|
655
771
|
if (!name) throw new Error("技能名不能为空");
|
|
656
772
|
if (conv.own.disabledSkills.includes(name)) throw new Error(`技能 '${name}' 已被禁用,请先恢复`);
|
|
@@ -659,13 +775,13 @@ export function createServices(repo, opts = {}) {
|
|
|
659
775
|
return conv;
|
|
660
776
|
},
|
|
661
777
|
async removeOwnManualSkill(conversationId, skillName) {
|
|
662
|
-
const conv = await this.
|
|
778
|
+
const conv = await this.ownForEdit(conversationId);
|
|
663
779
|
conv.own.manualSkills = conv.own.manualSkills.filter((s) => s !== skillName);
|
|
664
780
|
await repo.putConversation(serializeConversation(conv));
|
|
665
781
|
return conv;
|
|
666
782
|
},
|
|
667
783
|
async disableOwnSkill(conversationId, skillName) {
|
|
668
|
-
const conv = await this.
|
|
784
|
+
const conv = await this.ownForEdit(conversationId);
|
|
669
785
|
const name = String(skillName ?? "").trim();
|
|
670
786
|
if (!name) throw new Error("技能名不能为空");
|
|
671
787
|
conv.own.manualSkills = conv.own.manualSkills.filter((s) => s !== name);
|
|
@@ -674,7 +790,7 @@ export function createServices(repo, opts = {}) {
|
|
|
674
790
|
return conv;
|
|
675
791
|
},
|
|
676
792
|
async enableOwnSkill(conversationId, skillName) {
|
|
677
|
-
const conv = await this.
|
|
793
|
+
const conv = await this.ownForEdit(conversationId);
|
|
678
794
|
conv.own.disabledSkills = conv.own.disabledSkills.filter((s) => s !== skillName);
|
|
679
795
|
await repo.putConversation(serializeConversation(conv));
|
|
680
796
|
return conv;
|
|
@@ -794,11 +910,12 @@ export function createServices(repo, opts = {}) {
|
|
|
794
910
|
templates: templateStore,
|
|
795
911
|
workspaces: workspaceService,
|
|
796
912
|
conversations: conversationService,
|
|
913
|
+
meta: metaService,
|
|
797
914
|
ready: loadAll,
|
|
798
915
|
/** repository back-reference (api layer: template create) */
|
|
799
916
|
_repo: repo,
|
|
800
917
|
/** exposed for tests/inspection */
|
|
801
|
-
_caches: { tags, skillLinks, templates, workspaces, conversations },
|
|
918
|
+
_caches: { tags, skillLinks, templates, workspaces, conversations, syncMeta },
|
|
802
919
|
};
|
|
803
920
|
}
|
|
804
921
|
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* @property {() => Promise<Map<string, any>>} listConversations
|
|
22
22
|
* @property {(rec: any) => Promise<void>} putConversation
|
|
23
23
|
* @property {(conversationId: string) => Promise<void>} deleteConversation
|
|
24
|
+
* @property {() => Promise<Map<string, any>>} listSyncMeta
|
|
25
|
+
* @property {(rec: any) => Promise<void>} putSyncMeta
|
|
24
26
|
*/
|
|
25
27
|
|
|
26
28
|
// ── In-memory implementation (unit tests) ───────────────────────────────────
|
|
@@ -52,6 +54,8 @@ export function createMemoryRepository() {
|
|
|
52
54
|
async listConversations() { return new Map(t("conversations")); },
|
|
53
55
|
async putConversation(rec) { t("conversations").set(rec.conversationId, structuredClone(rec)); },
|
|
54
56
|
async deleteConversation(id) { t("conversations").delete(id); },
|
|
57
|
+
async listSyncMeta() { return new Map(t("sync_meta")); },
|
|
58
|
+
async putSyncMeta(rec) { t("sync_meta").set(rec.key, structuredClone(rec)); },
|
|
55
59
|
};
|
|
56
60
|
}
|
|
57
61
|
|
|
@@ -92,6 +96,8 @@ export function createDshRepository(domain) {
|
|
|
92
96
|
async listConversations() { return rows("conversations"); },
|
|
93
97
|
async putConversation(rec) { await tables.conversations.put(rec.conversationId, rec); },
|
|
94
98
|
async deleteConversation(id) { await tables.conversations.delete(id); },
|
|
99
|
+
async listSyncMeta() { return rows("sync_meta"); },
|
|
100
|
+
async putSyncMeta(rec) { await tables["sync_meta"].put(rec.key, rec); },
|
|
95
101
|
};
|
|
96
102
|
}
|
|
97
103
|
|
package/src/storage/schema.js
CHANGED
|
@@ -124,10 +124,20 @@ export const conversationRecord = z.object({
|
|
|
124
124
|
.nullable(),
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
// 同步元数据(键值小抽屉)。目前只放一个键:
|
|
128
|
+
// "installed_at" —— 本插件**本版首次运行**的时刻(ISO 字符串)。
|
|
129
|
+
//
|
|
130
|
+
// 用途(2026-10-31 三种对话口径):判定一条对话属于「插件安装前就存在」还是
|
|
131
|
+
// 「安装后新建」。判据是 session.header.createdAt(宿主保证恒存在)与
|
|
132
|
+
// installed_at 的比较,而不是 agent/created 事件 —— 因为宿主对**恢复**已有
|
|
133
|
+
// 会话同样会发 agent/created(dsh-agent 的 announce(agent, "startup")),
|
|
134
|
+
// 事件本身无法区分"新建"与"打开旧对话"。
|
|
127
135
|
export const syncMetaRecord = z.object({
|
|
128
136
|
key: z.string(),
|
|
129
137
|
rev: z.number().int().nonnegative(),
|
|
130
138
|
updatedAt: z.string(),
|
|
139
|
+
// 可选值载荷(旧行没有这个字段)
|
|
140
|
+
value: z.string().optional(),
|
|
131
141
|
});
|
|
132
142
|
|
|
133
143
|
// ── domain spec ─────────────────────────────────────────────────────────────
|