dsh-paoding 0.4.1 → 0.4.3

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 CHANGED
@@ -22,8 +22,9 @@ dsh-paoding(中文品牌「庖丁」,典出《庄子》——庖丁顺纹理
22
22
  - **保存前先体检**:预览/应用时自动过一遍检查——配置了但未挂载的工具、零工具角色、查无此名的模型、预设落后于配置,分级提示一目了然;CLI 侧 `dsh-paoding doctor` 六项只读自检(配置可解析、安装轨道、声明行托管块、孤儿目录、版本标记、预设漂移),一条命令查全身。
23
23
  - **场景模板一键换班底**:轻量(只留实现)、调研(只留检索)、全开(全员到齐、主 agent 带满检测库存)三档点一下就切,只动「谁在场 + 主 agent 额外带什么」,精调过的角色配置分毫不动。
24
24
  - **配置可导出可导入**:面板「更多」菜单一键下载 `dsh-paoding.config.yml`,换机迁移、备份整份拷走;导入先验格式,覆盖整体配置(含工作区段)需二次确认。
25
- - **面板中英双语**:界面文案 zh/en 全覆盖,语言随浏览器自动识别,「更多」菜单一键切换。
26
- - **版本更新有提醒,面板一键升级**:面板会对比 npm registry(npmmirror 镜像、GitHub release 依次兜底)上的最新版本,发现新版就地提示并附上发布页链接;点「升级」就地换上新版(profile 自动探测:普通 profile 走 `dsh plugin update dsh-paoding`;desktop profile 被 dsh CLI 的 Electron 专属守卫挡住,改在 profile 目录直跑 `pnpm add dsh-paoding@<版本>`),重启 DSH 后生效,编排预设会在重启时按新版自动重生成。开发检出的 link 形态无法就地升级,面板会提示切回 registry 版的办法;检测失败时页头留一行「版本检测失败 · 重试」(完整原因与代理排查提示在悬浮提示里),不弹窗打扰。
25
+ - **HTTP 接口直连配置**:预设的列举、新增、更新、删除走 `/api/paoding/presets` 四条接口,随 DSH Web 服务同进程挂载、本机回环直调;与面板「保存并应用」同一条生成管线、同一把写盘互斥闸——脚本和自动化工具改班底,不必开浏览器。
26
+ - **面板中英双语**:界面文案 zh/en 全覆盖,语言自动识别(显式选择 → 中文信号优先:浏览器语言、宿主页面语言、中文时区),「更多」菜单一键切换并记住。
27
+ - **版本更新有提醒,面板一键升级**:面板会对比 npm registry(npmmirror 镜像、GitHub release 依次兜底)上的最新版本,发现新版就地提示并附上发布页链接;点「升级」就地换上新版(profile 自动探测:普通 profile 走 `dsh plugin update dsh-paoding`;desktop profile 被 dsh CLI 的 Electron 专属守卫挡住,改在 profile 目录直跑 `pnpm add dsh-paoding@<版本>`,pnpm 找不到时自动回落 `corepack pnpm`),重启 DSH 后生效,编排预设会在重启时按新版自动重生成。开发检出的 link 形态无法就地升级,面板会提示切回 registry 版的办法;检测失败时页头留一行「版本检测失败 · 重试」(完整原因与代理排查提示在悬浮提示里),不弹窗打扰。
27
28
  - **来去自由**:不改 DSH 一行源码;装完新会话即用,卸载即删目录。
28
29
  - **两代宿主都兼容**:DSH 0.1.5 – 0.2.0-rc.2 全支持(0.1.5 / 0.1.6 / 0.1.7-rc.1 / 0.1.7-rc.2 / 0.2.0-rc.1 / 0.2.0-rc.2)——两代宿主发现预设的机制不同(目录扫描 vs patch 声明行),安装器自动检测宿主版本选对落点;编排组合里的 workflow worker 行也按宿主版本自动适配(0.1.5 用 workflow-worker-thread,0.1.6+ 换 workflow-ptc——旧包 0.1.6 起已删除,残留行正是 0.2.0 面板上「加载失败」的元凶),宿主升级、降级后的首次保存/自愈自动迁移,全程无感。
29
30
 
@@ -62,7 +63,7 @@ git clone https://github.com/lifangjin/dsh-paoding.git
62
63
  dsh plugin --profile web add link:"$PWD/dsh-paoding"
63
64
  ```
64
65
 
65
- 升级走面板「升级」按钮(普通 profile 等价 `dsh plugin update dsh-paoding`;desktop profile 则在 profile 目录直跑 `pnpm add` 精确版本)。详细步骤、升级与排查见 [安装文档](docs/installation.md)。
66
+ 升级走面板「升级」按钮(普通 profile 等价 `dsh plugin update dsh-paoding`;desktop profile 则在 profile 目录直跑 `pnpm add` 精确版本,无裸 pnpm 时自动用 `corepack pnpm`)。详细步骤、升级与排查见 [安装文档](docs/installation.md)。
66
67
 
67
68
  ## 怎么用
68
69
 
@@ -81,6 +82,14 @@ dsh plugin --profile web add link:"$PWD/dsh-paoding"
81
82
 
82
83
  所有偏好都写在 `~/.dsh/dsh-paoding.config.yml`:角色增删改、技能分配、专用模型、会话模式(one-shot / continuable)、启动方式(fresh / fork)、主 agent 的工具与技能……改完打开左侧栏底部的「庖丁配置」入口,点「保存并应用」即同步生效。预设每次由「静态源 + 配置」重新生成,不攒手工补丁。全部配置键说明见 [配置文档](docs/configuration.md);背后机制与成本账见 [架构文档](docs/architecture.md)。
83
84
 
85
+ 偏好走脚本的,还有一套 HTTP 接口:`/api/paoding/presets` 覆盖预设的列举 / 新增 / 更新 / 删除,随 DSH Web 服务同进程(端口即浏览器打开 DSH 的那个端口),本机回环直调即可:
86
+
87
+ ```bash
88
+ curl http://127.0.0.1:19387/api/paoding/presets
89
+ ```
90
+
91
+ 写操作与面板「保存并应用」同一条生成管线与写盘互斥。方法、请求体与错误语义见 [配置文档](docs/configuration.md)第 13 节。
92
+
84
93
  ## 文档
85
94
 
86
95
  | 文档 | 内容 |
@@ -88,7 +97,7 @@ dsh plugin --profile web add link:"$PWD/dsh-paoding"
88
97
  | [架构](docs/architecture.md) | 为什么这样设计——概述、成本账、角色与工具、对应 DSH 原生机制、Token 治理、已知边界。 |
89
98
  | [安装](docs/installation.md) | 官方插件通道一条命令安装、版本支持与预设落点双轨(DSH 0.1.5 – 0.2.0-rc.2)、首装自动化与版本标记、升级、开发检出自装、host patch 检测机制、庖丁配置面板、卸载、排查。 |
90
99
  | [编排](docs/orchestration.md) | 编排总览、失败如何呈现与三层处理、one-shot vs continuable、上下文隔离。 |
91
- | [配置](docs/configuration.md) | 配置键全参考、内置角色微调、主 agent 工具与技能、自定义角色、按角色分模型、角色会话模式。 |
100
+ | [配置](docs/configuration.md) | 配置键全参考、工具条目通配符、内置角色微调、主 agent 工具与技能、自定义角色、按角色分模型、角色会话与启动方式、场景模板、配置导出导入、检查提示、HTTP 接口。 |
92
101
 
93
102
  ## 贡献
94
103
 
package/README_en.md CHANGED
@@ -22,8 +22,9 @@ dsh-paoding (Chinese brand 「庖丁」/ Pao Ding, from the Zhuangzi parable of
22
22
  - **A health check before every save**: preview/apply runs a built-in check — tools configured but not mounted, zero-tool roles, unknown model names, a preset lagging behind the config — each flagged by severity; on the CLI side, `dsh-paoding doctor` runs six read-only checks (config parses, install track, managed declaration block, orphan directories, version marker, preset drift) in one command.
23
23
  - **Scenario templates swap the roster in one click**: Lite (implement only), Research (search roles only), and Full (everyone on deck, main agent carrying the whole detected inventory) — templates only touch who is present and what the main agent carries; hand-tuned role settings are never touched.
24
24
  - **Config export & import**: download `dsh-paoding.config.yml` from the panel's More menu for migration or backup; imports are validated first, and overwriting the whole config (workspaces included) asks for confirmation.
25
- - **Panel in English and Chinese**: every UI string ships in both languages, auto-detected from the browser, switchable from the More menu.
26
- - **Notified of new releases, one-click upgrade in the panel**: the panel compares your local version against the latest npm registry publish (npmmirror and GitHub release as successive fallbacks) and points to the release page when a newer one is out. The Upgrade button swaps in the new version in place (profile auto-detected): regular profiles go through `dsh plugin update dsh-paoding`, while the Electron-owned desktop profile — which the dsh CLI guards against — gets a direct `pnpm add dsh-paoding@<version>` run in the profile directory; a DSH restart applies it, and the orchestrator preset regenerates itself against the new version on startup. A dev checkout in link: form cannot be upgraded in place — the panel tells you how to switch back to the registry version. If the check fails (offline, rate-limited), a subdued "version check failed · retry" row stays in the header (full causes and proxy hints in its tooltip) without popping anything up.
25
+ - **Config over HTTP**: list, create, update, and delete presets through the four `/api/paoding/presets` endpoints, served in-process by the DSH web service and callable over loopback; they ride the panel's Save & Apply generation pipeline and share its write lock, so scripts and automation tools can reshape the roster without opening a browser.
26
+ - **Panel in English and Chinese**: every UI string ships in both languages, auto-detected (explicit choice first, then Chinese signals — browser language, host page language, Chinese time zones — outrank English ones, since the Electron shell pins navigator to en-US regardless of the OS), switchable from the More menu and remembered.
27
+ - **Notified of new releases, one-click upgrade in the panel**: the panel compares your local version against the latest npm registry publish (npmmirror and GitHub release as successive fallbacks) and points to the release page when a newer one is out. The Upgrade button swaps in the new version in place (profile auto-detected): regular profiles go through `dsh plugin update dsh-paoding`, while the Electron-owned desktop profile — which the dsh CLI guards against — gets a direct `pnpm add dsh-paoding@<version>` run in the profile directory, falling back to `corepack pnpm` when no bare pnpm is on PATH; a DSH restart applies it, and the orchestrator preset regenerates itself against the new version on startup. A dev checkout in link: form cannot be upgraded in place — the panel tells you how to switch back to the registry version. If the check fails (offline, rate-limited), a subdued "version check failed · retry" row stays in the header (full causes and proxy hints in its tooltip) without popping anything up.
27
28
  - **Installs and uninstalls cleanly**: no DSH source changes; active in a new session right after install, gone when you delete one directory.
28
29
  - **Works on both host generations**: DSH 0.1.5 through 0.2.0-rc.2 are all supported (0.1.5 / 0.1.6 / 0.1.7-rc.1 / 0.1.7-rc.2 / 0.2.0-rc.1 / 0.2.0-rc.2) — the two generations discover presets differently (directory scan vs patch declaration rows), and the installer picks the right landing automatically; from 0.2.0 the workflow worker row in the orchestration composition also adapts to the host version at generation time (0.1.5 uses workflow-worker-thread, 0.1.6+ swaps to workflow-ptc). Migration happens on the first save/self-heal after a host upgrade or downgrade, all without user action.
29
30
 
@@ -62,7 +63,7 @@ git clone https://github.com/lifangjin/dsh-paoding.git
62
63
  dsh plugin --profile web add link:"$PWD/dsh-paoding"
63
64
  ```
64
65
 
65
- Upgrades go through the panel's Upgrade button (regular profiles: equivalent to `dsh plugin update dsh-paoding`; the desktop profile: a direct `pnpm add` of the exact version in the profile directory). Full steps, upgrading, and troubleshooting are in the [installation guide](docs/installation_en.md).
66
+ Upgrades go through the panel's Upgrade button (regular profiles: equivalent to `dsh plugin update dsh-paoding`; the desktop profile: a direct `pnpm add` of the exact version in the profile directory, via `corepack pnpm` when no bare pnpm exists). Full steps, upgrading, and troubleshooting are in the [installation guide](docs/installation_en.md).
66
67
 
67
68
  ## How It Works in Practice
68
69
 
@@ -81,6 +82,14 @@ While a helper runs, the main agent can send follow-ups, check progress, or stop
81
82
 
82
83
  All preferences live in `~/.dsh/dsh-paoding.config.yml`: role add/remove/tweak, skill assignment, per-role models, per-role session modes (one-shot / continuable), per-role start modes (fresh / fork), the main agent's tools and skills… Edit it, open the 庖丁配置 (Paoding Config) entry at the bottom of the sidebar, and hit Save & Apply — the preset syncs. It is regenerated from "static source + config" every time, so no manual patches pile up. The full key reference is in the [configuration guide](docs/configuration_en.md); the rationale and the cost ledger are in the [architecture guide](docs/architecture_en.md).
83
84
 
85
+ Prefer driving it from a script? An HTTP API covers the same ground: `/api/paoding/presets` lists, creates, updates, and deletes presets. It rides the DSH web service (same port you open DSH at in the browser) and answers loopback calls directly:
86
+
87
+ ```bash
88
+ curl http://127.0.0.1:19387/api/paoding/presets
89
+ ```
90
+
91
+ Writes go through the same generation pipeline and write lock as the panel's Save & Apply. Methods, request bodies, and error semantics are in [section 13 of the configuration guide](docs/configuration_en.md).
92
+
84
93
  ## Documentation
85
94
 
86
95
  | Document | Contents |
@@ -88,7 +97,7 @@ All preferences live in `~/.dsh/dsh-paoding.config.yml`: role add/remove/tweak,
88
97
  | [Architecture](docs/architecture_en.md) | Why it is designed this way — overview, the cost ledger, roles and tools, mapping to DSH's native mechanisms, token governance, known limits. |
89
98
  | [Installation](docs/installation_en.md) | The single official channel (`dsh plugin add`), version support and the dual preset landing (DSH 0.1.5 – 0.2.0-rc.2), first-install automation and the version marker, upgrading, dev-checkout install, host patch detection, the Paoding Config panel, uninstalling, troubleshooting. |
90
99
  | [Orchestration](docs/orchestration_en.md) | Orchestration overview, how failures surface and the three recovery layers, one-shot vs continuable, context isolation. |
91
- | [Configuration](docs/configuration_en.md) | The full key reference, tuning built-in roles, main-agent tools and skills, custom roles, per-role models, per-role session modes. |
100
+ | [Configuration](docs/configuration_en.md) | The full key reference, tool-entry wildcards, tuning built-in roles, main-agent tools and skills, custom roles, per-role models, per-role session and start modes, scenario templates, config export/import, health-check hints, the HTTP API. |
92
101
 
93
102
  ## Contributing
94
103
 
@@ -750,3 +750,20 @@ workspaces:
750
750
  | `generation` | warn | 生成器自身的提示信息(如默认值回退) |
751
751
 
752
752
  作废口径:**预览**产生的提示随配置草稿的任何改动一并作废(与预览同一逻辑);**应用**产生的提示描述的是已落盘结果,保留到下一次预览或应用。应用失败时错误 toast 照旧,同时卡片给出每条失败原因;应用成功但存在 warn/error 级提示时,成功 toast 会换成琥珀档「有 N 条检查提示,见下方」。
753
+
754
+ ## 13. HTTP 接口
755
+
756
+ 插件的 Web 后端随 DSH Web 服务同进程挂载在 `/api/paoding/*` 前缀下(回环地址 + 端口随部署,信任围栏与面板一致),面板能做的配置读写,脚本与自动化工具也能照做。预设对象的完整 CRUD 四条:
757
+
758
+ | 方法与路径 | 语义 |
759
+ | --- | --- |
760
+ | `GET /api/paoding/presets` | 列举预设。返回 `{ ok, presets: [...] }`:全局 `orchestrator` 恒在首位(`scope: 'global'`、`workspacePath: null`,`assignments` = 配置顶层剥掉 `profile` / `workspaces` 后的目标字段),其后按配置 `workspaces` 段逐个工作区项(`scope: 'workspace'`);每项带 `installed`(落盘产物是否在场) |
761
+ | `POST /api/paoding/presets` | 新增(或幂等更新)工作区预设。body:`{ workspace: <绝对路径>, assignments: <目标字段对象> }`。等价「保存并应用」勾上该工作区:同一条生成管线落 `orchestrator-<slug>` 并把条目并进配置 `workspaces` 段;响应在 apply 字段(`wrote` / `roleResults` / `text` / `presetId` / `configFile` / `warnings`)之外附 `created`(此前配置里是否还没有该条目)与 `workspace`(归一后的绝对路径) |
762
+ | `PUT /api/paoding/presets/<id>` | 更新既有预设。body:`{ assignments }`;`<id>` 取 `orchestrator`(全局更新,等价 apply 不带 workspace)或 `orchestrator-<slug>`(按 id 反查到的工作区)。响应同 POST,但没有 `created` |
763
+ | `DELETE /api/paoding/presets/<id>` | 删除工作区预设:配置 `workspaces` 条目、`~/.dsh/.agent-presets/orchestrator-<slug>` 目录、声明行轨的声明行一次清干净。全局 `orchestrator` 拒绝删除(403)——插件启动自愈会自动重装,卸载请移除 dsh-paoding 插件 |
764
+
765
+ 几点约定:
766
+
767
+ - **同管线同体检**:写操作(POST / PUT)与面板「保存并应用」走同一条生成管线,响应的 `warnings` 字段即 §12 的结构化体检结果;并共用同一把写盘互斥闸——任一写操作进行中,其余写请求收到 409,稍后重试即可。
768
+ - **错误语义**:请求体缺失 / 工作区目录不存在等 422;目标预设不存在 404;删除全局预设 403;其余服务端错误 500,响应统一为 `{ error }`(生成失败时另带 `warnings`)。
769
+ - **删除只清配置层**:DELETE 清的是配置 `workspaces` 条目与预设产物(目录 + 声明行),不动宿主工作区注册表——注册/注销工作区仍走面板「添加工作区」对应的接口。
@@ -762,3 +762,20 @@ Entries come from the structured health review (`tools/lib/health.mjs`); codes a
762
762
  | `generation` | warn | Generator's own notices (e.g. default-value fallbacks) |
763
763
 
764
764
  Invalidation scope: messages from a **preview** are discarded together with the draft on any config edit (same rule as the preview itself); messages from an **apply** describe what has already been written and survive until the next preview or apply. When an apply fails, the error toast stays as usual and the card lists every reason; when an apply succeeds but warn/error-level findings exist, the success toast switches to the amber variant "N check messages, see below".
765
+
766
+ ## 13. HTTP API
767
+
768
+ The plugin's web backend is mounted under the `/api/paoding/*` prefix inside the DSH web server itself (loopback address + port per your deployment; the same trust fence as the panel). Anything the panel can do to the config, scripts and automation can do too. Full CRUD for preset objects:
769
+
770
+ | Method & path | Semantics |
771
+ | --- | --- |
772
+ | `GET /api/paoding/presets` | List presets. Returns `{ ok, presets: [...] }`: the global `orchestrator` always comes first (`scope: 'global'`, `workspacePath: null`, `assignments` = the config's top-level target fields minus `profile` / `workspaces`), followed by one entry per configured workspace (`scope: 'workspace'`); every entry carries `installed` (whether the on-disk preset exists) |
773
+ | `POST /api/paoding/presets` | Create (or idempotently update) a workspace preset. Body: `{ workspace: <absolute path>, assignments: <target-fields object> }`. Equivalent to "save & apply" with that workspace selected: the same generation pipeline writes `orchestrator-<slug>` and merges the entry into the config's `workspaces` section; the response adds `created` (whether the config lacked this entry before) and `workspace` (the normalized absolute path) on top of the apply fields (`wrote` / `roleResults` / `text` / `presetId` / `configFile` / `warnings`) |
774
+ | `PUT /api/paoding/presets/<id>` | Update an existing preset. Body: `{ assignments }`; `<id>` is either `orchestrator` (global update, equivalent to apply without a workspace) or `orchestrator-<slug>` (the workspace resolved from the id). Response as POST, but without `created` |
775
+ | `DELETE /api/paoding/presets/<id>` | Delete a workspace preset: the config `workspaces` entry, the `~/.dsh/.agent-presets/orchestrator-<slug>` directory, and its declarative-track row are removed in one go. The global `orchestrator` refuses deletion (403) — the plugin's startup self-heal reinstalls it; uninstall the dsh-paoding plugin instead |
776
+
777
+ Ground rules:
778
+
779
+ - **Same pipeline, same health review**: writes (POST / PUT) run through the same generation pipeline as the panel's "save & apply"; the response's `warnings` field is the §12 structured health review. They also share the same in-flight write gate — while any write is running, other writes answer 409; retry shortly.
780
+ - **Error semantics**: missing body fields or a non-existent workspace directory answer 422; an unknown preset id answers 404; deleting the global preset answers 403; anything else answers 500. Responses are uniformly `{ error }` (generation failures additionally carry `warnings`).
781
+ - **Deletion only touches the config layer**: DELETE removes the `workspaces` config entry and the preset artifacts (directory + declarative row), not the host workspace registry — registering/unregistering workspaces remains the job of the API behind the panel's "add workspace".
@@ -117,7 +117,7 @@ dsh plugin --profile <name> update dsh-paoding
117
117
 
118
118
  (profile 自动探测,通常无需手填。)升级只换 profile 里的插件包本体,当前进程的代码不变;成功后提示**重启 DSH**,重启时 preset 按 `.generator-version` 标记自动按新版重生成。只升级 dsh 本体(不动插件)同理:重启即触发轨道迁移自愈,无需重装或重新应用。
119
119
 
120
- 手动升级等价:普通 profile 直接在终端跑上面这条 `dsh plugin update` 命令;desktop profile 被 dsh CLI 独占守卫挡住,等价命令是 `cd ~/.dsh/profiles/desktop && pnpm add dsh-paoding@latest`,同样重启生效。
120
+ 手动升级等价:普通 profile 直接在终端跑上面这条 `dsh plugin update` 命令;desktop profile 被 dsh CLI 独占守卫挡住,等价命令是 `cd ~/.dsh/profiles/desktop && pnpm add dsh-paoding@latest`(机器上没有裸 `pnpm` 命令时用 `corepack pnpm add`,见第 8 节排查),同样重启生效。
121
121
 
122
122
  **开发 link 形态**(见第 4 节)版本比较照常(更新卡有新版照常提示),但无法就地升级——升级器要求包真实装在某个 profile 的 node_modules 下,link 直连仓库匹配不到;点「升级」会如实报这一点,并提示手动跑 `dsh plugin add dsh-paoding@latest` 切回 registry 版。
123
123
 
@@ -190,7 +190,7 @@ patch 与配置文件用 **`yaml` 包**解析——通过 `createRequire` 从 `$
190
190
 
191
191
  ### 数据接口与安全
192
192
 
193
- 面板数据走**同源 `/api/paoding/*`**:Node 侧注册前缀路由——`GET /api/paoding/state`(带缓存的检测状态)、`GET /api/paoding/version`(最新版检测,版本/更新卡的数据源)、`GET /api/paoding/models`(宿主模型运行时的模型清单)、`GET` + `POST /api/paoding/workspaces`(工作区列表 / 添加工作区)、`POST /api/paoding/rescan`(强制重检测,含 MCP 握手)、`POST /api/paoding/preview`(不写盘生成)、`POST /api/paoding/apply`(安装并保存配置)、`POST /api/paoding/upgrade`(就地升级:普通 profile 走 `dsh plugin update`,desktop profile 直跑 `pnpm add`)、`GET /api/paoding/client.css`(面板样式静态下发)——复用 `tools/` 的检测与生成管线(`plugins/paoding-config-ui/api-core.mjs`)。路由自带**浏览器信任围栏**:Host 必须回环或落在 `webRuntime.trustedHosts`,并拒绝 cross-site 请求(复刻 DSH `/api` 网关围栏语义,因为 `/api/paoding` 前缀更长会命中本插件而绕开网关)。面板**没有独立服务端/端口**:DSH Web 端口只监听 `127.0.0.1`,不对外网暴露。
193
+ 面板数据走**同源 `/api/paoding/*`**:Node 侧注册前缀路由——`GET /api/paoding/state`(带缓存的检测状态)、`GET /api/paoding/version`(最新版检测,版本/更新卡的数据源)、`GET /api/paoding/models`(宿主模型运行时的模型清单)、`GET` + `POST /api/paoding/workspaces`(工作区列表 / 添加工作区)、`POST /api/paoding/rescan`(强制重检测,含 MCP 握手)、`POST /api/paoding/preview`(不写盘生成)、`POST /api/paoding/apply`(安装并保存配置)、`POST /api/paoding/upgrade`(就地升级:普通 profile 走 `dsh plugin update`,desktop profile 直跑 `pnpm add`,pnpm 按「裸 pnpm → `corepack pnpm` → PNPM_HOME 落位」候选链解析)、`GET /api/paoding/config/export`(配置文件原文导出,下载/备份用)、`POST /api/paoding/config/import`(整份导入:先验格式、与「保存并应用」共用写盘闸)、`GET /api/paoding/client.css`(面板样式静态下发)——复用 `tools/` 的检测与生成管线(`plugins/paoding-config-ui/api-core.mjs`)。路由自带**浏览器信任围栏**:Host 必须回环或落在 `webRuntime.trustedHosts`,并拒绝 cross-site 请求(复刻 DSH `/api` 网关围栏语义,因为 `/api/paoding` 前缀更长会命中本插件而绕开网关)。面板**没有独立服务端/端口**:DSH Web 端口只监听 `127.0.0.1`,不对外网暴露。
194
194
 
195
195
  ### 面板能力
196
196
 
@@ -199,6 +199,9 @@ patch 与配置文件用 **`yaml` 包**解析——通过 `createRequire` 从 `$
199
199
  - **技能分配**:每个 skill 一行,勾选分配给哪些 agent(写入对应 persona 的 `Available skills` 软引导;角色技能软引导,主 agent 技能另有 read 按需/内联两组)。
200
200
  - **预览生成**:不写盘生成 `agent.cordis.yml`,显示每个角色 allow 的 intent → kept 计数与生成全文。
201
201
  - **保存并应用**:确认后写入 preset(覆盖 `$DSH_HOME/.agent-presets/orchestrator`)并按宿主版本同步登记——≤ 0.1.6 目录扫描即生效,≥ 0.1.7 同步维护 `$DSH_HOME/cordis.patch.yml` 里的声明行托管块(见第 2 节)——再把当前分配保存到 `$DSH_HOME/dsh-paoding.config.yml`;应用成功后提示重启 DSH 或新建会话生效(检测到 patch 比生成结果新时提示重启后生效)。打开面板时会加载既有配置文件作为当前值——手编过的选择直接可见、可改。
202
+ - **场景模板**:页头三档切换(轻量:只留实现角色;调研:只留检索角色;全开:全员到齐、主 agent 额外工具带满检测库存),一键换「班底」;只动 `roles_remove` 与 `main_agent_extra`,各角色精调过的工具面、persona、模型、启动方式分毫不动,切回即原样复原。
203
+ - **检查提示卡**:每次「预览生成 / 保存并应用」自动过一遍体检(配置了未挂载的工具、零工具角色、查无此名的模型、预设落后于配置),分级提示以卡片形式贴在页头下方,处理完再保存不迟。
204
+ - **「更多」菜单**:配置导出(下载 `dsh-paoding.config.yml` 原文)/ 导入(先验格式,整份覆盖含工作区段,需二次确认)与面板语言切换(中英自动识别、手动选择优先并记住)。
202
205
 
203
206
  ## 7. 卸载
204
207
 
@@ -117,7 +117,7 @@ dsh plugin --profile <name> update dsh-paoding
117
117
 
118
118
  (the profile is auto-detected; usually nothing to fill in). The upgrade only swaps the plugin package inside the profile — the running process's code is untouched; on success it asks you to **restart DSH**, and at restart the preset regenerates itself against the new version via the `.generator-version` marker. Upgrading only the dsh host (plugin untouched) works the same way: the restart triggers the track-migration self-heal — no reinstall or manual re-apply needed.
119
119
 
120
- Manual equivalent: run the `dsh plugin update` command above yourself (for the desktop profile, which the dsh CLI guards: `cd ~/.dsh/profiles/desktop && pnpm add dsh-paoding@latest`), restart, done.
120
+ Manual equivalent: run the `dsh plugin update` command above yourself (for the desktop profile, which the dsh CLI guards: `cd ~/.dsh/profiles/desktop && pnpm add dsh-paoding@latest`, or `corepack pnpm add` on machines without a bare `pnpm` — see the troubleshooting section), restart, done.
121
121
 
122
122
  A **dev link: checkout** (see §4) still gets version checks (the update card appears when a newer version exists) but cannot be upgraded in place — the upgrader requires the package to really live under a profile's node_modules, which a link straight into the repo never matches. Hitting Upgrade reports exactly that and suggests running `dsh plugin add dsh-paoding@latest` to switch back to the registry version.
123
123
 
@@ -190,7 +190,7 @@ If you'd rather not hand-edit `dsh-paoding.config.yml`, open the **庖丁配置
190
190
 
191
191
  ### Data interface and security
192
192
 
193
- Panel data goes over the **same-origin `/api/paoding/*`**: the Node side registers prefix routes — `GET /api/paoding/state` (cached detection state), `GET /api/paoding/version` (latest-version check, the data source of the version/update card), `GET /api/paoding/models` (model list from the host LLM runtime), `GET` + `POST /api/paoding/workspaces` (list / add workspaces), `POST /api/paoding/rescan` (forced re-detection, MCP handshakes included), `POST /api/paoding/preview` (generate without writing), `POST /api/paoding/apply` (install and save config), `POST /api/paoding/upgrade` (in-place upgrade: `dsh plugin update` for regular profiles, a direct `pnpm add` for the desktop profile), `GET /api/paoding/client.css` (statically served panel stylesheet) — reusing the detection/generation pipeline in `tools/` (`plugins/paoding-config-ui/api-core.mjs`). The routes carry a **browser trust fence**: the Host must be loopback or listed in `webRuntime.trustedHosts`, and cross-site requests are rejected (mirroring DSH's `/api` gateway fence semantics, since the longer `/api/paoding` prefix would otherwise hit this plugin and bypass the gateway). The panel has **no separate server or port**: the DSH Web port listens on `127.0.0.1` only and is never exposed to the outside.
193
+ Panel data goes over the **same-origin `/api/paoding/*`**: the Node side registers prefix routes — `GET /api/paoding/state` (cached detection state), `GET /api/paoding/version` (latest-version check, the data source of the version/update card), `GET /api/paoding/models` (model list from the host LLM runtime), `GET` + `POST /api/paoding/workspaces` (list / add workspaces), `POST /api/paoding/rescan` (forced re-detection, MCP handshakes included), `POST /api/paoding/preview` (generate without writing), `POST /api/paoding/apply` (install and save config), `POST /api/paoding/upgrade` (in-place upgrade: `dsh plugin update` for regular profiles, a direct `pnpm add` for the desktop profile — pnpm is resolved through a candidate chain: bare `pnpm` → `corepack pnpm` → PNPM_HOME locations), `GET /api/paoding/config/export` (raw config file for download/backup), `POST /api/paoding/config/import` (whole-file import: validated first, shares the apply write mutex), `GET /api/paoding/client.css` (statically served panel stylesheet) — reusing the detection/generation pipeline in `tools/` (`plugins/paoding-config-ui/api-core.mjs`). The routes carry a **browser trust fence**: the Host must be loopback or listed in `webRuntime.trustedHosts`, and cross-site requests are rejected (mirroring DSH's `/api` gateway fence semantics, since the longer `/api/paoding` prefix would otherwise hit this plugin and bypass the gateway). The panel has **no separate server or port**: the DSH Web port listens on `127.0.0.1` only and is never exposed to the outside.
194
194
 
195
195
  ### Panel capabilities
196
196
 
@@ -199,6 +199,9 @@ Panel data goes over the **same-origin `/api/paoding/*`**: the Node side registe
199
199
  - **Skill assignment**: one row per skill, check which agents it goes to (written into the matching personas as `Available skills` soft guidance; role skills are soft guidance, main-agent skills have the read-on-demand/inline groups).
200
200
  - **Preview**: generates `agent.cordis.yml` without writing, showing each role's intent → kept counts plus the full generated text.
201
201
  - **Save & Apply**: after confirmation, writes the preset (overwriting `$DSH_HOME/.agent-presets/orchestrator`) and registers it per host generation — on ≤ 0.1.6 the directory scan picks it up as-is, on ≥ 0.1.7 the declaration managed block in `$DSH_HOME/cordis.patch.yml` is maintained in the same pass (see §2) — then saves the current assignments to `$DSH_HOME/dsh-paoding.config.yml`; success asks you to restart DSH or start a new session (and flags when a patch is newer than the generated output). Opening the panel loads the existing config file as the current values — hand-edited choices are visible and editable right away.
202
+ - **Scenario templates**: three one-click presets in the header (Lite: implement role only; Research: search roles only; Full: everyone on deck with the main agent carrying the whole detected inventory) swap the roster — they only touch `roles_remove` and `main_agent_extra`; each role's hand-tuned tools, persona, model, and start mode stay untouched and restore as-is when you switch back.
203
+ - **Health-check card**: every Preview / Save & Apply runs a check pass (tools configured but not mounted, zero-tool roles, unknown model names, a preset lagging behind the config); findings show as a severity-tagged card under the header so you can fix them before saving.
204
+ - **"More" menu**: config export (download the raw `dsh-paoding.config.yml`) / import (validated first; overwrites the whole file, workspaces included, with confirmation) and the panel language toggle (English/Chinese auto-detected, manual choice wins and is remembered).
202
205
 
203
206
  ## 7. Uninstalling
204
207
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-paoding",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "庖丁 — DSH (DeepSeek Harness) 编排模式安装器:host 感知的角色工具过滤 + Web 设置页可视化配置器",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,11 +8,11 @@
8
8
  */
9
9
  import os from 'node:os'
10
10
  import path from 'node:path'
11
- import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
12
12
  import { readFile } from 'node:fs/promises'
13
13
  import { assignSlugs, collectState, generateAndInstall, presetIdOf } from '../../tools/install.mjs'
14
14
  import { autoAssignments, baseAssignments } from '../../tools/lib/alloc.mjs'
15
- import { normalizeConfig, normalizeTargetFields, saveConfig, serializeConfig } from '../../tools/lib/config.mjs'
15
+ import { loadConfig, normalizeConfig, normalizeTargetFields, saveConfig, serializeConfig, stripNonTargetKeys } from '../../tools/lib/config.mjs'
16
16
  import { parseYamlSubset, preprocessPatchText } from '../../tools/lib/yaml.mjs'
17
17
  import { captureWarnings, healthCheck } from '../../tools/lib/health.mjs'
18
18
  import {
@@ -23,7 +23,9 @@ import {
23
23
  isPtcWorkerHost,
24
24
  listOrchestratorPresetIds,
25
25
  parseGeneratorMarker,
26
+ presetRowId,
26
27
  readHomePatchRows,
28
+ stripHomePatchRows,
27
29
  WORKER_PTC,
28
30
  WORKER_WORKER_THREAD,
29
31
  } from '../../tools/lib/preset-system.mjs'
@@ -270,6 +272,133 @@ export async function importConfigContent(content) {
270
272
  return { ok: true }
271
273
  }
272
274
 
275
+ // ── 预设 CRUD(presets 路由族的服务端核心)──────────────────────────────────
276
+ //
277
+ // 面板 apply 只覆盖「全局 / 单个工作区视角的生成」,这里补齐预设对象本身的
278
+ // 生命周期:列举(GET)、新增 / 更新(POST/PUT,复用 installAssignments)、
279
+ // 删除(DELETE,配置条目 + preset 目录 + 声明行三位一体清理)。全局
280
+ // orchestrator 不在删除之列——插件启动自愈(ensurePresetInstalled)会在下次
281
+ // 启动把它装回来,删除语义不成立,路由层直接 403。
282
+
283
+ /**
284
+ * 预设清单组装(GET presets):输入 serializeState 后的只读视图,输出响应的
285
+ * presets 数组。全局 orchestrator 恒在首位(assignments = existing 顶层剥掉
286
+ * profile / workspaces 两个容器键后的目标字段;existing 缺失 = 尚无配置文件,
287
+ * assignments 也为 null),其后按配置 workspaces 段的键序逐个工作区补项。
288
+ */
289
+ export function listPresets(view) {
290
+ const existing = view?.existing ?? null
291
+ const installedPresets = Array.isArray(view?.installedPresets) ? view.installedPresets : []
292
+ const presets = [{
293
+ presetId: 'orchestrator',
294
+ scope: 'global',
295
+ workspacePath: null,
296
+ installed: installedPresets.includes('orchestrator'),
297
+ assignments: existing ? stripNonTargetKeys(existing) : null,
298
+ }]
299
+ const workspaces = existing?.workspaces && typeof existing.workspaces === 'object' && !Array.isArray(existing.workspaces)
300
+ ? existing.workspaces
301
+ : {}
302
+ const metaOf = view?.workspaceMeta ?? {}
303
+ // workspaceMeta 缺项兜底(理论不会走到——workspaceMeta 由同一份 existing 派生,
304
+ // 只防老版本状态/外部构造视图漏了键):整组键现算一次 assignSlugs,口径与
305
+ // serializeState / 生成层一致,避免逐项单算丢掉同 basename 的 hash 后缀。
306
+ let fallbackSlugs = null
307
+ for (const [wsPath, assignments] of Object.entries(workspaces)) {
308
+ let meta = metaOf[wsPath] ?? null
309
+ if (!meta) {
310
+ fallbackSlugs ??= assignSlugs(Object.keys(workspaces))
311
+ const slug = fallbackSlugs.get(wsPath)
312
+ const presetId = presetIdOf(slug)
313
+ meta = { slug, presetId, installed: installedPresets.includes(presetId) }
314
+ }
315
+ presets.push({
316
+ presetId: meta.presetId,
317
+ scope: 'workspace',
318
+ workspacePath: wsPath,
319
+ installed: !!meta.installed,
320
+ assignments,
321
+ })
322
+ }
323
+ return presets
324
+ }
325
+
326
+ /**
327
+ * presetId(orchestrator-<slug>)→ 工作区绝对路径反查(PUT/DELETE presets/<id>):
328
+ * 对 existing.workspaces 的键集整组 assignSlugs(与生成层 / serializeState 同源,
329
+ * 同 basename 冲突的 hash 后缀才能对得上),再按 presetIdOf 逐一比对。找不到
330
+ * (id 未注册、键集为空、全局 orchestrator 之外未知形态)返回 null,由路由层
331
+ * 回 404。
332
+ */
333
+ export function resolveWorkspacePathByPresetId(existing, presetId) {
334
+ const slugs = assignSlugs(Object.keys((existing && existing.workspaces) || {}))
335
+ for (const [wsPath, slug] of slugs) {
336
+ if (presetIdOf(slug) === presetId) return wsPath
337
+ }
338
+ return null
339
+ }
340
+
341
+ /**
342
+ * 删除工作区预设(DELETE presets/<id>):配置条目、preset 目录、声明行三位
343
+ * 一体清理。state 传原始 collectState state(拿 yamlMod / dshHome / configFile,
344
+ * 绝不能喂 serializeState 的 JSON 视图——loadConfig 需要宿主 yaml 口径)。
345
+ *
346
+ * 步骤与护栏:
347
+ * 1. 现配置读取(破坏性落盘护栏):读失败硬中止——「去条目 + 整文件覆写」
348
+ * 不允许对着不可信状态动刀,文案与 generateAndInstall 同款;
349
+ * 2. workspaces 段去掉该键后经 saveConfig 整体落盘(键已被外部移除也继续
350
+ * 后续清理,幂等);配置文件缺失(loadConfig 回 null)时没有条目可去,
351
+ * 跳过落盘——不为一次删除凭空物化配置文件;
352
+ * 3. slug 按「删除前键集 ∪ 本路径」分配:键在时恰为删除前键集,与
353
+ * generateAndInstall 落盘时的键集(磁盘 ∪ 本路径)同口径,保证算出的
354
+ * presetId 与当初产物目录名一致;键不在时同口径现算,纯兜底清理;
355
+ * 4. preset 目录存在才 rmSync;声明行经 stripHomePatchRows 撤除——该函数对
356
+ * 文件缺失 / 无托管块是安全 no-op(directory 轨宿主常态),无须预判轨道。
357
+ *
358
+ * 不动宿主 workspaceRegistry:注册表层与配置层解耦,DELETE 只清配置与预设产物
359
+ * (注册工作区是 POST /workspaces 的职责)。返回
360
+ * { presetId, removedConfigEntry, removedPresetDir };读配置失败仍按既有约定
361
+ * 抛错(无 statusCode,由路由层外层 catch 回 500)。
362
+ */
363
+ export function deleteWorkspacePreset(state, workspacePath) {
364
+ const { yamlMod, dshHome, configFile } = state
365
+ const wsPath = path.normalize(path.resolve(workspacePath))
366
+ let prev = null
367
+ try {
368
+ prev = loadConfig(configFile, yamlMod)
369
+ } catch (err) {
370
+ throw new Error(`现有配置 ${configFile} 读取失败,已中止(未做任何落盘): ${err.message}`)
371
+ }
372
+
373
+ const wsEntries = Object.keys(prev?.workspaces ?? {})
374
+ if (!wsEntries.includes(wsPath)) wsEntries.push(wsPath)
375
+ const presetId = presetIdOf(assignSlugs(wsEntries).get(wsPath))
376
+
377
+ let removedConfigEntry = false
378
+ if (prev !== null) {
379
+ const workspaces = { ...prev.workspaces }
380
+ removedConfigEntry = Object.prototype.hasOwnProperty.call(workspaces, wsPath)
381
+ delete workspaces[wsPath]
382
+ // 顶层字段随工作区保存的同一写盘口径重规范化(normalizeTargetFields),
383
+ // profile 维持磁盘现状(缺省 null 时由序列化器兜底,不引入运行时 profile)。
384
+ saveConfig(configFile, {
385
+ profile: prev.profile ?? null,
386
+ ...normalizeTargetFields(prev),
387
+ workspaces,
388
+ })
389
+ }
390
+
391
+ const presetDir = path.join(dshHome, '.agent-presets', presetId)
392
+ let removedPresetDir = false
393
+ if (existsSync(presetDir)) {
394
+ rmSync(presetDir, { recursive: true, force: true })
395
+ removedPresetDir = true
396
+ }
397
+ stripHomePatchRows(dshHome, [presetRowId(presetId)])
398
+
399
+ return { presetId, removedConfigEntry, removedPresetDir }
400
+ }
401
+
273
402
  // ── 首装 / 升级后 preset 自动重生成 ─────────────────────────────────────────
274
403
  //
275
404
  // 唯一官方安装通道是插件通道(dsh plugin add dsh-paoding),包不再携带 CLI:
@@ -2,11 +2,13 @@
2
2
  * paoding-config-ui — DSH Web 插件 (cordis)
3
3
  *
4
4
  * 把 dsh-paoding 可视化配置器整合进 DSH Web GUI(左侧栏全页配置,无独立服务端):
5
- * - Node 侧:注册 /api/paoding/* 前缀路由(state/models/workspaces/version/
6
- * upgrade/preview/apply/rescan/config/export/config/import + client.css
7
- * 静态样式),经
5
+ * - Node 侧:注册 /api/paoding/* 前缀路由(state/models/workspaces/presets/
6
+ * version/upgrade/preview/apply/rescan/config/export/config/import +
7
+ * client.css 静态样式),经
8
8
  * ./api-core.mjs 复用 tools/install.mjs 的检测与生成管线;workspaces 直连
9
- * 宿主 workspaceRegistry 服务(list / create,幂等);version
9
+ * 宿主 workspaceRegistry 服务(list / create,幂等);presets 提供预设
10
+ * 对象的 CRUD(列举 / 新增更新 / 删除工作区预设,全局 orchestrator 由启动
11
+ * 自愈保障、拒绝删除);version
10
12
  * 复用 tools/lib/version.mjs 的版本检测(npm registry 优先,npmmirror
11
13
  * 镜像、GitHub release 依次兜底);upgrade 经 tools/lib/upgrade.mjs 完成
12
14
  * 一键升级(常规 profile spawn dsh 插件转发器;desktop profile 被 dsh CLI
@@ -37,7 +39,7 @@ import { readFile } from 'node:fs/promises'
37
39
  import os from 'node:os'
38
40
  import path from 'node:path'
39
41
  import { fileURLToPath } from 'node:url'
40
- import { getState, refreshState, isStateCacheStale, serializeState, installAssignments, resolveDshHome, discoverWorkspaceCandidates, ensurePresetInstalled, pluginVersion, exportConfigPayload, importConfigContent } from './api-core.mjs'
42
+ import { getState, refreshState, isStateCacheStale, serializeState, installAssignments, resolveDshHome, discoverWorkspaceCandidates, ensurePresetInstalled, pluginVersion, exportConfigPayload, importConfigContent, listPresets, resolveWorkspacePathByPresetId, deleteWorkspacePreset } from './api-core.mjs'
41
43
  import { collectModelRefs } from '../../tools/lib/health.mjs'
42
44
  import { detectSkills } from '../../tools/lib/skills.mjs'
43
45
  import { collectRuntimeFacts } from './runtime-inventory.mjs'
@@ -289,6 +291,73 @@ async function modelDirectoryOf(ctx) {
289
291
  return { groups: results.filter(Boolean), failures }
290
292
  }
291
293
 
294
+ /**
295
+ * 工作区预设写侧公共流程(POST presets / PUT presets/<id> 共用,模块级以便
296
+ * 复用同一把闸):与 apply 路由完全同款 —— in-flight 闸防并发写盘、原始 state
297
+ * 喂生成管线(serializeState 视图会炸 TypeError,契约见 tests/api-apply.test.mjs)、
298
+ * 按需取模型目录做体检、落盘后强制重刷缓存(best-effort,失败只 warn)。
299
+ * withCreated:POST 需要回 created(以进 installAssignments 前的
300
+ * state.existing?.workspaces 判断该工作区键是否已存在);PUT 一律视作更新,
301
+ * 不回该键。生成失败按 apply 同款 422(err.warnings 透传体检结果)。
302
+ */
303
+ async function writeWorkspacePreset(ctx, res, { workspacePath, assignments, withCreated }) {
304
+ if (applyInFlight) {
305
+ json(res, 409, { error: '已有一次应用(apply)正在进行,请稍后重试' })
306
+ return
307
+ }
308
+ applyInFlight = true
309
+ try {
310
+ const s = await rawStateWithRuntimeFacts(ctx)
311
+ const created = withCreated
312
+ ? !(s.existing?.workspaces && Object.prototype.hasOwnProperty.call(s.existing.workspaces, workspacePath))
313
+ : undefined
314
+ // 模型目录按需取(unknown_model 体检的比对名单),口径与 apply 一致:
315
+ // assignments 里没有任何角色专用模型时不做目录发现,拿不到传 null。
316
+ let models = null
317
+ if (collectModelRefs(assignments).length > 0) {
318
+ try {
319
+ models = (await modelDirectoryOf(ctx)).groups
320
+ } catch {
321
+ models = null
322
+ }
323
+ }
324
+ try {
325
+ const result = installAssignments(s, assignments, {
326
+ dryRun: false,
327
+ saveConfigOnWrite: true,
328
+ workspacePath,
329
+ models,
330
+ })
331
+ // 落盘后强制重刷状态缓存(同 apply:否则浏览器刷新读到应用前的快照);
332
+ // best-effort,刷新失败不影响已成功的落盘结果。
333
+ try {
334
+ await rawStateWithRuntimeFacts(ctx, { force: true })
335
+ } catch (err) {
336
+ ctx.logger?.warn?.('paoding-config-ui: post-apply state refresh failed: ' + String(err?.message ?? err))
337
+ }
338
+ json(res, 200, {
339
+ wrote: result.wrote,
340
+ staleNote: result.staleNote,
341
+ roleResults: result.roleResults,
342
+ text: result.text,
343
+ // 本次落点随 workspacePath:orchestrator(全局更新)或 orchestrator-<slug>。
344
+ presetId: result.presetId,
345
+ configFile: s.configFile,
346
+ warnings: result.warnings,
347
+ ...(withCreated ? { created } : {}),
348
+ workspace: workspacePath,
349
+ })
350
+ } catch (err) {
351
+ json(res, 422, {
352
+ error: err.message,
353
+ ...(Array.isArray(err?.warnings) ? { warnings: err.warnings } : {}),
354
+ })
355
+ }
356
+ } finally {
357
+ applyInFlight = false
358
+ }
359
+ }
360
+
292
361
  // —— 宿主热切换竞态兜底 ————————————————————————————————————————————
293
362
  // dsh 0.1.7-rc.2 / 0.2.0-rc.x 插件列表「禁用→启用」会重建条目纤维:新 apply()
294
363
  // 先执行,旧 incarnation 的路由注销走异步结算,注册瞬间旧路由常仍在表里,
@@ -658,6 +727,129 @@ export async function apply(ctx) {
658
727
  return
659
728
  }
660
729
 
730
+ // ── 预设 CRUD(presets 路由族)──────────────────────────────────────
731
+ // 把「预设对象」的生命周期开放给 API(服务端核心在 api-core.mjs):
732
+ // GET presets 列举:全局 orchestrator 恒在首位 + 配置
733
+ // workspaces 段的各工作区预设(只读)。
734
+ // POST presets 新增(或幂等更新)工作区预设 = apply 带
735
+ // workspace 的幂等包装:同一生成管线落
736
+ // orchestrator-<slug> 并把条目并进配置
737
+ // workspaces 段,响应多带 created / workspace。
738
+ // PUT presets/<id> 更新既有预设(orchestrator = 全局,等价
739
+ // apply 不带 workspace;orchestrator-<slug>
740
+ // = 反查到的工作区)。
741
+ // DELETE presets/<id> 删工作区预设 = 配置条目 + preset 目录产物 +
742
+ // 声明行三位一体清理;全局 orchestrator 由
743
+ // 插件启动自愈保障,删除一律 403 拒绝。
744
+ // 写侧三条(POST/PUT/DELETE)与 apply 共用同一把 in-flight 闸:任一
745
+ // 落盘路径进行中,其余写请求一律 409,防并发交错写配置。方法不符的
746
+ // 路径不命中以下分支,落到底部 404 兜底(与既有路由一致)。
747
+ if (route === 'presets' && req.method === 'GET') {
748
+ // 列举只读:serializeState 后的浏览器视图即可(existing /
749
+ // installedPresets / workspaceMeta 齐全,见 listPresets)。
750
+ json(res, 200, { ok: true, presets: listPresets(await stateWithRuntimeFacts(ctx)) })
751
+ return
752
+ }
753
+
754
+ if (route === 'presets' && req.method === 'POST') {
755
+ // 新增 / 幂等更新工作区预设。校验先行(不占闸):assignments 非空
756
+ // 对象、workspace 归一为绝对路径且 statSync 预检是目录(文案与
757
+ // POST /workspaces / apply 的工作区预检同款)。
758
+ const body = await readBody(req)
759
+ const assignments = body.assignments
760
+ if (!assignments || typeof assignments !== 'object' || Array.isArray(assignments) || Object.keys(assignments).length === 0) {
761
+ json(res, 422, { error: '缺少预设配置(body.assignments)' })
762
+ return
763
+ }
764
+ const rawWorkspace = typeof body.workspace === 'string' ? body.workspace.trim() : ''
765
+ if (rawWorkspace === '') {
766
+ json(res, 422, { error: '缺少工作区目录绝对路径(body.workspace)' })
767
+ return
768
+ }
769
+ let wsStat = null
770
+ try { wsStat = statSync(rawWorkspace) } catch { /* 不存在:stat 抛错,落到下方 422 */ }
771
+ if (!wsStat || !wsStat.isDirectory()) {
772
+ json(res, 422, { error: `目录不存在或不是目录:${rawWorkspace}` })
773
+ return
774
+ }
775
+ await writeWorkspacePreset(ctx, res, {
776
+ workspacePath: path.normalize(path.resolve(rawWorkspace)),
777
+ assignments,
778
+ withCreated: true,
779
+ })
780
+ return
781
+ }
782
+
783
+ if (route.startsWith('presets/') && (req.method === 'PUT' || req.method === 'DELETE')) {
784
+ const presetId = route.slice('presets/'.length)
785
+ // 只认单层 id(presets/<id>);空 id / 多层路径落底部 404 兜底。
786
+ if (presetId !== '' && !presetId.includes('/')) {
787
+ // 全局预设两态先行分流:更新 = 等价 apply 不带 workspace;删除
788
+ // 语义不成立(启动自愈会装回来),403 直接拒绝。
789
+ if (req.method === 'DELETE' && presetId === 'orchestrator') {
790
+ json(res, 403, { error: '全局 orchestrator 预设不支持删除:插件启动自愈会自动重装;卸载请移除 dsh-paoding 插件' })
791
+ return
792
+ }
793
+ // PUT 的 body 与校验先行(不占闸,与 POST 同款文案),校验过了再
794
+ // 分流全局 / 工作区;DELETE 无 body。
795
+ let assignments = null
796
+ if (req.method === 'PUT') {
797
+ const body = await readBody(req)
798
+ assignments = body.assignments
799
+ if (!assignments || typeof assignments !== 'object' || Array.isArray(assignments) || Object.keys(assignments).length === 0) {
800
+ json(res, 422, { error: '缺少预设配置(body.assignments)' })
801
+ return
802
+ }
803
+ }
804
+ if (presetId === 'orchestrator') {
805
+ // 全局更新:workspacePath = null、无 created,其余与 POST 一致。
806
+ await writeWorkspacePreset(ctx, res, { workspacePath: null, assignments, withCreated: false })
807
+ return
808
+ }
809
+ // 工作区预设按 id 反查路径(原始 state 的 existing.workspaces 键集
810
+ // 整组 assignSlugs,同 basename 的 hash 后缀才对得上);此处读取
811
+ // 在闸外(缓存命中近乎零成本),未知的 id 趁早 404,不必等闸。
812
+ const s0 = await rawStateWithRuntimeFacts(ctx)
813
+ const workspacePath = resolveWorkspacePathByPresetId(s0.existing, presetId)
814
+ if (workspacePath === null) {
815
+ json(res, 404, { error: `未找到预设: ${presetId}` })
816
+ return
817
+ }
818
+ // 目录预检(配置里的路径可能已被外部删除),文案与 POST 同款。
819
+ let wsStat = null
820
+ try { wsStat = statSync(workspacePath) } catch { /* 不存在:落到下方 422 */ }
821
+ if (!wsStat || !wsStat.isDirectory()) {
822
+ json(res, 422, { error: `目录不存在或不是目录:${workspacePath}` })
823
+ return
824
+ }
825
+ if (req.method === 'PUT') {
826
+ await writeWorkspacePreset(ctx, res, { workspacePath, assignments, withCreated: false })
827
+ return
828
+ }
829
+ // DELETE:核心清理在 deleteWorkspacePreset(配置重读护栏在函数
830
+ // 内部),传原始 state 拿 yamlMod / dshHome / configFile。与写侧
831
+ // 共用同一把闸;成功后显式重刷缓存(saveConfig 已改 mtime,理论
832
+ // 上缓存自失效,重刷与 POST/PUT 对齐;best-effort)。
833
+ if (applyInFlight) {
834
+ json(res, 409, { error: '已有一次应用(apply)正在进行,请稍后重试' })
835
+ return
836
+ }
837
+ applyInFlight = true
838
+ try {
839
+ const removed = deleteWorkspacePreset(s0, workspacePath)
840
+ try {
841
+ await rawStateWithRuntimeFacts(ctx, { force: true })
842
+ } catch (err) {
843
+ ctx.logger?.warn?.('paoding-config-ui: post-apply state refresh failed: ' + String(err?.message ?? err))
844
+ }
845
+ json(res, 200, { ok: true, ...removed })
846
+ } finally {
847
+ applyInFlight = false
848
+ }
849
+ return
850
+ }
851
+ }
852
+
661
853
  json(res, 404, { error: `no such route: ${req.method} /api/paoding/${route}` })
662
854
  } catch (err) {
663
855
  // 错误原文(可能带本机绝对路径)留日志;对外 message 先脱敏(home /
@@ -97,9 +97,12 @@ window.__ModuleLoader__.load({
97
97
  // - t(key, params):按当前语言取文案,params 做 {name} 插值(split/join
98
98
  // 而非 replace,避免替换串里的 $ 模式被二次解释)。缺 key 原样返回 key
99
99
  // 本身——线上宁可露出 key 也不静默吞掉,测试侧保证不会发生。
100
- // - 语言解析:localStorage "paoding-lang"("zh"/"en")显式优先 →
101
- // navigator.language 以 zh 开头 → zh,否则 en(首次使用者多半是中文
102
- // 文档进来的,缺省 en 但 zh 环境自动命中)。
100
+ // - 语言解析(zh 信号优先):localStorage "paoding-lang" 显式优先 →
101
+ // navigator.language/languages 与宿主页面 <html lang> 任一带 zh 前缀
102
+ // → zh → 时区属中文地区 → zh → en 前缀命中 → en → 兜底 zh。Electron
103
+ // 外壳会把 navigator 语言钉成应用 locale(常 en-US,不跟操作系统),
104
+ // en 信号不可信而时区可信,故 zh 信号整体压过 en;服务端消息仅中文,
105
+ // 误判 zh(真英文用户点一次 English 即记住)代价远小于误判 en 混排。
103
106
  // - 切语言:setLang(lang) 写 localStorage + 同步广播 onLangChange;
104
107
  // 监听方各自刷新(86 PageShell 整页重渲染、82 委派行重建标题),面板
105
108
  // 数据重拉复用既有 registerReload 通道,不另造机制。
@@ -113,15 +116,48 @@ window.__ModuleLoader__.load({
113
116
  // 语言探测:localStorage 显式值优先;其余按 navigator.language 首缀猜 zh,
114
117
  // 兜底 en。全程 try/catch——非浏览器/受限环境(vm 测试、老内核)不炸,逐层回落。
115
118
  function paodingDetectLang() {
119
+ // zh 信号优先于 en 信号(不是平级链):Electron 外壳会把 navigator
120
+ // 语言钉成应用 locale(常为 en-US,跟打包不跟操作系统),en 命中在
121
+ // 本环境不可信;而本插件服务端消息仅中文,误判 zh 的代价(真英文
122
+ // 用户点一次 English,localStorage 记住)远小于误判 en(中文用户看
123
+ // 中英混排面板)。故顺序:显式选择 → 任一 zh 信号 → en 信号 → zh。
124
+ // ① 手动选择(localStorage)显式最优先
116
125
  try {
117
126
  var saved = window.localStorage.getItem(PAODING_LANG_KEY);
118
127
  if (saved === "zh" || saved === "en") return saved;
119
128
  } catch (err) { /* localStorage 不可用:继续探测 */ }
129
+ var navLangs = [];
120
130
  try {
121
- if (typeof navigator !== "undefined" && navigator.language
122
- && String(navigator.language).toLowerCase().indexOf("zh") === 0) return "zh";
123
- } catch (err) { /* navigator 不可用:落兜底 */ }
124
- return "en";
131
+ if (typeof navigator !== "undefined") {
132
+ if (navigator.language) navLangs.push(String(navigator.language));
133
+ if (navigator.languages && navigator.languages.length) {
134
+ for (var i = 0; i < navigator.languages.length; i++) navLangs.push(String(navigator.languages[i]));
135
+ }
136
+ }
137
+ } catch (err) { /* navigator 不可用:navLangs 留空 */ }
138
+ var docLang = "";
139
+ try {
140
+ docLang = String((document.documentElement && document.documentElement.lang) || "");
141
+ } catch (err) { /* 无 document:留空 */ }
142
+ // ② navigator / 宿主页面 <html lang> 任一带 zh 前缀 → zh
143
+ for (var j = 0; j < navLangs.length; j++) {
144
+ if (navLangs[j].toLowerCase().indexOf("zh") === 0) return "zh";
145
+ }
146
+ if (docLang.toLowerCase().indexOf("zh") === 0) return "zh";
147
+ // ③ 时区属中文地区 → zh:Electron webview 里 navigator 常整体被钉成
148
+ // en-US,时区是跟着操作系统走的最后真身(Asia/Shanghai 等)。
149
+ try {
150
+ var tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
151
+ if (/Asia\/(Shanghai|Hong_Kong|Macau|Taipei|Urumqi|Chongqing)$/.test(tz)) return "zh";
152
+ } catch (err) { /* 无 Intl:继续 */ }
153
+ // ④ en 信号(浏览器环境常规英文用户在此命中;Electron 假 en-US 已被
154
+ // ③ 时区先一步接走——中文时区的 Electron 用户不会再走到这里)
155
+ for (var k = 0; k < navLangs.length; k++) {
156
+ if (navLangs[k].toLowerCase().indexOf("en") === 0) return "en";
157
+ }
158
+ if (docLang.toLowerCase().indexOf("en") === 0) return "en";
159
+ // ⑤ 无任何信号 → zh(服务端消息仅中文,en 面板必混排)
160
+ return "zh";
125
161
  }
126
162
 
127
163
  var paodingLang = paodingDetectLang();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paoding-config-ui",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "main": "index.mjs",