dsh-skill-tag 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +237 -2
- package/cordis.patch.yml +4 -0
- package/docs/preview/render-conv-preview.mjs +327 -0
- package/docs/preview/render-manage-preview.mjs +278 -0
- package/docs/preview/render-preview.mjs +274 -0
- package/docs/skill/350/201/224/347/263/273tag/344/270/216/346/211/223/345/274/200/346/226/207/344/273/266/345/244/271.md +200 -0
- package/docs//345/257/271/350/257/235/347/256/241/347/220/206/346/240/207/347/255/276.md +469 -0
- 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 +170 -0
- package/docs//346/240/207/347/255/276/347/273/204/351/205/215/347/275/256.md +323 -0
- package/docs//347/256/241/347/220/206/346/240/207/347/255/276.md +281 -0
- package/docs//347/273/231/346/212/200/350/203/275/346/211/223/346/240/207/347/255/276.md +288 -0
- package/lib/client.js +1633 -0
- package/lib/index.js +726 -0
- package/package.json +60 -4
- package/skills/tagging-skills/SKILL.md +106 -0
- package/src/api.js +516 -0
- package/src/domain/conv.js +207 -0
- package/src/domain/expr.js +564 -0
- package/src/domain/match.js +95 -0
- package/src/domain/model.js +176 -0
- package/src/domain/origin.js +110 -0
- package/src/domain/sync.js +213 -0
- package/src/domain/workspace.js +112 -0
- package/src/reveal.js +92 -0
- package/src/service.js +806 -0
- package/src/skill-doc.js +62 -0
- package/src/skill-scan.js +133 -0
- package/src/storage/repository.js +166 -0
- package/src/storage/schema.js +143 -0
- package/tools/auto-tag.mjs +185 -0
- package/tools/domain-hints.mjs +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-skill-tag contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,238 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-skill-tag
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
给 DSH 的**技能(skill)**打标签,然后用一条**布尔表达式**在「工作区 / 对话」两级
|
|
4
|
+
选出真正要下发给模型的技能子集。
|
|
5
|
+
|
|
6
|
+
装了几十上百个 skill 之后,真正的痛点不是"怎么分类",而是"**这次对话只想让模型
|
|
7
|
+
看到哪几个**"。全部下发会撑爆上下文、也会让路由互相打架;手工一条条开关又太累。
|
|
8
|
+
这个插件把这件事变成一条表达式:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
pdf + docx # 只要处理文档的技能
|
|
12
|
+
office * plugin # 只要 office 包带来的、且是插件相关
|
|
13
|
+
(aegis + tdd) * !wip # 两个都要,且排除 wip
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 它能做什么
|
|
17
|
+
|
|
18
|
+
| 能力 | 说明 |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| **全局标签** | 建/改名/删除标签,标签之间无层级 |
|
|
21
|
+
| **标签组模板** | 把表达式存成模板(`前端组`、`只留本机技能`…),可复用、可求值 |
|
|
22
|
+
| **布尔表达式** | `+`=OR、`*`=AND、`*` 优先、支持括号;改标签名会自动改写所有引用它的表达式 |
|
|
23
|
+
| **工作区级筛选** | 每个工作区一套条件,只把命中的技能下发给该工作区的对话 |
|
|
24
|
+
| **对话级筛选** | 每个对话可以有**自己的**快照,独立于所属工作区;可一键「继承工作区配置」 |
|
|
25
|
+
| **禁用 / 手动添加** | 在标签命中之外额外放行某个技能,或强行屏蔽某个(即使它命中) |
|
|
26
|
+
| **技能 ↔ 标签双向编辑** | 「tag 联系 skill」和「skill 联系 tag」两个方向都能改 |
|
|
27
|
+
| **打开技能所在文件夹** | 任何列出技能的地方都有 📁 按钮,一点即在文件管理器里定位 |
|
|
28
|
+
| **自带一个 skill** | 安装后你的 agent 会获得 `tagging-skills`,能照着文档自己给技能打标签 |
|
|
29
|
+
|
|
30
|
+
## 安装
|
|
31
|
+
|
|
32
|
+
这是一个 DSH(DeepSeek Harness)**插件**,装进 profile 后随宿主启动加载。
|
|
33
|
+
|
|
34
|
+
### 一条命令
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
dsh plugin --profile <your-profile> add dsh-skill-tag
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
还没发布到 npm 之前,直接用仓库地址装:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
dsh plugin --profile <your-profile> add github:aawef/dsh-skill-manager-tags
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`dsh plugin` 本质是把参数转发给 profile 目录里的 pnpm,**它不会替你启用插件** ——
|
|
47
|
+
装完还要在 profile 的 `package.json` 的 `dsh.profile.bundles` 数组里加上 `"dsh-skill-tag"`:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"dsh": {
|
|
52
|
+
"profile": {
|
|
53
|
+
"bundles": [ "...", "dsh-skill-tag" ]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**重启 DSH** 后生效(设置页会出现「Skill 标签」一项)。
|
|
60
|
+
|
|
61
|
+
> 官方 DSH 桌面端的 `desktop` profile 归应用独占,命令行改不动它 ——
|
|
62
|
+
> 桌面端请在 **Settings → Plugins** 或插件市场里安装。
|
|
63
|
+
|
|
64
|
+
> 依赖 Node ≥ 20;需要 DSH `0.2.0-rc.1` 及以上(见 `engines.dsh`)。
|
|
65
|
+
>
|
|
66
|
+
> 宿主的 `@deepseek-ai/cordis` 与 `@deepseek-ai/dsh-storage-domain` 都声明为
|
|
67
|
+
> **peerDependency**,由 DSH 本体提供,**不要自己安装**:本插件会 `import` 它的 `defineDomain`
|
|
68
|
+
> 来注册存储域,必须与宿主使用同一份模块实例,装成 `dependencies` 会把旧版本提到 profile
|
|
69
|
+
> 顶层、遮蔽宿主版本,导致 `workspaceController` 等服务无法上台(详见 CHANGELOG 2026-10-21)。
|
|
70
|
+
|
|
71
|
+
### 本地开发式安装
|
|
72
|
+
|
|
73
|
+
如果你直接改源码,注意**源码目录和 profile 的 `node_modules` 里那份是两个物理文件**,
|
|
74
|
+
改完必须手动复制并校验哈希:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
S=/path/to/your/dsh-skill-tag
|
|
78
|
+
D=~/.dsh/profiles/<profile>/node_modules/dsh-skill-tag
|
|
79
|
+
cp "$S/lib/client.js" "$D/lib/client.js"
|
|
80
|
+
sha256sum "$S/lib/client.js" "$D/lib/client.js" | cut -c1-16
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
生效规则(很重要,搞混了会以为"改了没用"):
|
|
84
|
+
|
|
85
|
+
| 文件 | 改完之后 |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `lib/client.js` | **刷新页面**即可(宿主 no-cache 加载) |
|
|
88
|
+
| `lib/index.js`、`src/**`、`cordis.patch.yml` | **重启桌面进程** |
|
|
89
|
+
|
|
90
|
+
## 怎么用
|
|
91
|
+
|
|
92
|
+
### 1. 打标签
|
|
93
|
+
|
|
94
|
+
设置 →「Skill 标签」,三个按钮:
|
|
95
|
+
|
|
96
|
+
- **tag 联系 skill** —— 选一个标签,勾它包含哪些技能
|
|
97
|
+
- **skill 联系 tag** —— 选一个技能,勾它拥有哪些标签
|
|
98
|
+
- **编辑标签组** —— 写表达式、存成模板
|
|
99
|
+
|
|
100
|
+
也可以让 agent 自己做:安装后 `tagging-skills` 这个 skill 会出现在技能列表里,
|
|
101
|
+
里面是完整的推导法 + API 调用纪律。**不要照抄文档里的标签名**(`aegis`、`office-skills`
|
|
102
|
+
只是某一台机器上的实例),按自己机器的技能清单推导。
|
|
103
|
+
|
|
104
|
+
或者直接跑一键脚本(先 dry-run):
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
node tools/auto-tag.mjs http://127.0.0.1:<PORT> # 只看计划,不落地
|
|
108
|
+
node tools/auto-tag.mjs http://127.0.0.1:<PORT> --apply # 落地来源轴
|
|
109
|
+
node tools/auto-tag.mjs ... --domain ./my-domain-map.json --apply # 连领域轴一起
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
脚本幂等,重跑应输出 `tagsCreated: 0`。
|
|
113
|
+
|
|
114
|
+
### 2. 给工作区/对话配条件
|
|
115
|
+
|
|
116
|
+
- **工作区**:侧栏工作区行的「…」菜单 →「管理标签」
|
|
117
|
+
- **对话**:会话行的「…」菜单,或聊天框左下角「+」→「管理对话标签」
|
|
118
|
+
|
|
119
|
+
左列写标签条件表达式,右列是命中结果(可手动添加 / 禁用 / 恢复)。改完立刻生效:
|
|
120
|
+
该对话下发给模型的技能列表就是右列那一份。
|
|
121
|
+
|
|
122
|
+
### 3. 表达式语法
|
|
123
|
+
|
|
124
|
+
| 语法 | 含义 |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| `A + B` | OR |
|
|
127
|
+
| `A * B` | AND |
|
|
128
|
+
| `(A + B) * C` | 括号;`*` 优先级高于 `+` |
|
|
129
|
+
| `"my tag"` | 标签名含空格或 `+ * ( )` 时加引号(所以起名请用 kebab-case) |
|
|
130
|
+
| 空串 | 无条件 = 全部技能(合法的"不筛选") |
|
|
131
|
+
|
|
132
|
+
表达式会被编译成 DNF(`{orTags, andGroups}`)再交给匹配引擎。
|
|
133
|
+
标签改名时,**所有引用它的表达式会被按 token 原位改写**,不会失效。
|
|
134
|
+
|
|
135
|
+
## HTTP API
|
|
136
|
+
|
|
137
|
+
挂在 `/skill-tag/api` 前缀下。所有路由**同时接受 GET 和 POST**
|
|
138
|
+
(`q(k) = body[k] ?? url.searchParams.get(k)`),所以浏览器直接敲 URL 也能调试。
|
|
139
|
+
响应一律是 JSON;出错是 `{ok:false, error, code}`。
|
|
140
|
+
|
|
141
|
+
| 路由 | 方法 | 主要参数 | 说明 |
|
|
142
|
+
| --- | --- | --- | --- |
|
|
143
|
+
| `/state` | GET | — | 全量快照:tags / skillLinks / templates / workspaces / conversations |
|
|
144
|
+
| `/skills` | GET | `cwd` | 可打标签的技能清单(带 `path`)。**带 `cwd` 才能扫到项目级 `.dsh/skills`** |
|
|
145
|
+
| `/skills/tags` | GET/POST | `skillName`, `tagIds` | 读/写一个技能拥有的标签。**写是整体覆盖**,见下方警告 |
|
|
146
|
+
| `/skills/reveal` | POST | `skillName` 或 `path` | 拉起文件管理器定位该技能 |
|
|
147
|
+
| `/tags` | POST | `tagId`, `name` | 建标签(同名会报 `already exists`) |
|
|
148
|
+
| `/tags/rename` | POST | `tagId`, `name` | 改名并同步改写所有表达式 |
|
|
149
|
+
| `/tags/link` | POST | `skillId`, `tagIds` | 关联 |
|
|
150
|
+
| `/tags/unlink` | POST | `skillId` | 解除关联 |
|
|
151
|
+
| `/tags/delete` | POST | `tagId` | 全局删除,返回 `affectedWorkspaces` |
|
|
152
|
+
| `/templates/validate` | GET/POST | `expression` | 校验 → `{ok, errors, normalized, dnf, config}` |
|
|
153
|
+
| `/templates/evaluate` | GET/POST | `templateId` | 求值(**要 `templateId`,不是 expression**) |
|
|
154
|
+
| `/templates/expand` | GET/POST | `templateId` | 展开 |
|
|
155
|
+
| `/templates` `/templates/update` `/templates/delete` | POST | — | 模板 CRUD |
|
|
156
|
+
| `/workspaces/expression` | POST | `workspaceId`, `expression` | 改工作区条件 |
|
|
157
|
+
| `/workspaces/skills` | GET | `workspaceId` | 读该工作区的候选项(`candidates`) |
|
|
158
|
+
| `/workspaces/skills/add` `remove` `disable` `enable` | POST | `workspaceId`, `skillName` | 手动添加/移除/禁用/恢复 |
|
|
159
|
+
| `/conversations/config` | GET/POST | `conversationId` | 对话双列配置的读/写 |
|
|
160
|
+
| `/conversations/inherit` | POST | `conversationId` | 整体覆盖式继承所属工作区配置 |
|
|
161
|
+
| `/conversations/candidates` | GET | `conversationId` | 该对话实际生效的技能列表 |
|
|
162
|
+
|
|
163
|
+
> **警告**:`/skills/tags` 的写是**整体覆盖**。写之前必须先 GET 读回旧 `tagIds`
|
|
164
|
+
> 再合并,否则会清掉已有标签。这是最容易踩的坑。
|
|
165
|
+
|
|
166
|
+
## 目录结构
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
lib/index.js 宿主侧入口(ESM):注册 filter provider / storage domain / HTTP API / 自带 skill
|
|
170
|
+
lib/client.js 浏览器侧界面(__ModuleLoader__ 工厂,无构建步骤)
|
|
171
|
+
src/domain/
|
|
172
|
+
expr.js 表达式引擎:tokenize / parse / toDnf / dnfToConfig / validateExpression
|
|
173
|
+
match.js 匹配:matches / candidatesFor
|
|
174
|
+
origin.js 来源轴推导(从 path 推出这个技能是哪个包带来的)
|
|
175
|
+
conv.js 对话四区状态(加载/豁免/未加载/屏蔽)
|
|
176
|
+
workspace.js 工作区视图
|
|
177
|
+
src/api.js HTTP 路由
|
|
178
|
+
src/service.js 服务层(CRUD + 改名同步 + 迁移)
|
|
179
|
+
src/storage/ storage domain schema + repository
|
|
180
|
+
src/reveal.js 拉起系统文件管理器
|
|
181
|
+
skills/tagging-skills/ 随插件注册给宿主的一个 skill
|
|
182
|
+
tools/auto-tag.mjs 一键打标签脚本(dry-run 默认)
|
|
183
|
+
tests/ node:test,183 项
|
|
184
|
+
docs/ 设计文档(见下)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## 文档
|
|
188
|
+
|
|
189
|
+
| 文件 | 内容 |
|
|
190
|
+
| --- | --- |
|
|
191
|
+
| [docs/给技能打标签.md](docs/给技能打标签.md) | **打标签的完整手册**:三轴设计、API 调用序列、错误对照表、交付检查清单 |
|
|
192
|
+
| [docs/标签组配置.md](docs/标签组配置.md) | 表达式编辑页的设计与验收 |
|
|
193
|
+
| [docs/管理标签.md](docs/管理标签.md) | 工作区双列配置 |
|
|
194
|
+
| [docs/对话管理标签.md](docs/对话管理标签.md) | 对话级配置与继承 |
|
|
195
|
+
| [docs/技能发现与宿主对齐.md](docs/技能发现与宿主对齐.md) | 为什么"插件看到的技能数"曾经少于宿主,以及怎么对齐 |
|
|
196
|
+
| [docs/skill联系tag与打开文件夹.md](docs/skill联系tag与打开文件夹.md) | 两个方向编辑 + 📁 按钮 |
|
|
197
|
+
| [CHANGELOG.md](CHANGELOG.md) | 按日期倒序的完整开发记录 |
|
|
198
|
+
|
|
199
|
+
## 开发
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
npm install
|
|
203
|
+
npm test # 183 项
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
测试分两类,都在 `tests/` 下:
|
|
207
|
+
|
|
208
|
+
- `*.test.mjs` —— 单元/领域测试,被 `node --test` 自动收集
|
|
209
|
+
- `__*.mjs` —— 手工脚本:HTTP 全链路冒烟(`__api-smoke`)、用迷你 React 运行时
|
|
210
|
+
执行真实组件并断言渲染结构(`__page-render` 等)
|
|
211
|
+
|
|
212
|
+
**两个 glob 都要**,`npm test` 已经配好;只跑 `tests/**/*.test.mjs` 会漏掉 45 个用例。
|
|
213
|
+
|
|
214
|
+
界面预览可以在没有浏览器扩展时生成静态 HTML:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
node docs/preview/render-preview.mjs # → docs/preview/标签组配置-preview.html
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
(产物已在 `.gitignore` 里排除。)
|
|
221
|
+
|
|
222
|
+
### 改插件时的几条硬约束
|
|
223
|
+
|
|
224
|
+
这几条都是踩过的坑,源码注释里也写了:
|
|
225
|
+
|
|
226
|
+
- **宿主服务是声明式注入的**:没写进 `export const inject` 的服务,`ctx.<service>`
|
|
227
|
+
读属性会**直接抛** `cannot get property "x" without inject`。而且
|
|
228
|
+
`ctx.x ?? ctx.get("x")` 这种兜底照样抛 —— 左侧求值时就炸了。只能 `ctx.get()` + try/catch。
|
|
229
|
+
- **注册给宿主的回调里抛异常 = host fatal**(整个桌面进程挂掉)。
|
|
230
|
+
`domain/changed` 监听器、provider 的 `list`/`get`、`apply()` 同步栈,全部 self-contain。
|
|
231
|
+
- **新增 HTTP 路由必须重启桌面进程**,否则新路由 404 —— 这个 404 极易被误判成
|
|
232
|
+
"路由名写错了"。
|
|
233
|
+
- `ctx.effect(() => fn())` 的 thunk **立刻执行**,它的**返回值**才是 dispose。
|
|
234
|
+
写成 `ctx.effect(() => domain.close())` 会在装配阶段就把 domain 关掉。
|
|
235
|
+
|
|
236
|
+
## 许可证
|
|
237
|
+
|
|
238
|
+
MIT — 见 [LICENSE](LICENSE)。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// 生成「对话 → 管理标签」界面的可视化预览(不依赖浏览器控制扩展)
|
|
2
|
+
//
|
|
3
|
+
// 做法同 render-manage-preview.mjs:用迷你 React 运行时真正执行 lib/client.js 里的
|
|
4
|
+
// ManageTagsDialog(对话作用域)与 TagSettingsDialog,序列化成 HTML,再用 headless Edge 截图。
|
|
5
|
+
//
|
|
6
|
+
// 用法:node docs/preview/render-conv-preview.mjs
|
|
7
|
+
// 产物:docs/preview/对话管理标签-preview.html
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import vm from "node:vm";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const ROOT = path.resolve(HERE, "..", "..");
|
|
16
|
+
const SOURCE = fs.readFileSync(path.join(ROOT, "lib", "client.js"), "utf8");
|
|
17
|
+
const FRAGMENT = Symbol("Fragment");
|
|
18
|
+
|
|
19
|
+
const TAGS = [
|
|
20
|
+
{ tagId: "t-a", name: "前端" },
|
|
21
|
+
{ tagId: "t-b", name: "测试" },
|
|
22
|
+
{ tagId: "t-c", name: "文档" },
|
|
23
|
+
];
|
|
24
|
+
const TEMPLATES = [
|
|
25
|
+
{ templateId: "tpl-1", name: "前端组", expression: "前端+测试*文档" },
|
|
26
|
+
{ templateId: "tpl-2", name: "纯后端", expression: "(前端+测试)*文档" },
|
|
27
|
+
];
|
|
28
|
+
const UNIVERSE = ["code-review", "tdd", "docx", "xlsx", "pdf", "webapp-testing"];
|
|
29
|
+
const LINKS = [
|
|
30
|
+
{ skillId: "code-review", tagIds: ["t-a"] },
|
|
31
|
+
{ skillId: "tdd", tagIds: ["t-b"] },
|
|
32
|
+
{ skillId: "docx", tagIds: ["t-c"] },
|
|
33
|
+
{ skillId: "pdf", tagIds: ["t-c"] },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function makeFetcher(state) {
|
|
37
|
+
const hasFilter = () => state.expression.trim() !== "";
|
|
38
|
+
const hit = (name) => {
|
|
39
|
+
const ids = new Set((LINKS.find((l) => l.skillId === name)?.tagIds) ?? []);
|
|
40
|
+
if (!hasFilter()) return true;
|
|
41
|
+
return ids.has("t-a") || (ids.has("t-b") && ids.has("t-c"));
|
|
42
|
+
};
|
|
43
|
+
const view = () => {
|
|
44
|
+
const f = hasFilter();
|
|
45
|
+
const matched = f ? UNIVERSE.filter(hit) : UNIVERSE.slice();
|
|
46
|
+
const visible = [];
|
|
47
|
+
for (const n of matched) if (!state.disabledSkills.includes(n)) visible.push(n);
|
|
48
|
+
for (const n of state.manualSkills) if (!state.disabledSkills.includes(n) && !visible.includes(n)) visible.push(n);
|
|
49
|
+
return { hasFilter: f, matched, manual: state.manualSkills.slice(), disabled: state.disabledSkills.slice(), visible };
|
|
50
|
+
};
|
|
51
|
+
const payload = () => ({
|
|
52
|
+
ok: true,
|
|
53
|
+
conversationId: state.conversationId,
|
|
54
|
+
workspaceId: state.sourceWorkspaceId,
|
|
55
|
+
expression: state.expression,
|
|
56
|
+
config: hasFilter() ? { orTags: ["t-a"], andGroups: [{ groupId: "g1", tagIds: ["t-b", "t-c"] }] } : { orTags: [], andGroups: [] },
|
|
57
|
+
manualSkills: state.manualSkills.slice(),
|
|
58
|
+
disabledSkills: state.disabledSkills.slice(),
|
|
59
|
+
inheritedAt: state.inheritedAt,
|
|
60
|
+
sourceWorkspaceId: state.sourceWorkspaceId,
|
|
61
|
+
candidates: hasFilter() ? UNIVERSE.filter(hit) : [],
|
|
62
|
+
universe: UNIVERSE,
|
|
63
|
+
view: view(),
|
|
64
|
+
});
|
|
65
|
+
const respond = (p) => {
|
|
66
|
+
if (p === "/state") return { ok: true, tags: TAGS, skillLinks: LINKS, templates: TEMPLATES, workspaces: [], conversations: [] };
|
|
67
|
+
if (p === "/conversations/config") return payload();
|
|
68
|
+
if (p === "/conversations/inherit") {
|
|
69
|
+
// 假装工作区那份配置:前端+测试*文档 / 手动 xlsx / 禁用 pdf
|
|
70
|
+
state.expression = "前端+测试*文档";
|
|
71
|
+
state.manualSkills = ["xlsx"];
|
|
72
|
+
state.disabledSkills = ["pdf"];
|
|
73
|
+
state.sourceWorkspaceId = "ws-1";
|
|
74
|
+
state.inheritedAt = new Date().toISOString();
|
|
75
|
+
return payload();
|
|
76
|
+
}
|
|
77
|
+
if (p === "/conversations/expression") { state.expression = JSON.parse(state.lastBody ?? "{}").expression ?? ""; return { ok: true, conversation: {} }; }
|
|
78
|
+
if (p === "/conversations/skills/disable") {
|
|
79
|
+
const n = JSON.parse(state.lastBody ?? "{}").skillName;
|
|
80
|
+
state.disabledSkills.push(n);
|
|
81
|
+
state.manualSkills = state.manualSkills.filter((s) => s !== n);
|
|
82
|
+
return { ok: true, conversation: {} };
|
|
83
|
+
}
|
|
84
|
+
if (p === "/conversations/skills/enable") {
|
|
85
|
+
const n = JSON.parse(state.lastBody ?? "{}").skillName;
|
|
86
|
+
state.disabledSkills = state.disabledSkills.filter((s) => s !== n);
|
|
87
|
+
return { ok: true, conversation: {} };
|
|
88
|
+
}
|
|
89
|
+
if (p === "/conversations/skills/add") { state.manualSkills.push(JSON.parse(state.lastBody ?? "{}").skillName); return { ok: true, conversation: {} }; }
|
|
90
|
+
if (p === "/templates/validate") {
|
|
91
|
+
if (state.expression.trim() === "") {
|
|
92
|
+
return { ok: true, validation: { ok: true, errors: [], warnings: [], normalized: "", config: { orTags: [], andGroups: [] } } };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
validation: {
|
|
97
|
+
ok: true, errors: [], warnings: [], normalized: state.expression,
|
|
98
|
+
config: { orTags: ["t-a"], andGroups: [{ groupId: "g1", tagIds: ["t-b", "t-c"] }] },
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return { ok: true };
|
|
103
|
+
};
|
|
104
|
+
return async (url, init) => {
|
|
105
|
+
const p = String(url).replace("/skill-tag/api", "");
|
|
106
|
+
if (init?.body) state.lastBody = init.body;
|
|
107
|
+
const out = respond(p);
|
|
108
|
+
return { json: async () => out };
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function makeRuntime() {
|
|
113
|
+
const stores = new Map();
|
|
114
|
+
const pendingEffects = [];
|
|
115
|
+
function storeOf(Comp) {
|
|
116
|
+
let s = stores.get(Comp);
|
|
117
|
+
if (!s) { s = { hooks: [], cursor: 0 }; stores.set(Comp, s); }
|
|
118
|
+
return s;
|
|
119
|
+
}
|
|
120
|
+
const react = {
|
|
121
|
+
createElement: (type, props, ...children) => ({
|
|
122
|
+
type, props: props ?? {},
|
|
123
|
+
children: children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false),
|
|
124
|
+
}),
|
|
125
|
+
Fragment: FRAGMENT,
|
|
126
|
+
useState(init) {
|
|
127
|
+
const s = storeOf(react.__cur);
|
|
128
|
+
const i = s.cursor++;
|
|
129
|
+
if (!(i in s.hooks)) s.hooks[i] = typeof init === "function" ? init() : init;
|
|
130
|
+
return [s.hooks[i], (n) => { s.hooks[i] = typeof n === "function" ? n(s.hooks[i]) : n; }];
|
|
131
|
+
},
|
|
132
|
+
useRef(init) {
|
|
133
|
+
const s = storeOf(react.__cur);
|
|
134
|
+
const i = s.cursor++;
|
|
135
|
+
if (!(i in s.hooks)) s.hooks[i] = { current: init ?? null };
|
|
136
|
+
return s.hooks[i];
|
|
137
|
+
},
|
|
138
|
+
useEffect(fn) { storeOf(react.__cur).cursor++; pendingEffects.push(fn); },
|
|
139
|
+
useMemo(fn) { storeOf(react.__cur).cursor++; return fn(); },
|
|
140
|
+
useCallback(fn) { storeOf(react.__cur).cursor++; return fn; },
|
|
141
|
+
};
|
|
142
|
+
function renderComponent(Comp, props) {
|
|
143
|
+
const s = storeOf(Comp);
|
|
144
|
+
s.cursor = 0;
|
|
145
|
+
const prev = react.__cur;
|
|
146
|
+
react.__cur = Comp;
|
|
147
|
+
try { return Comp(props); } finally { react.__cur = prev; }
|
|
148
|
+
}
|
|
149
|
+
function drain() {
|
|
150
|
+
for (const fn of pendingEffects.splice(0)) { try { fn(); } catch { /* 忽略 */ } }
|
|
151
|
+
}
|
|
152
|
+
function expand(node, depth = 0) {
|
|
153
|
+
if (node === null || node === undefined || node === false) return node;
|
|
154
|
+
if (typeof node !== "object") return node;
|
|
155
|
+
if (depth > 40) return node;
|
|
156
|
+
if (typeof node.type === "function") return expand(renderComponent(node.type, node.props), depth + 1);
|
|
157
|
+
return { ...node, children: (node.children ?? []).map((c) => expand(c, depth + 1)) };
|
|
158
|
+
}
|
|
159
|
+
/** 注意:必须 expand 之后才 drain,否则子组件(TagSettingsDialog)的 effect 不会入队 */
|
|
160
|
+
async function settle(Comp, props, rounds = 10) {
|
|
161
|
+
let tree = null;
|
|
162
|
+
for (let r = 0; r < rounds; r += 1) {
|
|
163
|
+
pendingEffects.length = 0;
|
|
164
|
+
tree = expand(renderComponent(Comp, props));
|
|
165
|
+
drain();
|
|
166
|
+
await new Promise((res) => setTimeout(res, 0));
|
|
167
|
+
await new Promise((res) => setTimeout(res, 0));
|
|
168
|
+
}
|
|
169
|
+
pendingEffects.length = 0;
|
|
170
|
+
tree = expand(renderComponent(Comp, props));
|
|
171
|
+
drain();
|
|
172
|
+
return tree;
|
|
173
|
+
}
|
|
174
|
+
return { react, settle };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const ESC = { "&": "&", "<": "<", ">": ">", '"': """ };
|
|
178
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ESC[c]);
|
|
179
|
+
|
|
180
|
+
function toHtml(node) {
|
|
181
|
+
if (node === null || node === undefined || node === false) return "";
|
|
182
|
+
if (typeof node !== "object") return esc(node);
|
|
183
|
+
const { type, props = {}, children = [] } = node;
|
|
184
|
+
if (typeof type === "function") return "";
|
|
185
|
+
const inner = children.map(toHtml).join("");
|
|
186
|
+
const tag = typeof type === "string" ? type : "div";
|
|
187
|
+
const attrs = [];
|
|
188
|
+
const p = props ?? {};
|
|
189
|
+
if (p.style && typeof p.style === "object") {
|
|
190
|
+
attrs.push(`style="${esc(Object.entries(p.style).map(([k, v]) => `${k.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase())}:${v}`).join(";"))}"`);
|
|
191
|
+
}
|
|
192
|
+
if (p.className) attrs.push(`class="${esc(p.className)}"`);
|
|
193
|
+
if (tag === "input") {
|
|
194
|
+
if (p.value !== undefined) attrs.push(`value="${esc(p.value)}"`);
|
|
195
|
+
if (p.placeholder) attrs.push(`placeholder="${esc(p.placeholder)}"`);
|
|
196
|
+
if (p.type) attrs.push(`type="${esc(p.type)}"`);
|
|
197
|
+
if (p.checked) attrs.push("checked");
|
|
198
|
+
return `<input ${attrs.join(" ")}>`;
|
|
199
|
+
}
|
|
200
|
+
if (tag === "textarea") {
|
|
201
|
+
if (p.value !== undefined) attrs.push(`value="${esc(p.value)}"`);
|
|
202
|
+
return `<textarea ${attrs.join(" ")}>${esc(p.value ?? "")}</textarea>`;
|
|
203
|
+
}
|
|
204
|
+
if (tag === "select") {
|
|
205
|
+
const opts = children.map((c) => {
|
|
206
|
+
if (!c || typeof c !== "object" || c.type !== "option") return toHtml(c);
|
|
207
|
+
const sel = c.props?.value === p.value ? " selected" : "";
|
|
208
|
+
return `<option value="${esc(c.props?.value ?? "")}"${sel}>${esc((c.children ?? []).map(toHtml).join(""))}</option>`;
|
|
209
|
+
}).join("");
|
|
210
|
+
return `<select ${attrs.join(" ")}>${opts}</select>`;
|
|
211
|
+
}
|
|
212
|
+
if (tag === "option") return `<option>${inner}</option>`;
|
|
213
|
+
if (tag === "button") {
|
|
214
|
+
if (p.disabled) attrs.push("disabled");
|
|
215
|
+
return `<button ${attrs.join(" ")}>${inner}</button>`;
|
|
216
|
+
}
|
|
217
|
+
return `<${tag} ${attrs.join(" ")}>${inner}</${tag}>`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const walk = (n, f) => { if (!n || typeof n !== "object") return; f(n); for (const c of n.children ?? []) walk(c, f); };
|
|
221
|
+
const findBtn = (tree, label) => {
|
|
222
|
+
const out = [];
|
|
223
|
+
walk(tree, (n) => {
|
|
224
|
+
if (n.type === "button" && (n.children ?? []).map(String).join("").trim() === label) out.push(n);
|
|
225
|
+
});
|
|
226
|
+
return out;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/** 载入 client.js,取回 shell.overlay 组件与 __test 助手 */
|
|
230
|
+
function boot(rt, state) {
|
|
231
|
+
const sandbox = { console, setTimeout, clearTimeout, fetch: makeFetcher(state), window: { confirm: () => true, __ModuleLoader__: { load: () => {} } } };
|
|
232
|
+
sandbox.globalThis = sandbox;
|
|
233
|
+
vm.runInNewContext(SOURCE, sandbox, { filename: "lib/client.js" });
|
|
234
|
+
const out = { Dialog: null, helpers: null };
|
|
235
|
+
sandbox.window.__ModuleLoader__.load = ({ factory }) => {
|
|
236
|
+
const exp = factory((n) => (n === "react" ? rt.react : {}));
|
|
237
|
+
out.helpers = exp.__test;
|
|
238
|
+
exp.apply({
|
|
239
|
+
effect: (fn) => fn(),
|
|
240
|
+
slots: { inject: (_n, fn) => fn(), register: (o, C) => { if (o.name === "shell.overlay") out.Dialog = C; } },
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
// 再跑一次源码,这次 load 已就位,模块才会真正执行并注册槽位
|
|
244
|
+
vm.runInNewContext(SOURCE, sandbox, { filename: "lib/client.js" });
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function scene(title, note, bodyHtml) {
|
|
249
|
+
return `<div class="cap"><b>${esc(title)}</b> ${note}</div><div class="shot">${bodyHtml}</div>`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const htmls = [];
|
|
253
|
+
|
|
254
|
+
// 场景①:对话已继承工作区配置(条件 + 手动添加 + 禁用一起搬过来),可在其上继续改
|
|
255
|
+
{
|
|
256
|
+
const rt = makeRuntime();
|
|
257
|
+
const state = {
|
|
258
|
+
conversationId: "sess-7f3a", expression: "前端+测试*文档",
|
|
259
|
+
manualSkills: ["xlsx"], disabledSkills: ["pdf"],
|
|
260
|
+
sourceWorkspaceId: "ws-1", inheritedAt: new Date().toISOString(), lastBody: null,
|
|
261
|
+
};
|
|
262
|
+
const { Dialog, helpers } = boot(rt, state);
|
|
263
|
+
helpers.manageSet({ open: true, workspaceId: "", conversationId: "sess-7f3a" });
|
|
264
|
+
const tree = await rt.settle(Dialog, {});
|
|
265
|
+
htmls.push(scene(
|
|
266
|
+
"场景① 对话双列(已继承工作区)",
|
|
267
|
+
"左列多出「继承工作区配置」按钮,并标注继承来源与时间;右列技能同样可查看 tag / 禁用 / 添加",
|
|
268
|
+
`<div class="shot">${toHtml(tree)}</div>`,
|
|
269
|
+
));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 场景②:对话尚未继承任何工作区(独立配置)
|
|
273
|
+
{
|
|
274
|
+
const rt = makeRuntime();
|
|
275
|
+
const state = {
|
|
276
|
+
conversationId: "sess-9c11", expression: "",
|
|
277
|
+
manualSkills: [], disabledSkills: [],
|
|
278
|
+
sourceWorkspaceId: null, inheritedAt: null, lastBody: null,
|
|
279
|
+
};
|
|
280
|
+
const { Dialog, helpers } = boot(rt, state);
|
|
281
|
+
helpers.manageSet({ open: true, workspaceId: "", conversationId: "sess-9c11" });
|
|
282
|
+
const tree = await rt.settle(Dialog, {});
|
|
283
|
+
htmls.push(scene(
|
|
284
|
+
"场景② 对话双列(未继承,独立配置)",
|
|
285
|
+
"空条件 = 该对话掌握全部技能;点击「继承工作区配置」会把工作区那份整体搬过来",
|
|
286
|
+
`<div class="shot">${toHtml(tree)}</div>`,
|
|
287
|
+
));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 场景③:对话版「tag 设置」—— 载入标签组后可继续编辑,保存目标是「对话」而不是工作区
|
|
291
|
+
{
|
|
292
|
+
const rt = makeRuntime();
|
|
293
|
+
const state = {
|
|
294
|
+
conversationId: "sess-9c11", expression: "前端+测试",
|
|
295
|
+
manualSkills: [], disabledSkills: [],
|
|
296
|
+
sourceWorkspaceId: "ws-1", inheritedAt: new Date().toISOString(), lastBody: null,
|
|
297
|
+
};
|
|
298
|
+
const { Dialog, helpers } = boot(rt, state);
|
|
299
|
+
helpers.manageSet({ open: true, workspaceId: "", conversationId: "sess-9c11" });
|
|
300
|
+
let tree = await rt.settle(Dialog, {});
|
|
301
|
+
findBtn(tree, "tag 设置")[0]?.props?.onClick?.();
|
|
302
|
+
tree = await rt.settle(Dialog, {});
|
|
303
|
+
// 载入标签组「前端组」的运算
|
|
304
|
+
let sel = null;
|
|
305
|
+
walk(tree, (n) => { if (n.type === "select" && (n.children ?? []).some((c) => (c.children ?? []).map(String).join("").includes("前端组"))) sel = n; });
|
|
306
|
+
sel?.props?.onChange?.({ target: { value: "tpl-1" } });
|
|
307
|
+
tree = await rt.settle(Dialog, {});
|
|
308
|
+
htmls.push(scene(
|
|
309
|
+
"场景③ 对话版「tag 设置」",
|
|
310
|
+
"载入标签组「前端组」的运算到运算区后可继续编辑;底部按钮是 <b>保存到对话</b>,不会改工作区",
|
|
311
|
+
`<div class="shot">${toHtml(tree)}</div>`,
|
|
312
|
+
));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const head = `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>对话管理标签 · 界面预览</title>
|
|
316
|
+
<style>
|
|
317
|
+
body{margin:0;background:#0e1117;color:#d7dce5;font-family:"Microsoft YaHei",ui-sans-serif,system-ui;padding:14px 0 30px}
|
|
318
|
+
.cap{padding:12px 24px 4px;font-size:13px;color:#8b93a7} .cap b{color:#e6ebf5}
|
|
319
|
+
/* 静态预览:把浮层的 fixed/absolute 全部拍平,否则嵌套的「tag 设置」会脱离文档流留下一大段空白 */
|
|
320
|
+
.shot *{position:static!important}
|
|
321
|
+
.shot>div{display:block!important;background:transparent!important}
|
|
322
|
+
.shot>div>div{margin:0 auto}
|
|
323
|
+
h1{font-size:16px;padding:0 24px;margin:8px 0 4px}
|
|
324
|
+
code{background:#1b2130;padding:1px 5px;border-radius:4px}
|
|
325
|
+
</style></head><body><h1>对话 → 管理标签 · 界面预览</h1>`;
|
|
326
|
+
fs.writeFileSync(path.join(HERE, "对话管理标签-preview.html"), head + htmls.join("") + "</body></html>");
|
|
327
|
+
console.log("wrote docs/preview/对话管理标签-preview.html");
|