dsh-plugin-labels 0.8.0 → 0.9.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 +55 -20
- package/lib/index.js +497 -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.1**(本仓库即源码目录;通过 `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.1"`。
|
|
62
80
|
|
|
63
81
|
### 方式二:从 Git 仓库安装
|
|
64
82
|
|
|
@@ -74,7 +92,7 @@ host 启动约 **4 秒**后会自动补一次缺失标签:
|
|
|
74
92
|
|
|
75
93
|
## 使用
|
|
76
94
|
|
|
77
|
-
v0.
|
|
95
|
+
v0.9.1 起没有 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.1): every skill summary gets a Chinese purpose and a
|
|
27
|
+
* short usage hint prefixed onto 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 prefixed Chinese is
|
|
32
|
+
* what the model sees; the English tail may be cut, which is intended).
|
|
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,287 @@ 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
|
+
/** Prefix the Chinese purpose/usage so the truncated menu always shows it first. */
|
|
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
|
+
const prefix = "中文:" + zh + (usage === "" ? "" : ";用法:" + usage);
|
|
908
|
+
if (original.startsWith(prefix)) return original;
|
|
909
|
+
return prefix + " |" + original;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** Recover the original English description from an already-injected one. */
|
|
913
|
+
function originalSkillDescription(description, entry) {
|
|
914
|
+
if (typeof description !== "string") return "";
|
|
915
|
+
if (!entry || typeof entry !== "object") return description;
|
|
916
|
+
const zh = typeof entry.zh === "string" ? entry.zh.trim() : "";
|
|
917
|
+
if (zh === "") return description;
|
|
918
|
+
const usage = typeof entry.usage === "string" ? entry.usage.trim() : "";
|
|
919
|
+
const prefix = "中文:" + zh + (usage === "" ? "" : ";用法:" + usage);
|
|
920
|
+
if (description.startsWith(prefix + " |")) return description.slice(prefix.length + 3);
|
|
921
|
+
if (description.startsWith(prefix)) return description.slice(prefix.length).replace(/^ |/, "");
|
|
922
|
+
const oldSuffix = " |中文:" + zh + (usage === "" ? "" : ";用法:" + usage);
|
|
923
|
+
return description.endsWith(oldSuffix) ? description.slice(0, -oldSuffix.length) : description;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Return a shallow copy of one skill summary with the Chinese suffix applied. */
|
|
927
|
+
function skillWithTranslation(skill, entries) {
|
|
928
|
+
if (!skill || typeof skill !== "object") return skill;
|
|
929
|
+
const name = typeof skill.name === "string" ? skill.name : "";
|
|
930
|
+
const entry = name === "" ? undefined : entries[name];
|
|
931
|
+
const description = skillDescriptionWithChinese(skill.description, entry);
|
|
932
|
+
if (description === skill.description) return skill;
|
|
933
|
+
return { ...skill, description };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** Map a `snapshot`/`list` result through the skill translations. */
|
|
937
|
+
function mapSkillResult(methodName, value, home) {
|
|
938
|
+
const entries = readSkillCache(home).entries;
|
|
939
|
+
if (methodName === "list") {
|
|
940
|
+
if (!Array.isArray(value)) return value;
|
|
941
|
+
return value.map((skill) => skillWithTranslation(skill, entries));
|
|
942
|
+
}
|
|
943
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.skills)) return value;
|
|
944
|
+
return { ...value, skills: value.skills.map((skill) => skillWithTranslation(skill, entries)) };
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Patch the `skills` service so the `/` skill menu shows the Chinese purpose
|
|
949
|
+
* and usage next to the English description. Marker-based, idempotent,
|
|
950
|
+
* disposable, re-applied after a service swap; every failure keeps the host's
|
|
951
|
+
* own data.
|
|
952
|
+
*/
|
|
953
|
+
export function installSkillTranslations(ctx, home) {
|
|
954
|
+
const warn = (message) => {
|
|
955
|
+
try {
|
|
956
|
+
ctx.logger?.warn?.("[plugin-labels] " + message);
|
|
957
|
+
} catch {
|
|
958
|
+
// A logger failure must not break the host.
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
let installed = null; // { service, methods: [{ name, raw, wrapper }] }
|
|
962
|
+
|
|
963
|
+
const currentService = () => {
|
|
964
|
+
try {
|
|
965
|
+
return ctx.get("skills");
|
|
966
|
+
} catch {
|
|
967
|
+
return undefined;
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
const restore = () => {
|
|
972
|
+
const active = installed;
|
|
973
|
+
installed = null;
|
|
974
|
+
if (!active) return;
|
|
975
|
+
for (const method of active.methods) {
|
|
976
|
+
try {
|
|
977
|
+
if (!isSkillTranslationWrapper(active.service[method.name])) continue;
|
|
978
|
+
try {
|
|
979
|
+
delete active.service[method.name];
|
|
980
|
+
} catch {
|
|
981
|
+
// Frozen/exotic service object; fall back to the saved method.
|
|
982
|
+
}
|
|
983
|
+
if (isSkillTranslationWrapper(active.service[method.name])) active.service[method.name] = method.raw;
|
|
984
|
+
} catch (error) {
|
|
985
|
+
warn("failed to restore skills." + method.name + ": " + String((error && error.message) || error));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
const tryPatch = () => {
|
|
991
|
+
try {
|
|
992
|
+
const service = currentService();
|
|
993
|
+
if (!service || (typeof service !== "object" && typeof service !== "function")) return;
|
|
994
|
+
const alreadyWrapped = ["snapshot", "list"].filter((name) => isSkillTranslationWrapper(service[name]));
|
|
995
|
+
if (alreadyWrapped.length > 0) {
|
|
996
|
+
if (!installed) {
|
|
997
|
+
installed = {
|
|
998
|
+
service,
|
|
999
|
+
methods: alreadyWrapped.map((name) => ({ name, raw: service[name], wrapper: service[name] })),
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (installed) restore();
|
|
1005
|
+
const methods = [];
|
|
1006
|
+
for (const methodName of ["snapshot", "list"]) {
|
|
1007
|
+
const raw = service[methodName];
|
|
1008
|
+
if (typeof raw !== "function") continue;
|
|
1009
|
+
const wrapper = markSkillTranslationWrapper(function (...args) {
|
|
1010
|
+
let result;
|
|
1011
|
+
try {
|
|
1012
|
+
result = raw.apply(service, args);
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
return Promise.reject(error);
|
|
1015
|
+
}
|
|
1016
|
+
// These methods are async today but tolerate a sync result.
|
|
1017
|
+
return Promise.resolve(result).then(
|
|
1018
|
+
(value) => {
|
|
1019
|
+
try {
|
|
1020
|
+
return mapSkillResult(methodName, value, home);
|
|
1021
|
+
} catch {
|
|
1022
|
+
return value;
|
|
1023
|
+
}
|
|
1024
|
+
},
|
|
1025
|
+
(error) => {
|
|
1026
|
+
throw error;
|
|
1027
|
+
},
|
|
1028
|
+
);
|
|
1029
|
+
});
|
|
1030
|
+
try {
|
|
1031
|
+
service[methodName] = wrapper;
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
warn("could not wrap skills." + methodName + ": " + String((error && error.message) || error));
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
if (!isSkillTranslationWrapper(service[methodName])) {
|
|
1037
|
+
warn("could not wrap skills." + methodName + ": the assignment had no effect");
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
methods.push({ name: methodName, raw, wrapper });
|
|
1041
|
+
}
|
|
1042
|
+
if (methods.length > 0) installed = { service, methods };
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
warn("skills translation hook skipped: " + String((error && error.message) || error));
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
const status = () => ({
|
|
1049
|
+
patched: installed !== null,
|
|
1050
|
+
service: Boolean(currentService()),
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
tryPatch();
|
|
1054
|
+
return { dispose: restore, tryPatch, status };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
679
1057
|
/** Read the configured default route; fall back to the official DeepSeek route. */
|
|
680
1058
|
function modelOf(ctx) {
|
|
681
1059
|
try {
|
|
@@ -776,7 +1154,7 @@ function handleList(home, req, res, officialMetaStatus) {
|
|
|
776
1154
|
}));
|
|
777
1155
|
// Diagnostics for the experimental official-page override; clients ignore
|
|
778
1156
|
// unknown fields, so this stays backward compatible.
|
|
779
|
-
let officialMeta = { enabled: true, patched: false, service: false, bundles: false };
|
|
1157
|
+
let officialMeta = { enabled: true, patched: false, service: false, bundles: false, skills: false };
|
|
780
1158
|
if (typeof officialMetaStatus === "function") {
|
|
781
1159
|
try {
|
|
782
1160
|
officialMeta = officialMetaStatus();
|
|
@@ -897,6 +1275,8 @@ async function handleLabel(home, req, res) {
|
|
|
897
1275
|
* tokens twice and race the atomic cache rename, so refuse it instead.
|
|
898
1276
|
*/
|
|
899
1277
|
let translateInFlight = false;
|
|
1278
|
+
/** Separate single-flight lock for skill translations (HTTP 409 on concurrency). */
|
|
1279
|
+
let skillTranslateInFlight = false;
|
|
900
1280
|
|
|
901
1281
|
/**
|
|
902
1282
|
* One startup pass that fills missing labels. Never throws (a failed auto-run
|
|
@@ -995,6 +1375,90 @@ async function handleImport(home, req, res) {
|
|
|
995
1375
|
if (applied > 0) writeCache(home, cache);
|
|
996
1376
|
json(res, 200, { ok: true, applied, skipped, labels: cache.entries });
|
|
997
1377
|
}
|
|
1378
|
+
/** GET /skills: skill catalogue with the cached Chinese translations. */
|
|
1379
|
+
async function handleSkillsList(ctx, home, req, res) {
|
|
1380
|
+
if (!isLoopback(req)) {
|
|
1381
|
+
json(res, 403, { ok: false, error: "loopback only" });
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (!isSameOriginRequest(req) || req.method !== "GET") {
|
|
1385
|
+
json(res, 403, { ok: false, error: "cross-site or non-GET request rejected" });
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
try {
|
|
1389
|
+
const { available, skills } = await skillsSnapshot(ctx);
|
|
1390
|
+
const cache = readSkillCache(home);
|
|
1391
|
+
const items = skills.map((skill) => {
|
|
1392
|
+
const name = typeof skill.name === "string" ? skill.name : "";
|
|
1393
|
+
const entry = name === "" ? null : cache.entries[name] || null;
|
|
1394
|
+
const item = {
|
|
1395
|
+
// The service may already have our suffix injected; report the
|
|
1396
|
+
// original English description here.
|
|
1397
|
+
name,
|
|
1398
|
+
description: originalSkillDescription(skill.description, entry),
|
|
1399
|
+
translation: entry,
|
|
1400
|
+
};
|
|
1401
|
+
if (typeof skill.whenToUse === "string" && skill.whenToUse !== "") item.whenToUse = skill.whenToUse;
|
|
1402
|
+
return item;
|
|
1403
|
+
});
|
|
1404
|
+
json(res, 200, {
|
|
1405
|
+
ok: true,
|
|
1406
|
+
available,
|
|
1407
|
+
count: items.length,
|
|
1408
|
+
translated: items.filter((item) => item.translation !== null).length,
|
|
1409
|
+
pending: items.filter((item) => item.translation === null).length,
|
|
1410
|
+
skills: items,
|
|
1411
|
+
});
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
respondError(res, error);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/** POST /skills/translate: fill missing skill explanations (single-flight). */
|
|
1418
|
+
async function handleSkillsTranslate(ctx, home, req, res) {
|
|
1419
|
+
if (!isLoopback(req)) {
|
|
1420
|
+
json(res, 403, { ok: false, error: "loopback only" });
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
if (skillTranslateInFlight) {
|
|
1424
|
+
json(res, 409, { ok: false, error: "a skill translation is already running" });
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
const result = await translateSkillsPending(ctx, home, MAX_PENDING_PER_CALL);
|
|
1428
|
+
json(res, 200, {
|
|
1429
|
+
ok: true,
|
|
1430
|
+
available: result.available,
|
|
1431
|
+
added: result.added,
|
|
1432
|
+
remaining: result.remaining,
|
|
1433
|
+
total: result.total,
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/** POST /skills/label: set one manual skill translation (source:"manual"). */
|
|
1438
|
+
async function handleSkillsLabel(home, req, res) {
|
|
1439
|
+
if (!isLoopback(req)) {
|
|
1440
|
+
json(res, 403, { ok: false, error: "loopback only" });
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
const payload = await readJsonBody(req);
|
|
1444
|
+
const name = typeof payload.name === "string" ? payload.name.trim() : "";
|
|
1445
|
+
const zh = typeof payload.zh === "string" ? payload.zh.trim() : "";
|
|
1446
|
+
const usage = typeof payload.usage === "string" ? payload.usage.trim() : "";
|
|
1447
|
+
if (name === "") throw badRequest("name is required");
|
|
1448
|
+
if (zh === "") throw badRequest("zh is required");
|
|
1449
|
+
if (Array.from(zh).length > MAX_SKILL_ZH) throw badRequest("zh is too long (max " + MAX_SKILL_ZH + ")");
|
|
1450
|
+
if (Array.from(usage).length > MAX_SKILL_USAGE) throw badRequest("usage is too long (max " + MAX_SKILL_USAGE + ")");
|
|
1451
|
+
const cache = readSkillCache(home);
|
|
1452
|
+
cache.entries[name] = {
|
|
1453
|
+
zh: truncateChars(zh, MAX_SKILL_ZH),
|
|
1454
|
+
usage: truncateChars(usage, MAX_SKILL_USAGE),
|
|
1455
|
+
source: "manual",
|
|
1456
|
+
at: new Date().toISOString(),
|
|
1457
|
+
};
|
|
1458
|
+
writeSkillCache(home, cache);
|
|
1459
|
+
json(res, 200, { ok: true, name });
|
|
1460
|
+
}
|
|
1461
|
+
|
|
998
1462
|
/** Wrap an async handler so a rejection always becomes a JSON answer. */
|
|
999
1463
|
function guarded(res, req, work) {
|
|
1000
1464
|
if (!isSameOriginRequest(req)) {
|
|
@@ -1019,9 +1483,11 @@ export function apply(ctx) {
|
|
|
1019
1483
|
// the routes; the service listener re-applies them after a service swap.
|
|
1020
1484
|
const officialMeta = installOfficialMeta(ctx, home);
|
|
1021
1485
|
const bundleMeta = installBundleMeta(ctx, home);
|
|
1486
|
+
const skillTranslations = installSkillTranslations(ctx, home);
|
|
1022
1487
|
ctx.effect(() => () => {
|
|
1023
1488
|
officialMeta.dispose();
|
|
1024
1489
|
bundleMeta.dispose();
|
|
1490
|
+
skillTranslations.dispose();
|
|
1025
1491
|
}, "plugin-labels: restore official plugin metadata wrappers");
|
|
1026
1492
|
ctx.effect(() => {
|
|
1027
1493
|
try {
|
|
@@ -1029,19 +1495,22 @@ export function apply(ctx) {
|
|
|
1029
1495
|
return ctx.on("internal/service", (serviceName) => {
|
|
1030
1496
|
if (serviceName === "pluginPackages") officialMeta.tryPatch();
|
|
1031
1497
|
if (serviceName === "pluginManager") bundleMeta.tryPatch();
|
|
1498
|
+
if (serviceName === "skills") skillTranslations.tryPatch();
|
|
1032
1499
|
});
|
|
1033
1500
|
} catch {
|
|
1034
1501
|
return () => {};
|
|
1035
1502
|
}
|
|
1036
1503
|
}, "plugin-labels: watch the official metadata services");
|
|
1037
|
-
// Auto-translate: one pass a few seconds after startup fills missing
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1504
|
+
// Auto-translate: one pass a few seconds after startup fills missing plugin
|
|
1505
|
+
// labels, then missing skill explanations, so a newly installed plugin or
|
|
1506
|
+
// skill gets Chinese text without any UI. Never blocks startup;
|
|
1507
|
+
// DSH_PLUGIN_LABELS_AUTO_TRANSLATE=0 turns it off.
|
|
1040
1508
|
if (autoTranslateEnabled()) {
|
|
1041
1509
|
ctx.effect(() => {
|
|
1042
1510
|
const timer = setTimeout(() => {
|
|
1043
1511
|
Promise.resolve()
|
|
1044
1512
|
.then(() => runAutoTranslate(ctx, home))
|
|
1513
|
+
.then(() => translateSkillsPending(ctx, home, MAX_SKILLS_PER_AUTO))
|
|
1045
1514
|
.catch(() => {});
|
|
1046
1515
|
}, AUTO_TRANSLATE_DELAY_MS);
|
|
1047
1516
|
try {
|
|
@@ -1064,6 +1533,7 @@ export function apply(ctx) {
|
|
|
1064
1533
|
handler: (req, res) => handleList(home, req, res, () => ({
|
|
1065
1534
|
...officialMeta.status(),
|
|
1066
1535
|
bundles: bundleMeta.status().patched,
|
|
1536
|
+
skills: skillTranslations.status().patched,
|
|
1067
1537
|
})),
|
|
1068
1538
|
}), "plugin-labels: installed plugin list route");
|
|
1069
1539
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -1081,4 +1551,19 @@ export function apply(ctx) {
|
|
|
1081
1551
|
path: IMPORT_PATH,
|
|
1082
1552
|
handler: (req, res) => guarded(res, req, () => handleImport(home, req, res)),
|
|
1083
1553
|
}), "plugin-labels: import labels route");
|
|
1554
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1555
|
+
kind: "exact",
|
|
1556
|
+
path: SKILLS_PATH,
|
|
1557
|
+
handler: (req, res) => handleSkillsList(ctx, home, req, res),
|
|
1558
|
+
}), "plugin-labels: skill list route");
|
|
1559
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1560
|
+
kind: "exact",
|
|
1561
|
+
path: SKILLS_TRANSLATE_PATH,
|
|
1562
|
+
handler: (req, res) => guarded(res, req, () => handleSkillsTranslate(ctx, home, req, res)),
|
|
1563
|
+
}), "plugin-labels: skill translate route");
|
|
1564
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1565
|
+
kind: "exact",
|
|
1566
|
+
path: SKILLS_LABEL_PATH,
|
|
1567
|
+
handler: (req, res) => guarded(res, req, () => handleSkillsLabel(home, req, res)),
|
|
1568
|
+
}), "plugin-labels: skill manual label route");
|
|
1084
1569
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-labels",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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.",
|