@restregular/dsh-skill-center 0.3.0 → 0.3.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/README.md +74 -14
- package/client/client.js +136 -15
- package/lib/index.js +48 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Sidebar Main area
|
|
|
35
35
|
| Browse | Lists every skill visible in the **current session scope**, including skills embedded in plugins, each with provider, source root and invocation policy |
|
|
36
36
|
| Search / filter | Search by name or description; filter by provider source |
|
|
37
37
|
| Read body | Renders the raw `SKILL.md` Markdown inside the panel (no right-hand resource pane needed) |
|
|
38
|
-
| Invocation switches |
|
|
38
|
+
| Invocation switches | Flips `disable-model-invocation` / `user-invocable` locally — the switch moves at once, with no round trip — and writes them into the frontmatter when you press **Save** beside *Back to session* (the button reads 未修改 → 保存 → 已保存); the host pushes the refreshed catalog to the model immediately |
|
|
39
39
|
| Create | Creates `<name>/SKILL.md` under the user Agents or user DSH skills root |
|
|
40
40
|
| Delete | Moves the skill to `.trash/<timestamp>-<name>` in the same root — recoverable, never a hard delete |
|
|
41
41
|
| Read-only guard | Skills embedded in plugins (e.g. `office-docx`) are labelled *embedded* and their switches/deletion are disabled |
|
|
@@ -124,7 +124,7 @@ Then add the package to the profile's bundle list and restart:
|
|
|
124
124
|
// <profile>/package.json
|
|
125
125
|
{
|
|
126
126
|
"dsh": { "profile": { "bundles": [/* … */ "@restregular/dsh-skill-center"] } },
|
|
127
|
-
"dependencies": { "@restregular/dsh-skill-center": "^0.3.
|
|
127
|
+
"dependencies": { "@restregular/dsh-skill-center": "^0.3.1" }
|
|
128
128
|
}
|
|
129
129
|
```
|
|
130
130
|
|
|
@@ -137,7 +137,7 @@ because the client registers `sidebar.panellist` with `order: 1`.
|
|
|
137
137
|
> (`@restregular/dsh-skill-center`). `@deepseek-ai/dsh-client-modules` locates a row's
|
|
138
138
|
> `package.json` by that name and compares it strictly (`nearestPackage`). With a bare
|
|
139
139
|
> `dsh-skill-center` the host half still mounts, but the client half is dropped **silently**:
|
|
140
|
-
> no sidebar row,
|
|
140
|
+
> no sidebar row, `op=diagnose` reports `client.inGraph: false` with no `client.url`, and nothing is
|
|
141
141
|
> logged. 0.2.1 fixed exactly this; 0.2.0 shipped the bare name.
|
|
142
142
|
|
|
143
143
|
> [!IMPORTANT]
|
|
@@ -169,8 +169,11 @@ Nothing appears in the sidebar after install + restart? Check in this order:
|
|
|
169
169
|
2. **Did the client half reach the browser module graph?**
|
|
170
170
|
`http://127.0.0.1:19387/dsh-skill-center/api?op=diagnose` — `client.inGraph` must be `true`.
|
|
171
171
|
`false` while `host.mounted` is `true` is the loader-row-name / package-name mismatch above.
|
|
172
|
-
3. **Is the bundle served?**
|
|
173
|
-
|
|
172
|
+
3. **Is the bundle served?** The browser is only ever handed a **revisioned** URL
|
|
173
|
+
(`/plugins/@restregular/dsh-skill-center/client.js?rev=<12 hex>`), and the route deliberately rejects a
|
|
174
|
+
mismatched revision, so requesting the bare path answers `404` even on a healthy install. Read
|
|
175
|
+
`client.url` from `op=diagnose` (it now reports the row it matched in `client.entryId`) and fetch that
|
|
176
|
+
URL instead.
|
|
174
177
|
4. **Re-run the offline composition check** (no host restart needed):
|
|
175
178
|
`node verify-compose.mjs <profileDir>` — it now fails loudly on a loader row name that cannot be
|
|
176
179
|
resolved back to the package.
|
|
@@ -182,7 +185,10 @@ Nothing appears in the sidebar after install + restart? Check in this order:
|
|
|
182
185
|
1. Open a session (or stay on the home screen — see *Known limits*).
|
|
183
186
|
2. Click **Skill Center** in the sidebar.
|
|
184
187
|
3. Pick a skill on the left; the right pane shows provider, source, path, size and the two invocation switches.
|
|
185
|
-
4.
|
|
188
|
+
4. Flip switches as you like: they move immediately and stay staged in the panel. The button to the left of
|
|
189
|
+
*Back to session* tracks the state — **未修改** (greyed, nothing staged) → **保存** (active, changes staged;
|
|
190
|
+
a count appears when several skills are staged) → **已保存** (greyed, written). Editing anything again puts
|
|
191
|
+
it back to 保存. Press **Read body** for the Markdown, or **Create** / **Delete** to manage files.
|
|
186
192
|
|
|
187
193
|
## HTTP API
|
|
188
194
|
|
|
@@ -231,6 +237,10 @@ Two halves, no shared imports:
|
|
|
231
237
|
`<projectRoot>/.dsh/skills`, `<projectRoot>/.agents/skills`, `$DSH_HOME/skills`, `$DSH_AGENTS_HOME/skills`.
|
|
232
238
|
- Frontmatter edits are string-level: only the invocation keys are touched, everything else is preserved
|
|
233
239
|
byte for byte, including line-ending style.
|
|
240
|
+
- For skills inside a managed root, `list` takes the invocation policy **from the file on disk** instead of
|
|
241
|
+
the registry snapshot. The skill catalog is cached and the filesystem watcher invalidates it a moment
|
|
242
|
+
after a write, so a panel that trusted the snapshot would show its own stale value right after a save —
|
|
243
|
+
which is exactly what made a flipped switch snap back until a manual refresh.
|
|
234
244
|
- Delete is a `rename` into `.trash`.
|
|
235
245
|
- Also registers an embedded skill provider (`ctx.skills.registerProvider`) for `skills/skill-authoring/`
|
|
236
246
|
and the `skill_create` tool (`ctx.inject(['tools'], …)` + `ctx.tools.register`). The tool schema is a
|
|
@@ -258,6 +268,11 @@ Styles use host theme tokens only (`--dsw-alias-*`, `--dsw-radius-*`, `--dsw-foc
|
|
|
258
268
|
`<style>` tag carries `data-plugin="dsh-skill-center"` so the host's ownerless-style sweep does not
|
|
259
269
|
reclaim it.
|
|
260
270
|
|
|
271
|
+
Switch edits are staged in component state: the switch reads a local override first and the catalog
|
|
272
|
+
second, and the Save button's three states are derived from the staged set (`pendingEdits`) plus a
|
|
273
|
+
"saved once" flag. After a save the overrides are kept until a fresh catalog actually agrees with them
|
|
274
|
+
(`load()` drops the ones that match), so a lagging host snapshot can never bounce the UI back.
|
|
275
|
+
|
|
261
276
|
## Development
|
|
262
277
|
|
|
263
278
|
```bash
|
|
@@ -298,13 +313,32 @@ contract (directory name vs frontmatter, non-empty description, `resourceBase` d
|
|
|
298
313
|
`references/`-style path cited in a table or link actually existing), the tool's JSON Schema against
|
|
299
314
|
the subset `@deepseek-ai/dsh-tools` accepts, and then **really runs** `skill_create` inside a temp
|
|
300
315
|
dir: create, resource placement, overwrite, duplicate names, illegal names, path traversal, flat
|
|
301
|
-
files, explicit project roots, and the three degraded mount paths.
|
|
316
|
+
files, explicit project roots, and the three degraded mount paths. It also asserts that a managed-root
|
|
317
|
+
skill's invocation policy comes from the file on disk even when the registry snapshot disagrees.
|
|
318
|
+
|
|
319
|
+
`client-smoke.mjs` renders the real panel against the live loopback route through a mini React shim,
|
|
320
|
+
and then drives the staged-save flow: the switch flips with no write request, Save issues exactly one
|
|
321
|
+
`setInvocation` carrying both flags, the button steps 未修改 → 保存 → 已保存 (and back to 保存 on the
|
|
322
|
+
next edit), and a lagging catalog snapshot cannot bounce the switch back. It intercepts POSTs, so the
|
|
323
|
+
run never rewrites a real `SKILL.md`.
|
|
302
324
|
|
|
303
325
|
> Renaming or relocating the package: the client module table caches package metadata by
|
|
304
326
|
> `(loader baseUrl, loader line name)` — no file timestamps — so a running host keeps the old path.
|
|
305
327
|
> The browser side uses the in-memory snapshot taken at activation and keeps working after a reload;
|
|
306
328
|
> the new path is only resolved after a host restart.
|
|
307
329
|
|
|
330
|
+
### Releasing
|
|
331
|
+
|
|
332
|
+
Bump `version`, commit, push, then `npm publish` from the repo root with a token that has publish
|
|
333
|
+
rights for the scope. The registry answers a successful publish with `202 {"success":true}` and then
|
|
334
|
+
holds the version in a **validating** state: until validation passes, both
|
|
335
|
+
`/@restregular/dsh-skill-center/<version>` and the tarball URL answer `404`, and `dist-tags` still
|
|
336
|
+
points at the previous release. That is the normal window, not a failure. Re-probe the version URL
|
|
337
|
+
with a cache-busting query (`?t=<ticks>`) rather than publishing again: a second attempt for the same
|
|
338
|
+
version fails with `E409 Cannot publish over previously staged version <version>` (and
|
|
339
|
+
`npm stage publish` with `E409 Cannot stage previously published version <version>`), while
|
|
340
|
+
`npm stage list` may show nothing at all.
|
|
341
|
+
|
|
308
342
|
## Known limits
|
|
309
343
|
|
|
310
344
|
- While the panel occupies the main area, the right-hand `dsh-resource://` preview is unavailable
|
|
@@ -343,7 +377,7 @@ files, explicit project roots, and the three degraded mount paths.
|
|
|
343
377
|
| 浏览 | 列出**当前会话作用域**下模型/用户能看到的所有技能(含插件内嵌技能),带提供方、来源根、调用策略 |
|
|
344
378
|
| 搜索 / 筛选 | 按名称或描述搜索;按来源(provider source)筛选 |
|
|
345
379
|
| 查看正文 | 面板内直接读 `SKILL.md` 原文(Markdown 原文,不经过右栏) |
|
|
346
|
-
| 调用开关 |
|
|
380
|
+
| 调用开关 | 开关先改本地状态(**点了立刻切换**,不发请求),再按「返回会话」左边的 **保存** 按钮写回 frontmatter(按钮三态:未修改 → 保存 → 已保存,多个技能待保存时显示「保存 (n)」);写盘后监听器立刻把新目录推给模型 |
|
|
347
381
|
| 新建 | 在「用户 Agents 技能」或「用户 DSH 技能」根下创建 `<name>/SKILL.md` |
|
|
348
382
|
| 删除 | 移动到同根目录的 `.trash/<时间戳>-<名字>`(可手工恢复,不硬删) |
|
|
349
383
|
| 只读保护 | 插件内嵌技能(如 `office-docx`、`diagnose-windows-sandbox-acl`)标注「内嵌」,开关与删除按钮禁用 |
|
|
@@ -414,7 +448,7 @@ pnpm add @restregular/dsh-skill-center
|
|
|
414
448
|
// <profile>/package.json
|
|
415
449
|
{
|
|
416
450
|
"dsh": { "profile": { "bundles": [/* … */ "@restregular/dsh-skill-center"] } },
|
|
417
|
-
"dependencies": { "@restregular/dsh-skill-center": "^0.3.
|
|
451
|
+
"dependencies": { "@restregular/dsh-skill-center": "^0.3.1" }
|
|
418
452
|
}
|
|
419
453
|
```
|
|
420
454
|
|
|
@@ -425,8 +459,8 @@ pnpm add @restregular/dsh-skill-center
|
|
|
425
459
|
> patch 里那条 loader 行的 `name` **必须逐字等于包名**(`@restregular/dsh-skill-center`)。
|
|
426
460
|
> 宿主 `@deepseek-ai/dsh-client-modules` 是按这个行名去定位该行的 `package.json` 并**严格比对**
|
|
427
461
|
> `name` 的(`nearestPackage`)。写成裸名 `dsh-skill-center` 时:宿主半边照样挂得上,
|
|
428
|
-
> 但浏览器半边会被**静默丢弃** ——
|
|
429
|
-
>
|
|
462
|
+
> 但浏览器半边会被**静默丢弃** —— 侧栏没有「技能中心」、`op=diagnose` 里 `client.inGraph` 是 `false`
|
|
463
|
+
> 且没有 `client.url`,而且日志里一个字都没有。0.2.1 修的就是这个;0.2.0 发出去的是裸名。
|
|
430
464
|
|
|
431
465
|
> [!IMPORTANT]
|
|
432
466
|
> 客户端 bundle 还必须用**包名**注册:`__ModuleLoader__.load({ id: '@restregular/dsh-skill-center' })`,
|
|
@@ -453,8 +487,10 @@ pnpm add @restregular/dsh-skill-center
|
|
|
453
487
|
profile 的 `node_modules`。
|
|
454
488
|
2. **浏览器半边进模块图了吗**:`…/api?op=diagnose` 里的 `client.inGraph` 必须是 `true`。
|
|
455
489
|
若 `host.mounted` 为 `true` 而它是 `false`,就是上面那个「行名 ≠ 包名」。
|
|
456
|
-
3. **bundle
|
|
457
|
-
|
|
490
|
+
3. **bundle 有没有被服务**:浏览器只会拿到**带版本号**的地址
|
|
491
|
+
(`/plugins/@restregular/dsh-skill-center/client.js?rev=<12 位十六进制>`),而路由**故意拒绝**
|
|
492
|
+
版本不匹配的请求 —— 所以即使一切正常,直接要裸路径也会回 `404`。请从 `op=diagnose` 的
|
|
493
|
+
`client.url` 取地址(现在它还会在 `client.entryId` 里告诉你命中哪一行),再取那个地址。
|
|
458
494
|
4. **跑离线合成自检**(不用重启宿主):`node verify-compose.mjs <profileDir>`,
|
|
459
495
|
现在它会因为「行名解析不回本包」直接 FAIL。
|
|
460
496
|
5. `pnpm add` 必须**在 profile 目录里**执行;装到别处(全局 `npm i -g`、别的目录)profile 合成不到。
|
|
@@ -469,6 +505,9 @@ pnpm add @restregular/dsh-skill-center
|
|
|
469
505
|
- 写操作只针对**落在受管技能根里、真实存在于磁盘**的文件:
|
|
470
506
|
`<projectRoot>/.dsh/skills`、`<projectRoot>/.agents/skills`、`$DSH_HOME/skills`、`$DSH_AGENTS_HOME/skills`。
|
|
471
507
|
- frontmatter 改写是字符串级编辑,只动与调用策略相关的键,其余逐字保留(含行尾风格)。
|
|
508
|
+
- 受管根内的技能,`list` 的调用策略**以磁盘上的文件为准**,不照抄注册表快照:技能目录是缓存的,
|
|
509
|
+
而 watcher 的失效事件总在写盘之后一小会儿才到 —— 只信快照的面板会在刚保存完的瞬间把界面弹回
|
|
510
|
+
旧值,这正是「拨完开关又自己跳回去、手动刷新才变」的根因。
|
|
472
511
|
- 删除只 rename 进 `.trash`。
|
|
473
512
|
- 另注册一个内嵌技能提供方(`ctx.skills.registerProvider`,指向包内 `skills/skill-authoring/`)
|
|
474
513
|
和 `skill_create` 工具(`ctx.inject(['tools'], …)` + `ctx.tools.register`)。
|
|
@@ -493,6 +532,11 @@ ctx.slots.inject('main', () => ctx.slots.register({
|
|
|
493
532
|
样式只用宿主主题令牌 `--dsw-alias-*` / `--dsw-radius-*` / `--dsw-focus-ring-*`,
|
|
494
533
|
`<style>` 带 `data-plugin="dsh-skill-center"`(避免被宿主的无主样式回收误删)。
|
|
495
534
|
|
|
535
|
+
开关的改动先落在组件状态里:开关的值取「本地覆盖 → 目录快照」的先后顺序,
|
|
536
|
+
保存按钮的三态由「待保存集合 + 是否保存过」推出。保存之后本地覆盖会继续留着,
|
|
537
|
+
直到某次 `list` 的快照真的与它一致(`load()` 只撤销已经追平的那些)——
|
|
538
|
+
所以宿主的目录快照慢半拍也不会把界面弹回去。
|
|
539
|
+
|
|
496
540
|
### API 示例
|
|
497
541
|
|
|
498
542
|
```powershell
|
|
@@ -537,12 +581,28 @@ node "$here\verify-skill.mjs"
|
|
|
537
581
|
(目录名 vs frontmatter、description 非空、`resourceBase` 目录真实存在、正文表格/链接里点到的
|
|
538
582
|
`references/` 等路径是否真的存在)、检查工具 schema 是否落在 `@deepseek-ai/dsh-tools` 支持的子集内,
|
|
539
583
|
然后**真的跑一遍** `skill_create`:新建、资源落盘、覆盖、重名、非法名、路径穿越、平坦文件、
|
|
540
|
-
|
|
584
|
+
显式项目根,以及三条降级路径;另外还断言:受管根技能的调用策略取自磁盘文件,即使注册表快照
|
|
585
|
+
给的是旧值。
|
|
586
|
+
|
|
587
|
+
`client-smoke.mjs` 用迷你 React 把真实面板渲染在真宿主回环路由上,再驱动「暂存 + 保存」全流程:
|
|
588
|
+
拨开关不发任何写请求、点保存只发一次带两个策略字段的 `setInvocation`、按钮按
|
|
589
|
+
未修改 → 保存 → 已保存 走(再改一次回到保存)、以及宿主目录快照慢半拍也弹不回开关。
|
|
590
|
+
它拦下了所有 POST,所以跑一遍不会改动任何真实 `SKILL.md`。
|
|
541
591
|
|
|
542
592
|
> 搬迁/改名注意:客户端模块表按 `(loader baseUrl, loader 行名)` 缓存包元信息,不含文件时间戳。
|
|
543
593
|
> 把包挪到别处后,运行中的宿主仍握着旧路径;浏览器端用的是激活时读进内存的快照,
|
|
544
594
|
> 所以刷新页面照常可用,**下次重启宿主**才会解析新路径。
|
|
545
595
|
|
|
596
|
+
### 发版
|
|
597
|
+
|
|
598
|
+
改 `version` → 提交 → 推送 → 在仓库根用有该 scope 发布权限的 token 执行 `npm publish`。
|
|
599
|
+
注册表对成功的发布回 `202 {"success":true}`,随后该版本进入 **validating** 阶段:验证通过之前,
|
|
600
|
+
`/@restregular/dsh-skill-center/<版本>` 与 tarball 地址都回 `404`,`dist-tags` 仍指向上一个版本。
|
|
601
|
+
这是正常窗口,不是失败。**用带时间戳参数的版本地址重新探测**(`?t=<ticks>`)即可,
|
|
602
|
+
不要重复发布:同一版本再发一次会回
|
|
603
|
+
`E409 Cannot publish over previously staged version <版本>`(`npm stage publish` 则回
|
|
604
|
+
`E409 Cannot stage previously published version <版本>`),而 `npm stage list` 可能什么都不显示。
|
|
605
|
+
|
|
546
606
|
### 已知边界
|
|
547
607
|
|
|
548
608
|
- 面板占中央区域时,右栏的 `dsh-resource://` 预览不可用(宿主规定:无会话面时 `sidebarRight.openResource` 抛错)。
|
package/client/client.js
CHANGED
|
@@ -83,6 +83,9 @@ window.__ModuleLoader__.load({
|
|
|
83
83
|
noBody: '(正文为空)',
|
|
84
84
|
created: '已创建',
|
|
85
85
|
saved: '已保存',
|
|
86
|
+
unmodified: '未修改',
|
|
87
|
+
save: '保存',
|
|
88
|
+
saveHint: '把改动写回 SKILL.md 的 frontmatter',
|
|
86
89
|
back: '返回会话',
|
|
87
90
|
retry: '重试',
|
|
88
91
|
modelOff: '模型关闭',
|
|
@@ -132,6 +135,9 @@ window.__ModuleLoader__.load({
|
|
|
132
135
|
noBody: '(empty body)',
|
|
133
136
|
created: 'Created',
|
|
134
137
|
saved: 'Saved',
|
|
138
|
+
unmodified: 'Unmodified',
|
|
139
|
+
save: 'Save',
|
|
140
|
+
saveHint: 'Write the changes back into the SKILL.md frontmatter',
|
|
135
141
|
back: 'Back to session',
|
|
136
142
|
retry: 'Retry',
|
|
137
143
|
modelOff: 'model off',
|
|
@@ -389,6 +395,17 @@ window.__ModuleLoader__.load({
|
|
|
389
395
|
const [body, setBody] = React.useState(undefined);
|
|
390
396
|
const [pendingDelete, setPendingDelete] = React.useState(false);
|
|
391
397
|
const [creating, setCreating] = React.useState(false);
|
|
398
|
+
/** 已点开关、还没点"保存"的改动:技能名 → 部分调用策略。 */
|
|
399
|
+
const [pendingEdits, setPendingEdits] = React.useState({});
|
|
400
|
+
/**
|
|
401
|
+
* 面板显示值(技能名 → 部分调用策略),覆盖目录快照里的同名字段。
|
|
402
|
+
* 为什么要本地留存:宿主的技能目录是**缓存**的,写盘之后 chokidar 失效
|
|
403
|
+
* 事件到达前那次 `list` 仍是旧值 —— 只信快照的话,刚点完开关的界面会
|
|
404
|
+
* 自己弹回去(这正是"点了 toggle 不切换、手动刷新才切换"的原因)。
|
|
405
|
+
*/
|
|
406
|
+
const [shownEdits, setShownEdits] = React.useState({});
|
|
407
|
+
/** 本次进入面板后是否保存过:决定按钮显示"未修改"还是"已保存"。 */
|
|
408
|
+
const [savedOnce, setSavedOnce] = React.useState(false);
|
|
392
409
|
|
|
393
410
|
const load = React.useCallback(async () => {
|
|
394
411
|
setBusy(true);
|
|
@@ -397,6 +414,29 @@ window.__ModuleLoader__.load({
|
|
|
397
414
|
const payload = await apiGet('list', pickScope(sessions, scopeCache));
|
|
398
415
|
setCatalog(payload);
|
|
399
416
|
setSelected((previous) => previous ?? payload.skills[0]?.name);
|
|
417
|
+
// 快照已经追平本地面板值的字段就地撤销覆盖,覆盖集合只留"宿主还没追上"的部分。
|
|
418
|
+
setShownEdits((previous) => {
|
|
419
|
+
const next = {};
|
|
420
|
+
let changed = false;
|
|
421
|
+
for (const [name, patch] of Object.entries(previous)) {
|
|
422
|
+
const skill = payload.skills.find((candidate) => candidate.name === name);
|
|
423
|
+
if (skill === undefined) { changed = true; continue; }
|
|
424
|
+
const fresh = {
|
|
425
|
+
modelInvocable: skill.modelInvocable !== false,
|
|
426
|
+
userInvocable: skill.userInvocable !== false,
|
|
427
|
+
};
|
|
428
|
+
const keep = {};
|
|
429
|
+
if (patch.modelInvocable !== undefined && patch.modelInvocable !== fresh.modelInvocable) {
|
|
430
|
+
keep.modelInvocable = patch.modelInvocable;
|
|
431
|
+
}
|
|
432
|
+
if (patch.userInvocable !== undefined && patch.userInvocable !== fresh.userInvocable) {
|
|
433
|
+
keep.userInvocable = patch.userInvocable;
|
|
434
|
+
}
|
|
435
|
+
if (Object.keys(keep).length > 0) next[name] = keep;
|
|
436
|
+
else changed = true;
|
|
437
|
+
}
|
|
438
|
+
return changed ? next : previous;
|
|
439
|
+
});
|
|
400
440
|
} catch (cause) {
|
|
401
441
|
setError(String(cause?.message ?? cause));
|
|
402
442
|
} finally {
|
|
@@ -423,7 +463,7 @@ window.__ModuleLoader__.load({
|
|
|
423
463
|
/**
|
|
424
464
|
* 跑一个写操作,然后重读目录。
|
|
425
465
|
* @param action - 返回 Promise 的动作。
|
|
426
|
-
* @param okMessage -
|
|
466
|
+
* @param okMessage - 成功提示;不传就不显示绿色提示条。
|
|
427
467
|
*/
|
|
428
468
|
const mutate = async (action, okMessage) => {
|
|
429
469
|
setBusy(true);
|
|
@@ -440,16 +480,77 @@ window.__ModuleLoader__.load({
|
|
|
440
480
|
}
|
|
441
481
|
};
|
|
442
482
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
483
|
+
/**
|
|
484
|
+
* 一个技能此刻该显示的调用策略:本地改动优先,其次目录快照。
|
|
485
|
+
* @param skill - 目录条目。
|
|
486
|
+
* @returns `{ modelInvocable, userInvocable }`。
|
|
487
|
+
*/
|
|
488
|
+
const shownInvocation = (skill) => {
|
|
489
|
+
const local = shownEdits[skill.name] ?? {};
|
|
490
|
+
return {
|
|
491
|
+
modelInvocable: local.modelInvocable ?? (skill.modelInvocable !== false),
|
|
492
|
+
userInvocable: local.userInvocable ?? (skill.userInvocable !== false),
|
|
493
|
+
};
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 拨一个开关:只改本地状态,界面立刻反映,写盘交给"保存"。
|
|
498
|
+
* @param skill - 目录条目。
|
|
499
|
+
* @param patch - 本次改动的字段。
|
|
500
|
+
*/
|
|
501
|
+
const editInvocation = (skill, patch) => {
|
|
502
|
+
setShownEdits((previous) => ({
|
|
503
|
+
...previous,
|
|
504
|
+
[skill.name]: { ...(previous[skill.name] ?? {}), ...patch },
|
|
505
|
+
}));
|
|
506
|
+
setPendingEdits((previous) => ({
|
|
507
|
+
...previous,
|
|
508
|
+
[skill.name]: { ...(previous[skill.name] ?? {}), ...patch },
|
|
509
|
+
}));
|
|
510
|
+
setSavedOnce(false);
|
|
511
|
+
setError(undefined);
|
|
512
|
+
setNotice(undefined);
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* 保存全部未保存的调用策略改动:逐个 POST,然后重读目录。
|
|
517
|
+
* 重读拿到的可能仍是旧快照(目录缓存),所以显示值继续由 shownEdits 兜住,
|
|
518
|
+
* 直到快照追平 —— 撤销覆盖的逻辑在 load() 里。
|
|
519
|
+
*/
|
|
520
|
+
const saveEdits = async () => {
|
|
521
|
+
const entries = Object.entries(pendingEdits);
|
|
522
|
+
if (entries.length === 0) return;
|
|
523
|
+
setBusy(true);
|
|
524
|
+
setError(undefined);
|
|
525
|
+
setNotice(undefined);
|
|
526
|
+
try {
|
|
527
|
+
for (const [name, patch] of entries) {
|
|
528
|
+
const skill = skills.find((candidate) => candidate.name === name);
|
|
529
|
+
if (skill === undefined) continue;
|
|
530
|
+
const target = { ...shownInvocation(skill), ...patch };
|
|
531
|
+
await apiPost({
|
|
532
|
+
op: 'setInvocation',
|
|
533
|
+
...pickScope(sessions, scopeCache),
|
|
534
|
+
name,
|
|
535
|
+
modelInvocable: target.modelInvocable,
|
|
536
|
+
userInvocable: target.userInvocable,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
setPendingEdits({});
|
|
540
|
+
setSavedOnce(true);
|
|
541
|
+
await load();
|
|
542
|
+
} catch (cause) {
|
|
543
|
+
setError(String(cause?.message ?? cause));
|
|
544
|
+
} finally {
|
|
545
|
+
setBusy(false);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
/** 有多少个技能带着未保存的改动。 */
|
|
550
|
+
const pendingCount = Object.keys(pendingEdits).length;
|
|
551
|
+
const saveLabel = pendingCount === 0
|
|
552
|
+
? (savedOnce ? t('saved') : t('unmodified'))
|
|
553
|
+
: pendingCount === 1 ? t('save') : `${t('save')} (${pendingCount})`;
|
|
453
554
|
|
|
454
555
|
const removeSkill = (skill) => mutate(
|
|
455
556
|
async () => {
|
|
@@ -457,6 +558,18 @@ window.__ModuleLoader__.load({
|
|
|
457
558
|
setSelected(undefined);
|
|
458
559
|
setBody(undefined);
|
|
459
560
|
setPendingDelete(false);
|
|
561
|
+
setPendingEdits((previous) => {
|
|
562
|
+
if (!(skill.name in previous)) return previous;
|
|
563
|
+
const next = { ...previous };
|
|
564
|
+
delete next[skill.name];
|
|
565
|
+
return next;
|
|
566
|
+
});
|
|
567
|
+
setShownEdits((previous) => {
|
|
568
|
+
if (!(skill.name in previous)) return previous;
|
|
569
|
+
const next = { ...previous };
|
|
570
|
+
delete next[skill.name];
|
|
571
|
+
return next;
|
|
572
|
+
});
|
|
460
573
|
},
|
|
461
574
|
t('deleted'),
|
|
462
575
|
);
|
|
@@ -481,6 +594,14 @@ window.__ModuleLoader__.load({
|
|
|
481
594
|
? t('subtitle')
|
|
482
595
|
: `${t('total', { n: skills.length })} · ${scopeText}`),
|
|
483
596
|
h('span', { className: 'dsh-sc-spacer', key: 'sp' }),
|
|
597
|
+
h('button', {
|
|
598
|
+
key: 'save',
|
|
599
|
+
type: 'button',
|
|
600
|
+
className: `dsh-sc-btn${pendingCount > 0 ? ' dsh-sc-btn-primary' : ''}`,
|
|
601
|
+
disabled: busy || pendingCount === 0,
|
|
602
|
+
title: t('saveHint'),
|
|
603
|
+
onClick: saveEdits,
|
|
604
|
+
}, saveLabel),
|
|
484
605
|
h('button', {
|
|
485
606
|
key: 'back',
|
|
486
607
|
type: 'button',
|
|
@@ -579,19 +700,19 @@ window.__ModuleLoader__.load({
|
|
|
579
700
|
h('div', { key: 'sw', className: 'dsh-sc-section' }, [
|
|
580
701
|
h(Switch, {
|
|
581
702
|
key: 'model',
|
|
582
|
-
checked: current.modelInvocable
|
|
703
|
+
checked: shownInvocation(current).modelInvocable,
|
|
583
704
|
disabled: busy || current.writable !== true,
|
|
584
705
|
label: t('model'),
|
|
585
706
|
hint: t('modelHint'),
|
|
586
|
-
onChange: (value) =>
|
|
707
|
+
onChange: (value) => editInvocation(current, { modelInvocable: value }),
|
|
587
708
|
}),
|
|
588
709
|
h(Switch, {
|
|
589
710
|
key: 'user',
|
|
590
|
-
checked: current.userInvocable
|
|
711
|
+
checked: shownInvocation(current).userInvocable,
|
|
591
712
|
disabled: busy || current.writable !== true,
|
|
592
713
|
label: t('user'),
|
|
593
714
|
hint: t('userHint'),
|
|
594
|
-
onChange: (value) =>
|
|
715
|
+
onChange: (value) => editInvocation(current, { userInvocable: value }),
|
|
595
716
|
}),
|
|
596
717
|
]),
|
|
597
718
|
current.writable !== true
|
package/lib/index.js
CHANGED
|
@@ -71,6 +71,15 @@ const BUNDLED_SKILL_NAMES = ['skill-authoring'];
|
|
|
71
71
|
/** provider 名:面板的 provider 列显示这个名字。 */
|
|
72
72
|
const BUNDLED_SKILL_PROVIDER = 'dsh-skill-center';
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* 浏览器模块图里本插件那一行的 id 候选:图按**包名**编号,插件 id 只是宿主侧名字。
|
|
76
|
+
* 两个都收,免得 loader 行名或注册 id 变一处就误报。
|
|
77
|
+
*/
|
|
78
|
+
const BROWSER_ENTRY_IDS = new Set([
|
|
79
|
+
'@restregular/dsh-skill-center',
|
|
80
|
+
'dsh-skill-center',
|
|
81
|
+
]);
|
|
82
|
+
|
|
74
83
|
/**
|
|
75
84
|
* 随包技能的 rank,与 `@deepseek-ai/dsh-skill` 的 `BUNDLED_SKILL_RANK` 一致:
|
|
76
85
|
* 项目根(100/200)与用户根(400/500)的同名技能都能盖过它。
|
|
@@ -200,6 +209,22 @@ function frontmatterField(text, key) {
|
|
|
200
209
|
return raw.replace(/^["']/u, '').replace(/["']$/u, '');
|
|
201
210
|
}
|
|
202
211
|
|
|
212
|
+
/**
|
|
213
|
+
* 读 frontmatter 里的布尔字段,取值规则与 dsh-skill-filesystem 的
|
|
214
|
+
* `frontmatterBoolean` 一致(true/false/1/0/yes/no/on/off,大小写不敏感)。
|
|
215
|
+
* @param text - 文件全文。
|
|
216
|
+
* @param key - 字段名。
|
|
217
|
+
* @returns 布尔值,或 undefined(缺字段或值非法)。
|
|
218
|
+
*/
|
|
219
|
+
function frontmatterBoolean(text, key) {
|
|
220
|
+
const raw = frontmatterField(text, key);
|
|
221
|
+
if (raw === undefined) return undefined;
|
|
222
|
+
const value = raw.toLowerCase();
|
|
223
|
+
if (value === 'true' || value === '1' || value === 'yes' || value === 'on') return true;
|
|
224
|
+
if (value === 'false' || value === '0' || value === 'no' || value === 'off') return false;
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
203
228
|
// ---------------------------------------------------------------------------
|
|
204
229
|
// 随包技能:注册成 provider,资源用 resourceBase 目录暴露
|
|
205
230
|
// ---------------------------------------------------------------------------
|
|
@@ -487,13 +512,29 @@ function describeSkill(skill, roots) {
|
|
|
487
512
|
const root = rootOfPath(path, roots);
|
|
488
513
|
const onDisk = path !== undefined && existsSync(path) && statSync(path).isFile();
|
|
489
514
|
let bytes;
|
|
515
|
+
let invocation;
|
|
490
516
|
if (onDisk) {
|
|
491
517
|
try {
|
|
492
518
|
bytes = statSync(path).size;
|
|
493
519
|
} catch {
|
|
494
520
|
bytes = undefined;
|
|
495
521
|
}
|
|
522
|
+
// 受管根里的技能以**磁盘为准**:技能目录是缓存的,写完盘之后 watcher 的失效
|
|
523
|
+
// 事件到达之前,快照里的调用策略还是旧值 —— 面板若照抄快照,刚拨完开关的界面
|
|
524
|
+
// 会被自己重读回来的旧值弹回去(手动刷新才会变)。这里现读一次 frontmatter 求解。
|
|
525
|
+
if (root !== undefined) {
|
|
526
|
+
try {
|
|
527
|
+
const text = readFileSync(path, 'utf8');
|
|
528
|
+
invocation = {
|
|
529
|
+
modelInvocable: frontmatterBoolean(text, 'disable-model-invocation') !== true,
|
|
530
|
+
userInvocable: frontmatterBoolean(text, 'user-invocable') !== false,
|
|
531
|
+
};
|
|
532
|
+
} catch {
|
|
533
|
+
invocation = undefined;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
496
536
|
}
|
|
537
|
+
const effective = invocation ?? skill.invocation;
|
|
497
538
|
return {
|
|
498
539
|
name: skill.name,
|
|
499
540
|
description: skill.description,
|
|
@@ -502,8 +543,8 @@ function describeSkill(skill, roots) {
|
|
|
502
543
|
source: skill.source,
|
|
503
544
|
path,
|
|
504
545
|
directory: path === undefined ? undefined : dirname(path),
|
|
505
|
-
modelInvocable:
|
|
506
|
-
userInvocable:
|
|
546
|
+
modelInvocable: effective?.modelInvocable !== false,
|
|
547
|
+
userInvocable: effective?.userInvocable !== false,
|
|
507
548
|
rootId: root?.id,
|
|
508
549
|
rootLabel: root?.label,
|
|
509
550
|
writable: root !== undefined && onDisk,
|
|
@@ -551,7 +592,10 @@ async function opDiagnose(ctx, request) {
|
|
|
551
592
|
const clientModules = ctx.get('clientModules');
|
|
552
593
|
const graph = clientModules?.graph?.();
|
|
553
594
|
const entries = Array.isArray(graph?.entries) ? graph.entries : [];
|
|
554
|
-
|
|
595
|
+
// 浏览器模块图按**包名**给行编号(客户端 bundle 的注册 id 也是包名),
|
|
596
|
+
// 插件 id 只是宿主侧的 loader 名字:只按 'dsh-skill-center' 找会永远找不到,
|
|
597
|
+
// diagnose 就会把"客户端半边好好地挂在图里"误报成 inGraph: false。
|
|
598
|
+
const mine = entries.find((entry) => BROWSER_ENTRY_IDS.has(entry.id));
|
|
555
599
|
let catalog;
|
|
556
600
|
try {
|
|
557
601
|
const catalogRead = await readCatalog(ctx, request);
|
|
@@ -564,6 +608,7 @@ async function opDiagnose(ctx, request) {
|
|
|
564
608
|
host: { mounted: true, route: ROUTE },
|
|
565
609
|
client: {
|
|
566
610
|
inGraph: mine !== undefined,
|
|
611
|
+
entryId: mine?.id,
|
|
567
612
|
graphRevision: graph?.rev,
|
|
568
613
|
graphEntryCount: entries.length,
|
|
569
614
|
url: mine?.url,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@restregular/dsh-skill-center",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "技能中心 — DeepSeek Harness 技能管理面板:浏览当前会话可见的所有技能、查看正文、开关模型/用户调用、新建与删除;随包附带 skill-authoring 指导技能与 skill_create 模型工具。 · Skill Center for DeepSeek Harness: browse and manage the skills visible to the current session, with a bundled skill-authoring skill and a skill_create model tool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|