dsh-plugin-labels 0.8.0 → 0.9.0
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 +55 -20
- package/lib/index.js +492 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,20 +3,23 @@
|
|
|
3
3
|
[](https://github.com/qyy2005/dsh-plugin-labels/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
> 版本更新记录见 [CHANGELOG.md](CHANGELOG.md),或在 GitHub [Releases](https://github.com/qyy2005/dsh-plugin-labels/releases) 查看。
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
给 DSH(DeepSeek Harness)**已安装插件**配「中文名 + 一句话说明」:官方「插件」页的已安装插件行、插件包卡片与详情页都会**原地显示中文名与中文描述**(由 host 覆盖显示元数据);新装插件会在启动后自动翻译。同时给 **skills(技能)** 补中文说明:`/` 技能菜单里每条技能显示「英文原描述 |中文:<作用>;用法:<怎么用>」。**本插件没有任何 UI 入口**(没有 tab、没有设置项),管理通过 loopback HTTP API 或让 AI 代改。
|
|
9
|
+
|
|
10
|
+
- 当前版本:**0.9.0**(本仓库即源码目录;通过 `plugin_manager install_bundle` 从 GitHub 或 npm 安装到 DSH profile)
|
|
11
|
+
- 只支持 DSH 桌面版 Web GUI(开发验证版本 0.2.1-rc.2 / rc.3)
|
|
10
12
|
|
|
11
13
|
## 功能
|
|
12
14
|
|
|
13
15
|
- **中文名 + 一句话说明**:每个插件一条中文名(≤10 字)和一句说明(≤80 字);说明必须写清**怎么用**,不只是“是什么”(见下方「标签格式」)。
|
|
14
16
|
- **官方页原地中文**:官方「插件」页的已安装插件行、点开的详情页,以及插件包卡片(`listBundles`)都会直接显示中文名与中文描述;未翻译的插件回退英文。默认开启。
|
|
15
|
-
-
|
|
17
|
+
- **技能中文翻译**:`/` 技能菜单里每条技能追加「|中文:作用;用法:怎么用」,看懂技能是做什么、什么时候用(见「技能中文翻译」)。
|
|
18
|
+
- **自动翻译新插件与新技能**:host 启动约 4 秒后自动补一次缺失标签(每次最多 20 条),再补最多 10 条技能说明;只补空缺、不覆盖手改。
|
|
16
19
|
- **手改优先**:手动设置的标签不会被自动翻译覆盖;显式“重翻”才会覆盖。
|
|
17
|
-
- **无 UI 管理入口**:通过
|
|
20
|
+
- **无 UI 管理入口**:通过 7 个 loopback HTTP API(见「使用」)或让 AI/脚本直接调用它们来翻译、手改、重翻、导入导出。
|
|
18
21
|
- **导出 / 导入 API**:导出带 `source`(`ai` / `manual`)的 JSON,导入时保留来源,只接受已安装插件名(≤64KB)。
|
|
19
|
-
-
|
|
22
|
+
- **缓存**:插件标签在 `<DSH_HOME>/plugin-labels.json`(v2),技能说明在 `<DSH_HOME>/plugin-labels-skills.json`(v1);均 UTF-8 无 BOM、原子写,旧 v1 标签数据可迁移读取。
|
|
20
23
|
|
|
21
24
|
### 标签格式
|
|
22
25
|
|
|
@@ -53,12 +56,27 @@ host 启动约 **4 秒**后会自动补一次缺失标签:
|
|
|
53
56
|
- 与手动翻译共用同一个单飞锁与 120 秒超时;失败只写日志(warn),不抛错、不阻塞启动。
|
|
54
57
|
- 手动补翻译:`POST /api/plugin-labels/translate`(见「使用」)。
|
|
55
58
|
|
|
59
|
+
## 技能中文翻译
|
|
60
|
+
|
|
61
|
+
用户的技能大多是英文。本插件会在技能目录的描述末尾追加中文说明,**`/` 技能菜单里每条技能显示**:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
<英文原描述> |中文:<中文作用>;用法:<怎么用>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- **实现**:host 包装 `skills` 服务的 `snapshot`/`list`,命中缓存才追加,未命中保持原样(`/` 菜单走 `skills.list`,模型侧技能目录走同一份 description)。
|
|
68
|
+
- **缓存**:`<DSH_HOME>/plugin-labels-skills.json`(独立于插件标签缓存,`version: 1`,UTF-8 无 BOM、原子写);每条 `{zh ≤80 字, usage ≤60 字, source: "ai" | "manual", at}`。
|
|
69
|
+
- **API**(见「使用」):`GET /api/plugin-labels/skills`、`POST /api/plugin-labels/skills/translate`、`POST /api/plugin-labels/skills/label`。
|
|
70
|
+
- **自动翻译**:启动后约 4 秒的自动任务在补完插件标签后,会再补最多 **10** 条技能说明(`MAX_SKILLS_PER_AUTO`);只补空缺、不覆盖手改;`DSH_PLUGIN_LABELS_AUTO_TRANSLATE=0` 同时关闭插件与技能自动翻译。
|
|
71
|
+
- **限制**:只翻译/解释技能的目录描述,**技能正文仍是英文**;模型侧技能目录会把 description 截断到 500 字,追加在末尾的中文通常不会明显增加模型上下文;技能服务(`ctx.get("skills")`)不可用时静默跳过,不影响其它功能。
|
|
72
|
+
- **重启**:host 代码在启动时安装,**需要重启 DSH 一次**;`GET /api/plugin-labels/list` 的 `officialMeta.skills` 可确认包装是否已生效。
|
|
73
|
+
|
|
56
74
|
## 安装
|
|
57
75
|
|
|
58
76
|
### 方式一:npm 包(推荐,`npm publish` 后)
|
|
59
77
|
|
|
60
78
|
在 DSH 插件页 → 添加插件 → 填包名 `dsh-plugin-labels`;或在创造模式会话里 `plugin_manager` → `install_bundle` → `target: "dsh-plugin-labels"`。发布后 profile 依赖形如
|
|
61
|
-
`"dsh-plugin-labels": "dsh-plugin-labels@^0.
|
|
79
|
+
`"dsh-plugin-labels": "dsh-plugin-labels@^0.9.0"`。
|
|
62
80
|
|
|
63
81
|
### 方式二:从 Git 仓库安装
|
|
64
82
|
|
|
@@ -74,7 +92,7 @@ host 启动约 **4 秒**后会自动补一次缺失标签:
|
|
|
74
92
|
|
|
75
93
|
## 使用
|
|
76
94
|
|
|
77
|
-
v0.
|
|
95
|
+
v0.9.0 起没有 UI 入口,全部通过 7 个 loopback HTTP API 操作(也可以让 AI/脚本代改):
|
|
78
96
|
|
|
79
97
|
```powershell
|
|
80
98
|
# 查看已装插件 + 标签 + 诊断字段(officialMeta)
|
|
@@ -96,9 +114,21 @@ Invoke-RestMethod -Method Post -ContentType 'application/json' `
|
|
|
96
114
|
# 导入导出的 JSON(只保留已装插件名,保留 ai/manual 来源)
|
|
97
115
|
Invoke-RestMethod -Method Post -ContentType 'application/json' -InFile labels.json `
|
|
98
116
|
http://127.0.0.1:<端口>/api/plugin-labels/import
|
|
117
|
+
|
|
118
|
+
# 技能:列出技能 + 已缓存的中文说明
|
|
119
|
+
Invoke-WebRequest -UseBasicParsing http://127.0.0.1:<端口>/api/plugin-labels/skills
|
|
120
|
+
|
|
121
|
+
# 技能:补翻译缺失的技能说明(每次最多 20 条;可重复调用补剩余)
|
|
122
|
+
Invoke-RestMethod -Method Post -ContentType 'application/json' -Body '{}' `
|
|
123
|
+
http://127.0.0.1:<端口>/api/plugin-labels/skills/translate
|
|
124
|
+
|
|
125
|
+
# 技能:手改一条技能说明(source=manual,自动翻译不会覆盖)
|
|
126
|
+
Invoke-RestMethod -Method Post -ContentType 'application/json' `
|
|
127
|
+
-Body '{"name":"office","zh":"Office 文档处理","usage":"对 AI 说:用 office 技能处理文档"}' `
|
|
128
|
+
http://127.0.0.1:<端口>/api/plugin-labels/skills/label
|
|
99
129
|
```
|
|
100
130
|
|
|
101
|
-
端口就是当前 DSH Web GUI 的端口;这些路由只接受 loopback 请求且必须是 POST(`/list` 为 GET)。自动翻译会替你补空缺,手改过的条目不会被自动覆盖。
|
|
131
|
+
端口就是当前 DSH Web GUI 的端口;这些路由只接受 loopback 请求且必须是 POST(`/list`、`/skills` 为 GET)。自动翻译会替你补空缺,手改过的条目不会被自动覆盖。
|
|
102
132
|
|
|
103
133
|
## 插件速查文档
|
|
104
134
|
|
|
@@ -116,9 +146,9 @@ npm run docs # 等价于 node scripts/gen-doc.mjs
|
|
|
116
146
|
|
|
117
147
|
## 兼容性
|
|
118
148
|
|
|
119
|
-
- DSH 桌面版 **0.2.1-rc.2**(Web GUI
|
|
120
|
-
- 本插件不依赖任何 UI slot:显示覆盖包装宿主的 `pluginPackages.metaOf()`(官方页行/详情)与 `pluginManager.listBundles()
|
|
121
|
-
-
|
|
149
|
+
- DSH 桌面版 **0.2.1-rc.2**(Web GUI;技能翻译接口在 **0.2.1-rc.3** 核实);依赖官方 client 基座模块与 host 服务,只 `require("react")`。
|
|
150
|
+
- 本插件不依赖任何 UI slot:显示覆盖包装宿主的 `pluginPackages.metaOf()`(官方页行/详情)与 `pluginManager.listBundles()`(插件包卡片),技能中文包装 `skills` 服务的 `snapshot`/`list`,client 只注册 `locale` 字典。
|
|
151
|
+
- 拿不到这些内部服务时静默回退:不显示中文(回退英文)、技能说明保持英文,插件其它功能与 API 仍可用。
|
|
122
152
|
|
|
123
153
|
## 开发说明
|
|
124
154
|
|
|
@@ -128,10 +158,13 @@ npm run docs # 等价于 node scripts/gen-doc.mjs
|
|
|
128
158
|
- `POST /api/plugin-labels/translate`(无参=补空缺;`{names:[...]}`=显式重翻)
|
|
129
159
|
- `POST /api/plugin-labels/label`(`{name,title,oneLine}` 或 `{name,action:"reset"}`)
|
|
130
160
|
- `POST /api/plugin-labels/import`
|
|
131
|
-
-
|
|
132
|
-
-
|
|
133
|
-
-
|
|
134
|
-
-
|
|
161
|
+
- `GET /api/plugin-labels/skills`(技能列表 + 已缓存中文说明)
|
|
162
|
+
- `POST /api/plugin-labels/skills/translate`(补技能说明,单飞锁,并发 409)
|
|
163
|
+
- `POST /api/plugin-labels/skills/label`(`{name,zh,usage}` 手改技能说明)
|
|
164
|
+
- **自动翻译**:`apply` 里注册一个 4 秒后执行一次的定时器(`unref`),先 `translatePending` 补插件标签、再 `translateSkillsPending` 补最多 10 条技能说明;可用 `DSH_PLUGIN_LABELS_AUTO_TRANSLATE=0` 关闭;异常只 warn。
|
|
165
|
+
- **显示/技能覆盖**:`installOfficialMeta` / `installBundleMeta` / `installSkillTranslations` 用 symbol 标记识别 wrapper,幂等、可 dispose、服务替换后重打、失败静默回退。`DSH_PLUGIN_LABELS_OFFICIAL_META=0` 关闭前两个显示覆盖(技能翻译不受影响)。
|
|
166
|
+
- **安全围栏**:loopback 校验、`sec-fetch-site` 跨站拒绝、POST-only、翻译单飞锁(插件与技能各一把,并发 409)、`AbortSignal.timeout(120s)`、未预期异常统一 500 不泄漏路径。
|
|
167
|
+
- **缓存**:插件标签 `<DSH_HOME>/plugin-labels.json`(v2,旧 v1 可迁移)、技能说明 `<DSH_HOME>/plugin-labels-skills.json`(v1);均 UTF-8 无 BOM,临时文件 + `rename` 原子写,单条非法条目丢弃。
|
|
135
168
|
- **验证**:`node --check lib/index.js`、`node --check lib/client.js`;host 可用桩 `ctx` 跑冒烟。
|
|
136
169
|
- 修改 host 后需重启 DSH 生效(本插件已没有需要刷新页面的 UI 改动)。
|
|
137
170
|
|
|
@@ -148,7 +181,7 @@ dsh-plugin-labels/
|
|
|
148
181
|
├─ package.json # manifest(dsh.bundle 组合 patch、dsh.client 平台与 inject)
|
|
149
182
|
├─ cordis.patch.yml # bundle 组合 patch
|
|
150
183
|
├─ lib/
|
|
151
|
-
│ ├─ index.js # host:HTTP API + 显示覆盖 + 自动翻译 +
|
|
184
|
+
│ ├─ index.js # host:HTTP API + 显示覆盖 + 技能中文 + 自动翻译 + 缓存
|
|
152
185
|
│ └─ client.js # client:模块骨架,仅注册 locale(v0.7.0 起无 UI)
|
|
153
186
|
├─ locale/
|
|
154
187
|
│ ├─ zh.json # 插件元数据(中文)
|
|
@@ -168,10 +201,12 @@ MIT,见 [LICENSE](LICENSE)。Copyright (c) 2026 qyy2005
|
|
|
168
201
|
|
|
169
202
|
## English summary
|
|
170
203
|
|
|
171
|
-
`dsh-plugin-labels` is a DSH (DeepSeek Harness) plugin that gives every installed plugin a Chinese name and a one-line explanation. The labels are shown **in place** on the official Plugins page: the installed-plugin rows, the detail pages and the plugin package cards (`pluginManager.listBundles`) all display the Chinese name and description, falling back to English for plugins without a label. **The plugin has no UI entry point** (no tab, no settings item): labels are managed through
|
|
204
|
+
`dsh-plugin-labels` is a DSH (DeepSeek Harness) plugin that gives every installed plugin a Chinese name and a one-line explanation. The labels are shown **in place** on the official Plugins page: the installed-plugin rows, the detail pages and the plugin package cards (`pluginManager.listBundles`) all display the Chinese name and description, falling back to English for plugins without a label. **The plugin has no UI entry point** (no tab, no settings item): labels are managed through loopback HTTP routes (or by an AI/script calling them), and results are cached under `<DSH_HOME>/plugin-labels.json` with manual edits never overwritten by automatic translation.
|
|
205
|
+
|
|
206
|
+
It also translates **skills**: each skill description in the `/` skill menu gets a Chinese purpose and usage hint appended (`English description |中文:…;用法:…`), cached separately in `<DSH_HOME>/plugin-labels-skills.json` and managed through `GET/POST /api/plugin-labels/skills…`. Only the catalogue description is translated, the skill body stays English; the model-side catalogue truncates descriptions at 500 characters.
|
|
172
207
|
|
|
173
208
|
Each label is a Chinese name (≤10 characters) plus a one-line explanation (≤80 characters) that must explain **how to use** the plugin: the problem it solves, where in DSH the user enters or operates it, and a concrete first step. When the entry point cannot be inferred, the model is instructed to write 入口以界面为准 instead of inventing menus.
|
|
174
209
|
|
|
175
|
-
Newly installed plugins are translated automatically: about 4 seconds after host startup one pass fills up to 20 missing labels (set `DSH_PLUGIN_LABELS_AUTO_TRANSLATE=0` and restart DSH to disable it). The display overrides can be disabled together with `DSH_PLUGIN_LABELS_OFFICIAL_META=0`; `GET /api/plugin-labels/list` reports `officialMeta: {enabled, patched, service, bundles}` for diagnostics.
|
|
210
|
+
Newly installed plugins are translated automatically: about 4 seconds after host startup one pass fills up to 20 missing labels and then up to 10 skill explanations (set `DSH_PLUGIN_LABELS_AUTO_TRANSLATE=0` and restart DSH to disable it). The display overrides can be disabled together with `DSH_PLUGIN_LABELS_OFFICIAL_META=0`; `GET /api/plugin-labels/list` reports `officialMeta: {enabled, patched, service, bundles, skills}` for diagnostics. These hooks require one DSH restart after installation.
|
|
176
211
|
|
|
177
|
-
The client half is a hand-written `__ModuleLoader__` bundle with no build step that only requires `react` (v0.7.0: locale registration only, no UI); the host half is an ESM plugin exposing
|
|
212
|
+
The client half is a hand-written `__ModuleLoader__` bundle with no build step that only requires `react` (v0.7.0: locale registration only, no UI); the host half is an ESM plugin exposing seven loopback-only HTTP routes plus the display overrides, the skill translation hook and the startup auto-translate. Verified on DSH desktop 0.2.1-rc.2 / rc.3. MIT licensed.
|
package/lib/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-plugin-labels — host half (v0.
|
|
2
|
+
* dsh-plugin-labels — host half (v0.9.0: plugin labels + skill translations).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* longer renders a tab):
|
|
4
|
+
* Loopback-only endpoints:
|
|
6
5
|
* GET /api/plugin-labels/list -> installed profile bundles + cached labels
|
|
7
6
|
* POST /api/plugin-labels/translate -> translate the bundles that have no label yet
|
|
8
7
|
* POST /api/plugin-labels/label -> set one manual label, or reset one back to AI
|
|
9
8
|
* POST /api/plugin-labels/import -> merge an exported labels JSON
|
|
9
|
+
* GET /api/plugin-labels/skills -> skills with their cached Chinese translations
|
|
10
|
+
* POST /api/plugin-labels/skills/translate -> translate missing skill explanations
|
|
11
|
+
* POST /api/plugin-labels/skills/label -> set one manual skill translation
|
|
10
12
|
*
|
|
11
13
|
* Translation uses the live Harness LLM service exactly like the official
|
|
12
14
|
* @deepseek-ai/dsh-experimental-auto-review plugin does:
|
|
@@ -21,19 +23,28 @@
|
|
|
21
23
|
* and a concrete first step when useful. The model is told never to invent
|
|
22
24
|
* menus: when the entry point cannot be inferred it must write 入口以界面为准.
|
|
23
25
|
*
|
|
26
|
+
* Skill translations (v0.9.0): every skill summary gets a Chinese purpose and a
|
|
27
|
+
* short usage hint appended to its `description`, so the `/` skill menu shows
|
|
28
|
+
* "English original |中文:…;用法:…". They live in their own cache
|
|
29
|
+
* <DSH_HOME>/plugin-labels-skills.json. Optional `whenToUse` is sent to the
|
|
30
|
+
* model, the skill body itself is not translated, and the model-side skill
|
|
31
|
+
* catalogue truncates descriptions at 500 characters (the appended Chinese
|
|
32
|
+
* usually falls past that point, so model context barely grows).
|
|
33
|
+
*
|
|
24
34
|
* Display overrides (all failure-safe; marker-detected because Cordis hands out
|
|
25
35
|
* traceable Proxies whose method references differ on every read):
|
|
26
36
|
* - `installOfficialMeta` wraps `pluginPackages.metaOf()` so the official
|
|
27
37
|
* Plugins page rows and detail pages show the Chinese labels in place.
|
|
28
38
|
* - `installBundleMeta` wraps `pluginManager.listBundles()` so the plugin
|
|
29
39
|
* package cards (which call `readPluginMeta` directly) show them too.
|
|
30
|
-
*
|
|
40
|
+
* - `installSkillTranslations` wraps the `skills` service `snapshot`/`list`.
|
|
41
|
+
* Set DSH_PLUGIN_LABELS_OFFICIAL_META=0 to disable the first two overrides.
|
|
31
42
|
*
|
|
32
43
|
* Auto translation: shortly after startup one pass fills up to
|
|
33
|
-
* MAX_PENDING_PER_CALL missing labels through the same core,
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* blocks host startup.
|
|
44
|
+
* MAX_PENDING_PER_CALL missing labels through the same core, then up to
|
|
45
|
+
* MAX_SKILLS_PER_AUTO skill explanations, so newly installed plugins and skills
|
|
46
|
+
* get Chinese text without any UI. Set DSH_PLUGIN_LABELS_AUTO_TRANSLATE=0 to
|
|
47
|
+
* disable it. It never throws into or blocks host startup.
|
|
37
48
|
*
|
|
38
49
|
* Failure policy: every handler catches its own errors and answers with JSON;
|
|
39
50
|
* nothing is allowed to escape as an unhandled rejection (that is fatal to the
|
|
@@ -51,6 +62,9 @@ export const LIST_PATH = "/api/plugin-labels/list";
|
|
|
51
62
|
export const TRANSLATE_PATH = "/api/plugin-labels/translate";
|
|
52
63
|
export const LABEL_PATH = "/api/plugin-labels/label";
|
|
53
64
|
export const IMPORT_PATH = "/api/plugin-labels/import";
|
|
65
|
+
export const SKILLS_PATH = "/api/plugin-labels/skills";
|
|
66
|
+
export const SKILLS_TRANSLATE_PATH = "/api/plugin-labels/skills/translate";
|
|
67
|
+
export const SKILLS_LABEL_PATH = "/api/plugin-labels/skills/label";
|
|
54
68
|
|
|
55
69
|
const CACHE_VERSION = 2;
|
|
56
70
|
const LEGACY_CACHE_VERSION = 1;
|
|
@@ -60,6 +74,12 @@ const MAX_ONE_LINE = 80;
|
|
|
60
74
|
const MAX_BODY_BYTES = 65536;
|
|
61
75
|
const TRANSLATE_TIMEOUT_MS = 120000;
|
|
62
76
|
|
|
77
|
+
const SKILL_CACHE_VERSION = 1;
|
|
78
|
+
const MAX_SKILL_ZH = 80;
|
|
79
|
+
const MAX_SKILL_USAGE = 60;
|
|
80
|
+
const MAX_SKILL_DESCRIPTION = 600;
|
|
81
|
+
const MAX_SKILLS_PER_AUTO = 10;
|
|
82
|
+
|
|
63
83
|
const SYSTEM_PROMPT = [
|
|
64
84
|
"You label DeepSeek Harness (DSH) plugins for a Chinese-speaking user.",
|
|
65
85
|
"For every plugin you receive, produce:",
|
|
@@ -73,6 +93,15 @@ const SYSTEM_PROMPT = [
|
|
|
73
93
|
'{"items":[{"name":"<exact package name>","title":"...","oneLine":"..."}]}',
|
|
74
94
|
].join("\n");
|
|
75
95
|
|
|
96
|
+
const SKILL_SYSTEM_PROMPT = [
|
|
97
|
+
"You translate DeepSeek Harness (DSH) skill descriptions for a Chinese-speaking user who cannot read English.",
|
|
98
|
+
"For every skill you receive, produce:",
|
|
99
|
+
"1. zh: a Chinese name plus one-line purpose, at most 80 Chinese characters, concrete (no marketing words, no literal package-name translation).",
|
|
100
|
+
"2. usage: how and when to use it, at most 60 Chinese characters — for example 对 AI 说\"用 X 技能\" or 输入 /<name>,when the description implies an invocation. Never invent a command that the description does not imply.",
|
|
101
|
+
"Answer with strict JSON only, no prose, no markdown fences:",
|
|
102
|
+
'{"items":[{"name":"<exact skill name>","zh":"...","usage":"..."}]}',
|
|
103
|
+
].join("\n");
|
|
104
|
+
|
|
76
105
|
/**
|
|
77
106
|
* Experimental display-layer override: wrap the host `pluginPackages.metaOf`
|
|
78
107
|
* service so the official Plugins page shows our Chinese labels in place
|
|
@@ -101,9 +130,11 @@ function displayOverrideEnabled() {
|
|
|
101
130
|
*/
|
|
102
131
|
const OFFICIAL_META_MARK = Symbol.for("dsh-plugin-labels.official-meta");
|
|
103
132
|
const BUNDLE_META_MARK = Symbol.for("dsh-plugin-labels.bundle-meta");
|
|
133
|
+
const SKILL_TRANSLATIONS_MARK = Symbol.for("dsh-plugin-labels.skill-translations");
|
|
104
134
|
|
|
105
135
|
const isOfficialMetaWrapper = (value) => Boolean(value && typeof value === "function" && value[OFFICIAL_META_MARK] === true);
|
|
106
136
|
const isBundleMetaWrapper = (value) => Boolean(value && typeof value === "function" && value[BUNDLE_META_MARK] === true);
|
|
137
|
+
const isSkillTranslationWrapper = (value) => Boolean(value && typeof value === "function" && value[SKILL_TRANSLATIONS_MARK] === true);
|
|
107
138
|
|
|
108
139
|
function markWithSymbol(wrapper, symbol) {
|
|
109
140
|
try {
|
|
@@ -127,6 +158,10 @@ function markBundleMetaWrapper(wrapper) {
|
|
|
127
158
|
return markWithSymbol(wrapper, BUNDLE_META_MARK);
|
|
128
159
|
}
|
|
129
160
|
|
|
161
|
+
function markSkillTranslationWrapper(wrapper) {
|
|
162
|
+
return markWithSymbol(wrapper, SKILL_TRANSLATIONS_MARK);
|
|
163
|
+
}
|
|
164
|
+
|
|
130
165
|
/** Split a configured specifier into its bare package name (`@scope/name/sub` -> `@scope/name`). */
|
|
131
166
|
export function bareNameOf(specifier) {
|
|
132
167
|
if (typeof specifier !== "string") return "";
|
|
@@ -439,6 +474,68 @@ function writeCache(home, cache) {
|
|
|
439
474
|
}
|
|
440
475
|
}
|
|
441
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Skill explanation cache: a separate file from the plugin-label cache so the
|
|
479
|
+
* two features never overwrite each other.
|
|
480
|
+
*/
|
|
481
|
+
function skillCachePath(home) {
|
|
482
|
+
return path.join(home, "plugin-labels-skills.json");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Validate one cached skill translation; invalid entries are dropped. */
|
|
486
|
+
function normalizeSkillEntry(value) {
|
|
487
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
488
|
+
const zh = typeof value.zh === "string" ? value.zh.trim() : "";
|
|
489
|
+
if (zh === "") return null;
|
|
490
|
+
const usage = typeof value.usage === "string" ? value.usage.trim() : "";
|
|
491
|
+
const source = value.source === "manual" ? "manual" : "ai";
|
|
492
|
+
const entry = { zh: zh.slice(0, MAX_SKILL_ZH), usage: usage.slice(0, MAX_SKILL_USAGE), source };
|
|
493
|
+
if (typeof value.at === "string" && value.at !== "") entry.at = value.at;
|
|
494
|
+
return entry;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function emptySkillCache() {
|
|
498
|
+
return { version: SKILL_CACHE_VERSION, entries: {} };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function readSkillCache(home) {
|
|
502
|
+
let parsed;
|
|
503
|
+
try {
|
|
504
|
+
parsed = JSON.parse(fs.readFileSync(skillCachePath(home), "utf8"));
|
|
505
|
+
} catch {
|
|
506
|
+
// Missing or corrupt skill cache: start over, never fail the request.
|
|
507
|
+
return emptySkillCache();
|
|
508
|
+
}
|
|
509
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptySkillCache();
|
|
510
|
+
if (parsed.version !== SKILL_CACHE_VERSION) return emptySkillCache();
|
|
511
|
+
if (!parsed.entries || typeof parsed.entries !== "object" || Array.isArray(parsed.entries)) return emptySkillCache();
|
|
512
|
+
const cache = emptySkillCache();
|
|
513
|
+
for (const [name, value] of Object.entries(parsed.entries)) {
|
|
514
|
+
if (typeof name !== "string" || name.trim() === "" || RESERVED_KEYS.has(name)) continue;
|
|
515
|
+
const entry = normalizeSkillEntry(value);
|
|
516
|
+
if (entry) cache.entries[name] = entry;
|
|
517
|
+
}
|
|
518
|
+
return cache;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function writeSkillCache(home, cache) {
|
|
522
|
+
const target = skillCachePath(home);
|
|
523
|
+
const temporary = target + ".tmp-" + process.pid;
|
|
524
|
+
// UTF-8 without BOM: DSH and Node parse these files as raw JSON text.
|
|
525
|
+
const body = JSON.stringify({ version: SKILL_CACHE_VERSION, entries: cache.entries || {} }, null, 2);
|
|
526
|
+
fs.writeFileSync(temporary, body, "utf8");
|
|
527
|
+
try {
|
|
528
|
+
fs.renameSync(temporary, target);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
try {
|
|
531
|
+
fs.unlinkSync(temporary);
|
|
532
|
+
} catch {
|
|
533
|
+
// The temp file is already gone.
|
|
534
|
+
}
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
442
539
|
/**
|
|
443
540
|
* Install the experimental official-page display override.
|
|
444
541
|
* Wraps `pluginPackages.metaOf(specifier, parentURL)` with our cached labels,
|
|
@@ -676,6 +773,282 @@ export function installBundleMeta(ctx, home) {
|
|
|
676
773
|
return { dispose: restore, tryPatch, status };
|
|
677
774
|
}
|
|
678
775
|
|
|
776
|
+
/** Read the current skill summaries from the host registry; never throws. */
|
|
777
|
+
async function skillsSnapshot(ctx) {
|
|
778
|
+
const warn = (message) => {
|
|
779
|
+
try {
|
|
780
|
+
ctx.logger?.warn?.("[plugin-labels] " + message);
|
|
781
|
+
} catch {
|
|
782
|
+
// A logger failure must not break the host.
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
try {
|
|
786
|
+
const service = ctx.get("skills");
|
|
787
|
+
if (!service || typeof service.snapshot !== "function") return { available: false, skills: [] };
|
|
788
|
+
const snapshot = await service.snapshot({});
|
|
789
|
+
if (!snapshot || !Array.isArray(snapshot.skills)) return { available: false, skills: [] };
|
|
790
|
+
return { available: true, skills: snapshot.skills };
|
|
791
|
+
} catch (error) {
|
|
792
|
+
warn("skills snapshot failed: " + String((error && error.message) || error));
|
|
793
|
+
return { available: false, skills: [] };
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function truncateChars(value, limit) {
|
|
798
|
+
const characters = Array.from(value);
|
|
799
|
+
return characters.length <= limit ? value : characters.slice(0, limit).join("");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function buildSkillsUserText(pending) {
|
|
803
|
+
return JSON.stringify(
|
|
804
|
+
{
|
|
805
|
+
instruction: "Translate and explain every skill below. Reply with the strict JSON object only.",
|
|
806
|
+
skills: pending.map((skill) => ({
|
|
807
|
+
name: skill.name,
|
|
808
|
+
description: truncateChars(typeof skill.description === "string" ? skill.description : "", MAX_SKILL_DESCRIPTION),
|
|
809
|
+
...(typeof skill.whenToUse === "string" && skill.whenToUse.trim() !== ""
|
|
810
|
+
? { whenToUse: truncateChars(skill.whenToUse, MAX_SKILL_DESCRIPTION) }
|
|
811
|
+
: {}),
|
|
812
|
+
})),
|
|
813
|
+
},
|
|
814
|
+
null,
|
|
815
|
+
2,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Parse the model's strict-JSON skill answer, keeping only names we asked about. */
|
|
820
|
+
function parseSkillTranslations(text, pending) {
|
|
821
|
+
let body = text.trim();
|
|
822
|
+
const fenced = body.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
823
|
+
if (fenced) body = fenced[1].trim();
|
|
824
|
+
const start = body.indexOf("{");
|
|
825
|
+
const end = body.lastIndexOf("}");
|
|
826
|
+
if (start < 0 || end <= start) throw new Error("model output contains no JSON object");
|
|
827
|
+
const parsed = JSON.parse(body.slice(start, end + 1));
|
|
828
|
+
const items = parsed && Array.isArray(parsed.items) ? parsed.items : [];
|
|
829
|
+
const wanted = new Set(pending.map((skill) => skill.name));
|
|
830
|
+
const out = {};
|
|
831
|
+
for (const item of items) {
|
|
832
|
+
if (!item || typeof item !== "object") continue;
|
|
833
|
+
const name = typeof item.name === "string" ? item.name : "";
|
|
834
|
+
if (!wanted.has(name)) continue;
|
|
835
|
+
const zh = typeof item.zh === "string" ? item.zh.trim() : "";
|
|
836
|
+
if (zh === "") continue;
|
|
837
|
+
const usage = typeof item.usage === "string" ? item.usage.trim() : "";
|
|
838
|
+
out[name] = {
|
|
839
|
+
zh: truncateChars(zh, MAX_SKILL_ZH),
|
|
840
|
+
usage: truncateChars(usage, MAX_SKILL_USAGE),
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
return out;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Translate the skill explanations that are still missing. Never throws: HTTP
|
|
848
|
+
* and the startup auto-run both treat failures as "nothing happened".
|
|
849
|
+
*/
|
|
850
|
+
export async function translateSkillsPending(ctx, home, limit = MAX_PENDING_PER_CALL) {
|
|
851
|
+
const warn = (message) => {
|
|
852
|
+
try {
|
|
853
|
+
ctx.logger?.warn?.("[plugin-labels] " + message);
|
|
854
|
+
} catch {
|
|
855
|
+
// A logger failure must not break the host.
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
if (skillTranslateInFlight) return { added: 0, remaining: 0, total: 0, available: true, busy: true };
|
|
859
|
+
skillTranslateInFlight = true;
|
|
860
|
+
try {
|
|
861
|
+
const { available, skills } = await skillsSnapshot(ctx);
|
|
862
|
+
if (!available) return { added: 0, remaining: 0, total: 0, available: false };
|
|
863
|
+
const cache = readSkillCache(home);
|
|
864
|
+
const pendingAll = skills.filter((skill) => skill && typeof skill.name === "string" && skill.name !== "" && !cache.entries[skill.name]);
|
|
865
|
+
const requested = Math.max(1, Number(limit) || MAX_PENDING_PER_CALL);
|
|
866
|
+
const pending = pendingAll.slice(0, requested);
|
|
867
|
+
const remaining = Math.max(0, pendingAll.length - pending.length);
|
|
868
|
+
const total = pendingAll.length;
|
|
869
|
+
if (pending.length === 0) return { added: 0, remaining, total, available: true };
|
|
870
|
+
const { provider, model } = modelOf(ctx);
|
|
871
|
+
const llm = ctx.get("llm");
|
|
872
|
+
if (!llm || typeof llm.stream !== "function") throw new Error("the llm service is not available in this profile");
|
|
873
|
+
const stream = llm.stream({
|
|
874
|
+
provider,
|
|
875
|
+
model,
|
|
876
|
+
temperature: 0,
|
|
877
|
+
system: SKILL_SYSTEM_PROMPT,
|
|
878
|
+
messages: [{ role: "user", content: [{ type: "text", text: buildSkillsUserText(pending) }] }],
|
|
879
|
+
signal: AbortSignal.timeout(TRANSLATE_TIMEOUT_MS),
|
|
880
|
+
});
|
|
881
|
+
const text = await collectText(stream);
|
|
882
|
+
const translations = parseSkillTranslations(text, pending);
|
|
883
|
+
const at = new Date().toISOString();
|
|
884
|
+
let added = 0;
|
|
885
|
+
for (const [name, value] of Object.entries(translations)) {
|
|
886
|
+
// Never overwrite an existing entry (a manual one always wins).
|
|
887
|
+
if (cache.entries[name]) continue;
|
|
888
|
+
cache.entries[name] = { zh: value.zh, usage: value.usage, source: "ai", at };
|
|
889
|
+
added += 1;
|
|
890
|
+
}
|
|
891
|
+
if (added > 0) writeSkillCache(home, cache);
|
|
892
|
+
return { added, remaining, total, available: true };
|
|
893
|
+
} catch (error) {
|
|
894
|
+
warn("skill translation failed: " + String((error && error.message) || error));
|
|
895
|
+
return { added: 0, remaining: 0, total: 0, available: true, error: String((error && error.message) || error) };
|
|
896
|
+
} finally {
|
|
897
|
+
skillTranslateInFlight = false;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** Append the Chinese purpose/usage to an English skill description. */
|
|
902
|
+
export function skillDescriptionWithChinese(original, entry) {
|
|
903
|
+
if (typeof original !== "string" || !entry || typeof entry !== "object") return original;
|
|
904
|
+
const zh = typeof entry.zh === "string" ? entry.zh.trim() : "";
|
|
905
|
+
if (zh === "") return original;
|
|
906
|
+
const usage = typeof entry.usage === "string" ? entry.usage.trim() : "";
|
|
907
|
+
return original + " |中文:" + zh + (usage === "" ? "" : ";用法:" + usage);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Recover the original English description from an already-injected one. */
|
|
911
|
+
function originalSkillDescription(description, entry) {
|
|
912
|
+
if (typeof description !== "string") return "";
|
|
913
|
+
if (!entry || typeof entry !== "object") return description;
|
|
914
|
+
const zh = typeof entry.zh === "string" ? entry.zh.trim() : "";
|
|
915
|
+
if (zh === "") return description;
|
|
916
|
+
const usage = typeof entry.usage === "string" ? entry.usage.trim() : "";
|
|
917
|
+
const suffix = " |中文:" + zh + (usage === "" ? "" : ";用法:" + usage);
|
|
918
|
+
return description.endsWith(suffix) ? description.slice(0, -suffix.length) : description;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** Return a shallow copy of one skill summary with the Chinese suffix applied. */
|
|
922
|
+
function skillWithTranslation(skill, entries) {
|
|
923
|
+
if (!skill || typeof skill !== "object") return skill;
|
|
924
|
+
const name = typeof skill.name === "string" ? skill.name : "";
|
|
925
|
+
const entry = name === "" ? undefined : entries[name];
|
|
926
|
+
const description = skillDescriptionWithChinese(skill.description, entry);
|
|
927
|
+
if (description === skill.description) return skill;
|
|
928
|
+
return { ...skill, description };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** Map a `snapshot`/`list` result through the skill translations. */
|
|
932
|
+
function mapSkillResult(methodName, value, home) {
|
|
933
|
+
const entries = readSkillCache(home).entries;
|
|
934
|
+
if (methodName === "list") {
|
|
935
|
+
if (!Array.isArray(value)) return value;
|
|
936
|
+
return value.map((skill) => skillWithTranslation(skill, entries));
|
|
937
|
+
}
|
|
938
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.skills)) return value;
|
|
939
|
+
return { ...value, skills: value.skills.map((skill) => skillWithTranslation(skill, entries)) };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Patch the `skills` service so the `/` skill menu shows the Chinese purpose
|
|
944
|
+
* and usage next to the English description. Marker-based, idempotent,
|
|
945
|
+
* disposable, re-applied after a service swap; every failure keeps the host's
|
|
946
|
+
* own data.
|
|
947
|
+
*/
|
|
948
|
+
export function installSkillTranslations(ctx, home) {
|
|
949
|
+
const warn = (message) => {
|
|
950
|
+
try {
|
|
951
|
+
ctx.logger?.warn?.("[plugin-labels] " + message);
|
|
952
|
+
} catch {
|
|
953
|
+
// A logger failure must not break the host.
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
let installed = null; // { service, methods: [{ name, raw, wrapper }] }
|
|
957
|
+
|
|
958
|
+
const currentService = () => {
|
|
959
|
+
try {
|
|
960
|
+
return ctx.get("skills");
|
|
961
|
+
} catch {
|
|
962
|
+
return undefined;
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
const restore = () => {
|
|
967
|
+
const active = installed;
|
|
968
|
+
installed = null;
|
|
969
|
+
if (!active) return;
|
|
970
|
+
for (const method of active.methods) {
|
|
971
|
+
try {
|
|
972
|
+
if (!isSkillTranslationWrapper(active.service[method.name])) continue;
|
|
973
|
+
try {
|
|
974
|
+
delete active.service[method.name];
|
|
975
|
+
} catch {
|
|
976
|
+
// Frozen/exotic service object; fall back to the saved method.
|
|
977
|
+
}
|
|
978
|
+
if (isSkillTranslationWrapper(active.service[method.name])) active.service[method.name] = method.raw;
|
|
979
|
+
} catch (error) {
|
|
980
|
+
warn("failed to restore skills." + method.name + ": " + String((error && error.message) || error));
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
const tryPatch = () => {
|
|
986
|
+
try {
|
|
987
|
+
const service = currentService();
|
|
988
|
+
if (!service || (typeof service !== "object" && typeof service !== "function")) return;
|
|
989
|
+
const alreadyWrapped = ["snapshot", "list"].filter((name) => isSkillTranslationWrapper(service[name]));
|
|
990
|
+
if (alreadyWrapped.length > 0) {
|
|
991
|
+
if (!installed) {
|
|
992
|
+
installed = {
|
|
993
|
+
service,
|
|
994
|
+
methods: alreadyWrapped.map((name) => ({ name, raw: service[name], wrapper: service[name] })),
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (installed) restore();
|
|
1000
|
+
const methods = [];
|
|
1001
|
+
for (const methodName of ["snapshot", "list"]) {
|
|
1002
|
+
const raw = service[methodName];
|
|
1003
|
+
if (typeof raw !== "function") continue;
|
|
1004
|
+
const wrapper = markSkillTranslationWrapper(function (...args) {
|
|
1005
|
+
let result;
|
|
1006
|
+
try {
|
|
1007
|
+
result = raw.apply(service, args);
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
return Promise.reject(error);
|
|
1010
|
+
}
|
|
1011
|
+
// These methods are async today but tolerate a sync result.
|
|
1012
|
+
return Promise.resolve(result).then(
|
|
1013
|
+
(value) => {
|
|
1014
|
+
try {
|
|
1015
|
+
return mapSkillResult(methodName, value, home);
|
|
1016
|
+
} catch {
|
|
1017
|
+
return value;
|
|
1018
|
+
}
|
|
1019
|
+
},
|
|
1020
|
+
(error) => {
|
|
1021
|
+
throw error;
|
|
1022
|
+
},
|
|
1023
|
+
);
|
|
1024
|
+
});
|
|
1025
|
+
try {
|
|
1026
|
+
service[methodName] = wrapper;
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
warn("could not wrap skills." + methodName + ": " + String((error && error.message) || error));
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
if (!isSkillTranslationWrapper(service[methodName])) {
|
|
1032
|
+
warn("could not wrap skills." + methodName + ": the assignment had no effect");
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
methods.push({ name: methodName, raw, wrapper });
|
|
1036
|
+
}
|
|
1037
|
+
if (methods.length > 0) installed = { service, methods };
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
warn("skills translation hook skipped: " + String((error && error.message) || error));
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
const status = () => ({
|
|
1044
|
+
patched: installed !== null,
|
|
1045
|
+
service: Boolean(currentService()),
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
tryPatch();
|
|
1049
|
+
return { dispose: restore, tryPatch, status };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
679
1052
|
/** Read the configured default route; fall back to the official DeepSeek route. */
|
|
680
1053
|
function modelOf(ctx) {
|
|
681
1054
|
try {
|
|
@@ -776,7 +1149,7 @@ function handleList(home, req, res, officialMetaStatus) {
|
|
|
776
1149
|
}));
|
|
777
1150
|
// Diagnostics for the experimental official-page override; clients ignore
|
|
778
1151
|
// unknown fields, so this stays backward compatible.
|
|
779
|
-
let officialMeta = { enabled: true, patched: false, service: false, bundles: false };
|
|
1152
|
+
let officialMeta = { enabled: true, patched: false, service: false, bundles: false, skills: false };
|
|
780
1153
|
if (typeof officialMetaStatus === "function") {
|
|
781
1154
|
try {
|
|
782
1155
|
officialMeta = officialMetaStatus();
|
|
@@ -897,6 +1270,8 @@ async function handleLabel(home, req, res) {
|
|
|
897
1270
|
* tokens twice and race the atomic cache rename, so refuse it instead.
|
|
898
1271
|
*/
|
|
899
1272
|
let translateInFlight = false;
|
|
1273
|
+
/** Separate single-flight lock for skill translations (HTTP 409 on concurrency). */
|
|
1274
|
+
let skillTranslateInFlight = false;
|
|
900
1275
|
|
|
901
1276
|
/**
|
|
902
1277
|
* One startup pass that fills missing labels. Never throws (a failed auto-run
|
|
@@ -995,6 +1370,90 @@ async function handleImport(home, req, res) {
|
|
|
995
1370
|
if (applied > 0) writeCache(home, cache);
|
|
996
1371
|
json(res, 200, { ok: true, applied, skipped, labels: cache.entries });
|
|
997
1372
|
}
|
|
1373
|
+
/** GET /skills: skill catalogue with the cached Chinese translations. */
|
|
1374
|
+
async function handleSkillsList(ctx, home, req, res) {
|
|
1375
|
+
if (!isLoopback(req)) {
|
|
1376
|
+
json(res, 403, { ok: false, error: "loopback only" });
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
if (!isSameOriginRequest(req) || req.method !== "GET") {
|
|
1380
|
+
json(res, 403, { ok: false, error: "cross-site or non-GET request rejected" });
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
try {
|
|
1384
|
+
const { available, skills } = await skillsSnapshot(ctx);
|
|
1385
|
+
const cache = readSkillCache(home);
|
|
1386
|
+
const items = skills.map((skill) => {
|
|
1387
|
+
const name = typeof skill.name === "string" ? skill.name : "";
|
|
1388
|
+
const entry = name === "" ? null : cache.entries[name] || null;
|
|
1389
|
+
const item = {
|
|
1390
|
+
// The service may already have our suffix injected; report the
|
|
1391
|
+
// original English description here.
|
|
1392
|
+
name,
|
|
1393
|
+
description: originalSkillDescription(skill.description, entry),
|
|
1394
|
+
translation: entry,
|
|
1395
|
+
};
|
|
1396
|
+
if (typeof skill.whenToUse === "string" && skill.whenToUse !== "") item.whenToUse = skill.whenToUse;
|
|
1397
|
+
return item;
|
|
1398
|
+
});
|
|
1399
|
+
json(res, 200, {
|
|
1400
|
+
ok: true,
|
|
1401
|
+
available,
|
|
1402
|
+
count: items.length,
|
|
1403
|
+
translated: items.filter((item) => item.translation !== null).length,
|
|
1404
|
+
pending: items.filter((item) => item.translation === null).length,
|
|
1405
|
+
skills: items,
|
|
1406
|
+
});
|
|
1407
|
+
} catch (error) {
|
|
1408
|
+
respondError(res, error);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** POST /skills/translate: fill missing skill explanations (single-flight). */
|
|
1413
|
+
async function handleSkillsTranslate(ctx, home, req, res) {
|
|
1414
|
+
if (!isLoopback(req)) {
|
|
1415
|
+
json(res, 403, { ok: false, error: "loopback only" });
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
if (skillTranslateInFlight) {
|
|
1419
|
+
json(res, 409, { ok: false, error: "a skill translation is already running" });
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
const result = await translateSkillsPending(ctx, home, MAX_PENDING_PER_CALL);
|
|
1423
|
+
json(res, 200, {
|
|
1424
|
+
ok: true,
|
|
1425
|
+
available: result.available,
|
|
1426
|
+
added: result.added,
|
|
1427
|
+
remaining: result.remaining,
|
|
1428
|
+
total: result.total,
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/** POST /skills/label: set one manual skill translation (source:"manual"). */
|
|
1433
|
+
async function handleSkillsLabel(home, req, res) {
|
|
1434
|
+
if (!isLoopback(req)) {
|
|
1435
|
+
json(res, 403, { ok: false, error: "loopback only" });
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const payload = await readJsonBody(req);
|
|
1439
|
+
const name = typeof payload.name === "string" ? payload.name.trim() : "";
|
|
1440
|
+
const zh = typeof payload.zh === "string" ? payload.zh.trim() : "";
|
|
1441
|
+
const usage = typeof payload.usage === "string" ? payload.usage.trim() : "";
|
|
1442
|
+
if (name === "") throw badRequest("name is required");
|
|
1443
|
+
if (zh === "") throw badRequest("zh is required");
|
|
1444
|
+
if (Array.from(zh).length > MAX_SKILL_ZH) throw badRequest("zh is too long (max " + MAX_SKILL_ZH + ")");
|
|
1445
|
+
if (Array.from(usage).length > MAX_SKILL_USAGE) throw badRequest("usage is too long (max " + MAX_SKILL_USAGE + ")");
|
|
1446
|
+
const cache = readSkillCache(home);
|
|
1447
|
+
cache.entries[name] = {
|
|
1448
|
+
zh: truncateChars(zh, MAX_SKILL_ZH),
|
|
1449
|
+
usage: truncateChars(usage, MAX_SKILL_USAGE),
|
|
1450
|
+
source: "manual",
|
|
1451
|
+
at: new Date().toISOString(),
|
|
1452
|
+
};
|
|
1453
|
+
writeSkillCache(home, cache);
|
|
1454
|
+
json(res, 200, { ok: true, name });
|
|
1455
|
+
}
|
|
1456
|
+
|
|
998
1457
|
/** Wrap an async handler so a rejection always becomes a JSON answer. */
|
|
999
1458
|
function guarded(res, req, work) {
|
|
1000
1459
|
if (!isSameOriginRequest(req)) {
|
|
@@ -1019,9 +1478,11 @@ export function apply(ctx) {
|
|
|
1019
1478
|
// the routes; the service listener re-applies them after a service swap.
|
|
1020
1479
|
const officialMeta = installOfficialMeta(ctx, home);
|
|
1021
1480
|
const bundleMeta = installBundleMeta(ctx, home);
|
|
1481
|
+
const skillTranslations = installSkillTranslations(ctx, home);
|
|
1022
1482
|
ctx.effect(() => () => {
|
|
1023
1483
|
officialMeta.dispose();
|
|
1024
1484
|
bundleMeta.dispose();
|
|
1485
|
+
skillTranslations.dispose();
|
|
1025
1486
|
}, "plugin-labels: restore official plugin metadata wrappers");
|
|
1026
1487
|
ctx.effect(() => {
|
|
1027
1488
|
try {
|
|
@@ -1029,19 +1490,22 @@ export function apply(ctx) {
|
|
|
1029
1490
|
return ctx.on("internal/service", (serviceName) => {
|
|
1030
1491
|
if (serviceName === "pluginPackages") officialMeta.tryPatch();
|
|
1031
1492
|
if (serviceName === "pluginManager") bundleMeta.tryPatch();
|
|
1493
|
+
if (serviceName === "skills") skillTranslations.tryPatch();
|
|
1032
1494
|
});
|
|
1033
1495
|
} catch {
|
|
1034
1496
|
return () => {};
|
|
1035
1497
|
}
|
|
1036
1498
|
}, "plugin-labels: watch the official metadata services");
|
|
1037
|
-
// Auto-translate: one pass a few seconds after startup fills missing
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1499
|
+
// Auto-translate: one pass a few seconds after startup fills missing plugin
|
|
1500
|
+
// labels, then missing skill explanations, so a newly installed plugin or
|
|
1501
|
+
// skill gets Chinese text without any UI. Never blocks startup;
|
|
1502
|
+
// DSH_PLUGIN_LABELS_AUTO_TRANSLATE=0 turns it off.
|
|
1040
1503
|
if (autoTranslateEnabled()) {
|
|
1041
1504
|
ctx.effect(() => {
|
|
1042
1505
|
const timer = setTimeout(() => {
|
|
1043
1506
|
Promise.resolve()
|
|
1044
1507
|
.then(() => runAutoTranslate(ctx, home))
|
|
1508
|
+
.then(() => translateSkillsPending(ctx, home, MAX_SKILLS_PER_AUTO))
|
|
1045
1509
|
.catch(() => {});
|
|
1046
1510
|
}, AUTO_TRANSLATE_DELAY_MS);
|
|
1047
1511
|
try {
|
|
@@ -1064,6 +1528,7 @@ export function apply(ctx) {
|
|
|
1064
1528
|
handler: (req, res) => handleList(home, req, res, () => ({
|
|
1065
1529
|
...officialMeta.status(),
|
|
1066
1530
|
bundles: bundleMeta.status().patched,
|
|
1531
|
+
skills: skillTranslations.status().patched,
|
|
1067
1532
|
})),
|
|
1068
1533
|
}), "plugin-labels: installed plugin list route");
|
|
1069
1534
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -1081,4 +1546,19 @@ export function apply(ctx) {
|
|
|
1081
1546
|
path: IMPORT_PATH,
|
|
1082
1547
|
handler: (req, res) => guarded(res, req, () => handleImport(home, req, res)),
|
|
1083
1548
|
}), "plugin-labels: import labels route");
|
|
1549
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1550
|
+
kind: "exact",
|
|
1551
|
+
path: SKILLS_PATH,
|
|
1552
|
+
handler: (req, res) => handleSkillsList(ctx, home, req, res),
|
|
1553
|
+
}), "plugin-labels: skill list route");
|
|
1554
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1555
|
+
kind: "exact",
|
|
1556
|
+
path: SKILLS_TRANSLATE_PATH,
|
|
1557
|
+
handler: (req, res) => guarded(res, req, () => handleSkillsTranslate(ctx, home, req, res)),
|
|
1558
|
+
}), "plugin-labels: skill translate route");
|
|
1559
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1560
|
+
kind: "exact",
|
|
1561
|
+
path: SKILLS_LABEL_PATH,
|
|
1562
|
+
handler: (req, res) => guarded(res, req, () => handleSkillsLabel(home, req, res)),
|
|
1563
|
+
}), "plugin-labels: skill manual label route");
|
|
1084
1564
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-labels",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Chinese names and one-line explanations for installed DSH plugins; automatic translation and in-place display on the official Plugins page.",
|