dsh-custom-mode 1.10.0 → 1.11.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.i18n.yaml +2 -2
- package/README.md +49 -4
- package/README.zh.md +40 -4
- package/assistants.mjs +23 -3
- package/atomic.mjs +93 -7
- package/base-composition.mjs +25 -1
- package/client.js +38 -9
- package/composition.mjs +296 -18
- package/index.mjs +128 -10
- package/journal.mjs +32 -5
- package/locales.mjs +14 -4
- package/meta.mjs +77 -10
- package/package.json +1 -1
package/README.i18n.yaml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# 双语配对一致性记录:两侧在「上次确认一致」时的 git blob 哈希。
|
|
2
2
|
# 两份文档权威相同——改完任意一侧,请把另一侧也改掉,然后重新记录:
|
|
3
3
|
# node tools/verify-translation-pairing.mjs --write
|
|
4
|
-
README.md:
|
|
5
|
-
README.zh.md:
|
|
4
|
+
README.md: 4009df09f4f352850312dbeefb0f4f8729ba1e45
|
|
5
|
+
README.zh.md: dc907b21aa6d277d5ad0f91b2c7acd40d3fd3e42
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ role-play). · **中文**:DeepSeek Harness(dsh)自定义模式插件 —
|
|
|
11
11
|
|
|
12
12
|
<!-- community badge row: required in every new repository (see the storefront template in the operator SOP/DSH Plugins folder) -->
|
|
13
13
|
|
|
14
|
-
[](https://www.npmjs.com/package/dsh-custom-mode) [](https://github.com/BOWLUNA/dsh-custom-mode/actions/workflows/test.yml) [](LICENSE) [](https://github.com/BOWLUNA/dsh-custom-mode#readme)
|
|
14
|
+
[](https://github.com/BOWLUNA/dsh-custom-mode/stargazers) [](https://www.npmjs.com/package/dsh-custom-mode) [](https://github.com/BOWLUNA/dsh-custom-mode/actions/workflows/test.yml) [](LICENSE) [](https://github.com/BOWLUNA/dsh-custom-mode#readme)
|
|
15
15
|
|
|
16
16
|
<!-- badge rows: same two-part structure (dark label + coloured value) in both rows; every new repo copies this block -->
|
|
17
17
|
[](https://b23.tv/qJ4Ev0W) [](https://v.douyin.com/VWh0M03Fa4Y/) [](https://xhslink.cn/o/A7QtXmePBBF) [](https://discord.gg/pz97SfAfSy) [](https://github.com/BOWLUNA/dsh-custom-mode/discussions)
|
|
@@ -59,7 +59,7 @@ One command installs everything — the settings-page plugin, and the preset it
|
|
|
59
59
|
activation:
|
|
60
60
|
|
|
61
61
|
```sh
|
|
62
|
-
dsh plugin --profile web add dsh-custom-mode@1.
|
|
62
|
+
dsh plugin --profile web add dsh-custom-mode@1.11.1 # pin the version to get this one for sure
|
|
63
63
|
# A bare `add dsh-custom-mode` is subject to pnpm's release cooldown (`minimumReleaseAge`, 1 day by
|
|
64
64
|
# default): for hours after a release it can silently install an OLDER version — measured: a bare
|
|
65
65
|
# install 38 minutes after 1.3.0 shipped landed on 1.0.3. Check what you got with `npm ls
|
|
@@ -107,7 +107,8 @@ Electron shell**, so this plugin's page renders there unchanged. One install pat
|
|
|
107
107
|
|
|
108
108
|
- **Install from inside the app**: sidebar → **Plugins** → add plugin → search `dsh-custom-mode`.
|
|
109
109
|
The app runs the shared plugin manager with its own bundled pnpm.
|
|
110
|
-
- **Do not try to use
|
|
110
|
+
- **Do not try to use a standalone CLI for it — dsh itself refuses** (measured on 0.1.7-rc.2, and again on
|
|
111
|
+
0.2.0-rc.2):
|
|
111
112
|
```
|
|
112
113
|
$ dsh plugin --profile desktop add .
|
|
113
114
|
error: profile "desktop" is managed exclusively by the Electron application
|
|
@@ -116,8 +117,17 @@ Electron shell**, so this plugin's page renders there unchanged. One install pat
|
|
|
116
117
|
and startup recovery renames `cordis.patch.yml`. `install.sh` therefore **refuses** `--profile desktop`
|
|
117
118
|
too and points you at the app, so the two agree instead of one silently working around the other.
|
|
118
119
|
(A lab simulation of the profile shape can override it with `DSH_ALLOW_DESKTOP_PROFILE=1`.)
|
|
120
|
+
**The app's own bundled CLI is the exception** — `<install>\resources\runtime\cli\bin\dsh.cmd` is allowed
|
|
121
|
+
to operate on that profile (measured on 1.11.1: `add dsh-custom-mode@1.11.1` → `+ dsh-custom-mode 1.11.1`).
|
|
122
|
+
It is a launcher, not a dsh feature: it starts Electron with `ELECTRON_RUN_AS_NODE=1` and runs the
|
|
123
|
+
desktop-host CLI, so it is the app talking to its own profile rather than something driving from outside.
|
|
119
124
|
- **Nothing else to do on the preset side**: `$DSH_HOME/.agent-presets/` is product data shared by the
|
|
120
125
|
desktop app and the CLI, and the preset is seeded by the plugin on first activation.
|
|
126
|
+
- **If the mode does not show up in the picker, open Settings → Custom mode and read the warning.** A preset
|
|
127
|
+
whose rows cannot start on that machine is marked *broken* by the platform and dropped from every picker;
|
|
128
|
+
the settings page names the rows and offers **Fix for this line**. On the desktop line that can take one
|
|
129
|
+
click and then a **restart of the app** — the picker re-reads the registry only at boot (measured on
|
|
130
|
+
1.11.1, see `docs/MEASUREMENTS.md` section 34).
|
|
121
131
|
- If a third-party bundle ever keeps the app from starting, the native recovery dialog offers
|
|
122
132
|
**Disable third-party plugins**; installed packages and the plugin's own data stay on disk.
|
|
123
133
|
|
|
@@ -142,6 +152,24 @@ cd dsh-custom-mode
|
|
|
142
152
|
The mode needs a profile that ships `agent-presets` — the `web` profile does, `tui` and `headless` do
|
|
143
153
|
not.
|
|
144
154
|
|
|
155
|
+
### Trying it without installing anything
|
|
156
|
+
|
|
157
|
+
Every release carries a **`dsh-custom-mode.dshpreset`** beside the source
|
|
158
|
+
([latest](https://github.com/BOWLUNA/dsh-custom-mode/releases/latest/download/dsh-custom-mode.dshpreset)).
|
|
159
|
+
It is a zip holding one ready-to-use assistant: `manifest.json` plus `preset/` — the composition, a
|
|
160
|
+
`prompt.md`, the reader that re-reads it before every model step, and a model-facing tool that reads and
|
|
161
|
+
rewrites that prompt. Import it in the desktop app and you have a working「自定义模式」**without
|
|
162
|
+
installing a package**:
|
|
163
|
+
|
|
164
|
+
| | |
|
|
165
|
+
| --- | --- |
|
|
166
|
+
| **What you get** | one assistant whose system prompt is a plain file. Edit it in any editor — the agent loop re-reads it before every model step, so the change applies on the next step, with no restart. Or just ask the model to rewrite it: the preset ships exactly that tool. |
|
|
167
|
+
| **What you don't get** | the settings page. That is what the plugin adds — the page itself, plus managing **several** assistants side by side and picking one from the new-session menu. |
|
|
168
|
+
|
|
169
|
+
So the preset is the 30-second way to see what this is, and the plugin is the way to live with it.
|
|
170
|
+
Importing the preset does not conflict with installing the plugin afterwards: the plugin seeds its own
|
|
171
|
+
preset under `$DSH_HOME/.agent-presets/` and never overwrites a `prompt.md` you wrote.
|
|
172
|
+
|
|
145
173
|
## Usage
|
|
146
174
|
|
|
147
175
|
The settings page (Settings → "Custom mode") is an **assistant manager**: the top of the page lists
|
|
@@ -187,6 +215,23 @@ plugin switches, system prompt) edit **whichever one is selected**.
|
|
|
187
215
|
this plugin always replaces the base's `persona` row with its own reader (`complete: false`), so the
|
|
188
216
|
base's *prompt* semantics are **not** inherited. Minimal is the visible case: you get minimal's tool set,
|
|
189
217
|
not minimal's prompt.
|
|
218
|
+
- **A fifth base mode: Custom — the union of every shipped row.** The first four are dsh's own; the fifth is
|
|
219
|
+
**synthesised by this plugin**. Its row set is the union of those four, and a row declared by more than one
|
|
220
|
+
mode keeps the text and shipped default of whichever mode comes first in `standard → ptc → minimal →
|
|
221
|
+
cordis` order — so standard's shipped state wins where there is a conflict.
|
|
222
|
+
Why it has to exist: a switch can only rewrite a row the *base text already has*, so on Standard the rows
|
|
223
|
+
only PTC declares (`tool-presentation`), only Minimal declares (the persistent-terminal group) and only
|
|
224
|
+
Cordis declares (`tool-cordis`) were **unreachable** — not hidden in the UI, just absent from the composer.
|
|
225
|
+
Measured on `0.2.0-rc.2`: standard 32 rows, PTC 33, Cordis 33, Minimal 7, and the union **40**. Groups
|
|
226
|
+
travel whole with their `isolate` realms and `!!js` conditions intact, so the union cannot invent a row
|
|
227
|
+
that `dsh-agent-presets` would refuse to mount.
|
|
228
|
+
- **The two shells cannot both be on, and the plugin moves the switch for you.** The union contains one
|
|
229
|
+
mutually exclusive pair: Minimal's "persistent terminal" set and standard's `tool-bash` / `tool-pwsh` are
|
|
230
|
+
two implementations of the same thing — both register a tool called `bash`. Enabling both makes
|
|
231
|
+
`dsh-agent-presets` mark the whole mode **broken**, and a broken mode is silently dropped from **every**
|
|
232
|
+
picker while the settings page stays green. So in Custom mode, turning one side on turns the other off,
|
|
233
|
+
and the save message says which row moved. A conflicting combination written outside the page (hand edit,
|
|
234
|
+
another tool) is named by a warning instead of failing silently.
|
|
190
235
|
- **Switching assistants never discards drafts** — each keeps its own unsaved edits, marked
|
|
191
236
|
"Unsaved" in the list; the only path that throws edits away is the reload button, which renames
|
|
192
237
|
itself to say so.
|
|
@@ -385,7 +430,7 @@ installing again — your data is not touched:
|
|
|
385
430
|
|
|
386
431
|
```sh
|
|
387
432
|
# the pinned form: what you ask for is what you get
|
|
388
|
-
dsh plugin --profile web add dsh-custom-mode@1.
|
|
433
|
+
dsh plugin --profile web add dsh-custom-mode@1.11.1
|
|
389
434
|
# then restart the DSH process that serves the web profile
|
|
390
435
|
```
|
|
391
436
|
|
package/README.zh.md
CHANGED
|
@@ -10,7 +10,7 @@ base mode / plugin switches / multi-assistant / multi-persona。
|
|
|
10
10
|
|
|
11
11
|
<!-- 社区入口:第二排徽章在每个新仓库里都要加(见 SOP/DSH Plugins/仓库门面模板.md) -->
|
|
12
12
|
|
|
13
|
-
[](https://www.npmjs.com/package/dsh-custom-mode) [](https://github.com/BOWLUNA/dsh-custom-mode/actions/workflows/test.yml) [](LICENSE) [](https://github.com/BOWLUNA/dsh-custom-mode#readme)
|
|
13
|
+
[](https://github.com/BOWLUNA/dsh-custom-mode/stargazers) [](https://www.npmjs.com/package/dsh-custom-mode) [](https://github.com/BOWLUNA/dsh-custom-mode/actions/workflows/test.yml) [](LICENSE) [](https://github.com/BOWLUNA/dsh-custom-mode#readme)
|
|
14
14
|
|
|
15
15
|
<!-- 徽章:两排统一"深色标签 + 彩色值"的两段式;新仓库照抄这一块 -->
|
|
16
16
|
[](https://b23.tv/qJ4Ev0W) [](https://v.douyin.com/VWh0M03Fa4Y/) [](https://xhslink.cn/o/A7QtXmePBBF) [](https://discord.gg/pz97SfAfSy) [](https://github.com/BOWLUNA/dsh-custom-mode/discussions)
|
|
@@ -48,7 +48,7 @@ base mode / plugin switches / multi-assistant / multi-persona。
|
|
|
48
48
|
一条命令装完——设置页插件,以及它在首次激活时自动播种的 preset:
|
|
49
49
|
|
|
50
50
|
```sh
|
|
51
|
-
dsh plugin --profile web add dsh-custom-mode@1.
|
|
51
|
+
dsh plugin --profile web add dsh-custom-mode@1.11.1 # 钉版本才能确定拿到这一版
|
|
52
52
|
# 不带版本号会受 pnpm 的发布冷却期影响(`minimumReleaseAge`,默认一天):发布后数小时内按名安装
|
|
53
53
|
# 可能**静默装到旧版** —— 实测 1.3.0 发布 38 分钟后按名安装装到了 1.0.3。用 profile 里的
|
|
54
54
|
# `npm ls dsh-custom-mode` 核对实际装到的版本,或像上面那样钉版本。
|
|
@@ -89,7 +89,7 @@ dsh `0.1.6-alpha.2` 起有插件管理页:**侧边栏 → 插件 → 添加插
|
|
|
89
89
|
安装只有一条路,就是上面那条:
|
|
90
90
|
|
|
91
91
|
- **在应用里装**:侧栏 → **插件** → 添加插件 → 搜 `dsh-custom-mode`。应用自带 pnpm,走的是同一套插件管理器。
|
|
92
|
-
-
|
|
92
|
+
- **不要试着用独立的 CLI 装 —— dsh 自己就会拒绝**(0.1.7-rc.2 实测,0.2.0-rc.2 复测同样):
|
|
93
93
|
```
|
|
94
94
|
$ dsh plugin --profile desktop add .
|
|
95
95
|
error: profile "desktop" is managed exclusively by the Electron application
|
|
@@ -97,8 +97,15 @@ dsh `0.1.6-alpha.2` 起有插件管理页:**侧边栏 → 插件 → 添加插
|
|
|
97
97
|
桌面端独占 `$DSH_HOME/profiles/desktop`:那里的包操作持有 profile 事务锁,启动恢复还会重命名
|
|
98
98
|
`cordis.patch.yml`。所以 `install.sh` 也**拒绝** `--profile desktop` 并指向应用内安装 —— 两边一致,
|
|
99
99
|
而不是某一方偷偷绕过去。(只想在本机模拟 profile 形状时可设 `DSH_ALLOW_DESKTOP_PROFILE=1`。)
|
|
100
|
+
**应用自带的捆绑 CLI 是例外**:`<安装目录>\resources\runtime\cli\bin\dsh.cmd` 可以操作那个 profile
|
|
101
|
+
(1.11.1 实测:`add dsh-custom-mode@1.11.1` → `+ dsh-custom-mode 1.11.1`)。它是启动器而不是 dsh 的
|
|
102
|
+
新功能 —— 它用 `ELECTRON_RUN_AS_NODE=1` 起 Electron 去跑 desktop-host 的 CLI,所以那是**应用在跟自己的
|
|
103
|
+
profile 说话**,不是从外面驱动它。
|
|
100
104
|
- **预设那一侧不用做任何事**:`$DSH_HOME/.agent-presets/` 是桌面端与 CLI 共享的产品数据,
|
|
101
105
|
预设由插件首次激活时落盘。
|
|
106
|
+
- **模式没出现在选择器里,就去「设置 → 自定义模式」读那条告警。** 行的启动条件在本机不满足时,平台会把整个
|
|
107
|
+
预设判为 broken 并**从所有选择器里丢掉**;设置页会点名那几行,并给出**「按本线修复」**。在桌面端那条线上,
|
|
108
|
+
这通常是一次点击 + **重启应用** —— 选择器只在应用启动时重读注册表(1.11.1 实测,见 `docs/MEASUREMENTS.md` §34)。
|
|
102
109
|
- 万一某个第三方 bundle 让应用起不来:原生恢复对话框提供**「禁用第三方插件」**,已安装的包与插件数据都留在磁盘上。
|
|
103
110
|
|
|
104
111
|
**兼容性是声明出来的,不是猜的。** dsh 会拿每个插件的 `peerDependencies["@deepseek-ai/dsh"]` 与运行时
|
|
@@ -117,6 +124,22 @@ cd dsh-custom-mode
|
|
|
117
124
|
|
|
118
125
|
模式需要带 `agent-presets` 的 profile:`web` 有,`tui` 与 `headless` 没有。
|
|
119
126
|
|
|
127
|
+
### 不装插件也能先试
|
|
128
|
+
|
|
129
|
+
每个 release 都带一个 **`dsh-custom-mode.dshpreset`**
|
|
130
|
+
([最新版](https://github.com/BOWLUNA/dsh-custom-mode/releases/latest/download/dsh-custom-mode.dshpreset))。
|
|
131
|
+
它是一个 zip,里面是一个**开箱可用的助手**:`manifest.json` 加 `preset/` —— 组成文件、`prompt.md`、
|
|
132
|
+
每次模型调用前重读它的读取器,以及一个**模型可见的工具**(读写这份提示词)。在桌面端导入它,
|
|
133
|
+
**不用装任何包**就能得到一个可用的「自定义模式」:
|
|
134
|
+
|
|
135
|
+
| | |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| **你得到** | 一个助手,它的系统提示词就是一份普通文件。用任何编辑器改 —— agent loop 每次模型调用前重读,所以下一步就生效,不用重启。也可以**直接让模型改**:预设里带的就是这个工具。 |
|
|
138
|
+
| **你没有** | 设置页。那是插件提供的东西 —— 页面本身,以及**多个助手并存**、新建会话时从菜单里挑一个。 |
|
|
139
|
+
|
|
140
|
+
⇒ 预设是「30 秒看看这是什么」,插件是「长期用它」。导入预设与之后装插件**不冲突**:
|
|
141
|
+
插件把自己的预设写在 `$DSH_HOME/.agent-presets/` 下,且**从不覆盖**你自己写的 `prompt.md`。
|
|
142
|
+
|
|
120
143
|
## 使用
|
|
121
144
|
|
|
122
145
|
设置页(设置 → 「自定义模式」)是一个**助手管理器**:上面列出你所有的自定义模式,可以新增、切换、删除,下面四个区块(模式名称 / 基础模式 / 插件开关 / 系统提示词)编辑的是**当前选中的那一个**。
|
|
@@ -141,6 +164,19 @@ cd dsh-custom-mode
|
|
|
141
164
|
- **基础模式是"行集合",不是提示词** —— 底子决定有哪些行、这个模式有哪些工具;本插件始终把底子的
|
|
142
165
|
`persona` 行替换成自己的读取器(`complete: false`),所以底子的**提示词语义不会被继承**。最明显的是
|
|
143
166
|
「极简模式」:你拿到的是极简的工具集,不是极简的提示词。
|
|
167
|
+
- **第五个基础模式:自定义模式(全部出厂行的并集)** —— 前四个是 dsh 出厂的模式,第五个是**本插件自己合成**的。
|
|
168
|
+
它的行集合是那四个模式的并集;同一行在多个模式里都有时,取**先出现的那个模式**的原文与出厂默认
|
|
169
|
+
(顺序:标准 → PTC → 极简 → 创造),所以出厂状态以标准模式为准。
|
|
170
|
+
为什么需要它:开关只能作用在**基础文本里已经有的行**上,于是选「标准模式」时,只有 PTC 才有的
|
|
171
|
+
`tool-presentation`、只有极简才有的持久终端组、只有创造模式才有的 `tool-cordis` 是**拨不到的**
|
|
172
|
+
—— 不是界面藏起来了,是合成器里根本没有那条通路。0.2.0-rc.2 上实测:标准 32 行、PTC 33 行、创造 33 行、
|
|
173
|
+
极简 7 行,**并集 40 行**。取并集时分组整体搬运,`isolate` realm 与 `!!js` 平台条件原样保留,
|
|
174
|
+
因此不会凭空造出一个会让 `dsh-agent-presets` 拒绝挂载的行。
|
|
175
|
+
- **两套壳不能同时开 —— 插件替你避让。** 并集里有一组互斥的行:极简的「持久终端壳」和标准的
|
|
176
|
+
`tool-bash`/`tool-pwsh` 是同一件东西的两种实现,都注册一个叫 `bash` 的工具。同时启用会让
|
|
177
|
+
`dsh-agent-presets` 把整个模式判为 **broken**,而 broken 的模式会被**从所有选择器里静默丢掉** ——
|
|
178
|
+
设置页却全绿。所以当选「自定义模式」时,打开其中一侧会自动关掉另一侧,并在保存提示里说明动了哪一行。
|
|
179
|
+
页面之外写进去的冲突组合(手工编辑、别的工具)会在告警里被点名。
|
|
144
180
|
- **切换助手不会丢草稿** —— 每个助手各自留着未保存的修改,列表上用「未保存」标出来;唯一会放弃修改的是「放弃修改并重新读取」(有草稿时按钮会改名说明)。
|
|
145
181
|
- **删除** —— 先确认,再由插件自己动手删。确认形态取决于这条线:壳把 `RiskConfirmation` 交给第三方客户端插件时,是壳自己的弹窗(需勾选);而 `0.1.7-rc.2` 的客户端种子表只暴露 `Button/Input/Switch/Tag/Pill`,于是退化为原生 `confirm`(护栏不变,少一个勾选框)。删除两条线都能用:`0.1.6` 及更早有 `agentPresets.remove()`,`0.1.7`+ 根本没有这个调用 —— 那里由插件自己注销注册表项并删掉目录。删除只移除磁盘上的模式目录:**正在使用它的会话不受影响**(组成在会话创建时就已读取),新建会话时不再出现。
|
|
146
182
|
- **直接对 agent 说** —— 每个助手都自带 `custom_prompt` 工具,会话里可以读取或改写**它自己**的提示词。
|
|
@@ -294,7 +330,7 @@ MIT
|
|
|
294
330
|
|
|
295
331
|
```sh
|
|
296
332
|
# 钉版本的写法:要哪版就是哪版
|
|
297
|
-
dsh plugin --profile web add dsh-custom-mode@1.
|
|
333
|
+
dsh plugin --profile web add dsh-custom-mode@1.11.1
|
|
298
334
|
# 然后重启为该 profile 提供服务的 DSH 进程
|
|
299
335
|
```
|
|
300
336
|
|
package/assistants.mjs
CHANGED
|
@@ -409,17 +409,37 @@ export function reorderAssistant(rows, input, write = writePresetMeta) {
|
|
|
409
409
|
const result = write(item.name, item.description, directory, { order: position + 1 })
|
|
410
410
|
if (result.ok !== true) {
|
|
411
411
|
const failed = assistantDir(rows, item.id)
|
|
412
|
+
// 报的必须是**真的回滚了几个**,不是"尝试了几个"。
|
|
413
|
+
//
|
|
414
|
+
// 实测(`probes/probe-io.mjs` O):旧代码把 `written.length` 写进消息,而循环里
|
|
415
|
+
// 快照为 null 的条目被 `continue` 跳过、写失败的被 catch 吞掉 —— 于是两种都没还原的情况下,
|
|
416
|
+
// 用户看到的仍然是"已回滚 2 个"。那是一句**被夸大的保证**:磁盘上留着改了一半的时间线,
|
|
417
|
+
// 而错误消息说安全网已经兜住了。
|
|
418
|
+
//
|
|
419
|
+
// 现在分开数:真的还原成功几个、有几个还原不了(快照没读到 / 写回失败)。
|
|
420
|
+
let restored = 0
|
|
421
|
+
let unrestorable = 0
|
|
412
422
|
for (const done of written) {
|
|
413
|
-
if (done.text === null)
|
|
423
|
+
if (done.text === null) {
|
|
424
|
+
// 快照没读到,就没有可以写回去的内容 —— 这条回滚不了,不能算进"已回滚"。
|
|
425
|
+
unrestorable += 1
|
|
426
|
+
continue
|
|
427
|
+
}
|
|
414
428
|
try {
|
|
415
429
|
writeAtomic(presetMetaPath(done.directory), done.text)
|
|
430
|
+
restored += 1
|
|
416
431
|
} catch {
|
|
417
|
-
/*
|
|
432
|
+
/* 回滚失败不掩盖原始错误,但也不算成功 */
|
|
433
|
+
unrestorable += 1
|
|
418
434
|
}
|
|
419
435
|
}
|
|
436
|
+
const rollback =
|
|
437
|
+
unrestorable === 0
|
|
438
|
+
? `已回滚 ${String(restored)} 个已写入的顺序`
|
|
439
|
+
: `已回滚 ${String(restored)} 个;另有 ${String(unrestorable)} 个**未能还原**(快照读不到或写回失败),磁盘上可能留有改动`
|
|
420
440
|
return {
|
|
421
441
|
...result,
|
|
422
|
-
error: result.error +
|
|
442
|
+
error: result.error + `(${rollback};失败的目录:${String(failed)})`,
|
|
423
443
|
}
|
|
424
444
|
}
|
|
425
445
|
written.push({ directory, text: snapshot.find((entry) => entry.item.id === item.id)?.text ?? null })
|
package/atomic.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* random temporary name does not help with *that* collision, only with temp-vs-temp ones.
|
|
15
15
|
*/
|
|
16
16
|
import { randomBytes } from 'node:crypto'
|
|
17
|
-
import { renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { existsSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
18
18
|
|
|
19
19
|
/** Synchronous backoff: these write paths are synchronous, so waiting is simpler than async plumbing. */
|
|
20
20
|
function sleepSync(ms) {
|
|
@@ -81,22 +81,105 @@ export function discardStaged(staged) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* Write several files together
|
|
84
|
+
* Write several files together, as close to a transaction as a filesystem will give us.
|
|
85
85
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
86
|
+
* **Why this is not the old two-phase version.** The previous implementation staged everything and then
|
|
87
|
+
* renamed them one by one, and said so honestly in a comment ("not a true transaction — a rename can still
|
|
88
|
+
* fail between two commits"). But the callers' contracts do not say that: `savePromptOnly` promises
|
|
89
|
+
* "提示词与 preset.yml 一起换名,失败则两个都不动", and `saveState` says the three files are written
|
|
90
|
+
* together (issue #5). A caller that returns `ok:false` while the disk holds new-prompt + old-name is
|
|
91
|
+
* exactly the state those promises rule out — the user is told nothing was saved and half of it was.
|
|
92
|
+
*
|
|
93
|
+
* Two defects were measured by `probes/probe-io.mjs`:
|
|
94
|
+
*
|
|
95
|
+
* L staging happened **outside** the try, so a staging failure left its `.tmp-…` behind — and the
|
|
96
|
+
* module's own comment says an orphan temporary in an assistant directory is not just litter,
|
|
97
|
+
* because the roster scans that directory;
|
|
98
|
+
* K committing renamed them in order and gave up on the first failure — the earlier targets were
|
|
99
|
+
* already replaced, and the caller saw a plain failure.
|
|
100
|
+
*
|
|
101
|
+
* **How it is transactional now**: stage everything, then move every existing target aside to a backup,
|
|
102
|
+
* then put the new files in place. Any failure anywhere in that sequence restores from the backups and
|
|
103
|
+
* removes what it had already installed, so the caller's "nothing changed" is true. The backups are
|
|
104
|
+
* removed only after every destination is in place.
|
|
88
105
|
*
|
|
89
106
|
* @param {Array<[string, string]>} entries - `[file, text]` pairs.
|
|
90
107
|
* @returns {void}
|
|
91
108
|
*/
|
|
92
109
|
export function writeAtomicPair(entries) {
|
|
93
|
-
const
|
|
110
|
+
const stamp = `${String(process.pid)}-${randomBytes(4).toString('hex')}`
|
|
111
|
+
|
|
112
|
+
// ── phase 0: refuse to touch a destination that is a directory. ──
|
|
113
|
+
//
|
|
114
|
+
// Measured: `probes/probe-io.mjs` K injects the failure by making the second target a directory.
|
|
115
|
+
// A naive "move the old target aside, then install" transaction **succeeds** on that input — it renames
|
|
116
|
+
// the user's directory to `.bak-…` and leaves a file where it was. That is worse than failing: it obeys
|
|
117
|
+
// the letter of "transactional" while quietly relocating a directory nobody asked it to touch.
|
|
118
|
+
// Being transactional must not mean "willing to move anything out of the way". A directory at a path that
|
|
119
|
+
// should hold a file is not a target to replace — it is a reason to refuse before anything moves.
|
|
120
|
+
for (const [file] of entries) {
|
|
121
|
+
if (existsSync(file) && statSync(file).isDirectory()) {
|
|
122
|
+
throw new Error(`拒绝写入:目标已存在且是目录 — ${file}(未改动任何文件)`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── phase 1: stage everything. A failure here must not leave a temporary behind. ──
|
|
127
|
+
const staged = []
|
|
94
128
|
try {
|
|
95
|
-
for (const
|
|
129
|
+
for (const [file, text] of entries) staged.push(stageAtomic(file, text))
|
|
96
130
|
} catch (error) {
|
|
97
131
|
for (const one of staged) discardStaged(one)
|
|
98
132
|
throw error
|
|
99
133
|
}
|
|
134
|
+
|
|
135
|
+
// ── phase 2: move the current targets aside. `null` = the target did not exist. ──
|
|
136
|
+
const backups = []
|
|
137
|
+
const installed = []
|
|
138
|
+
try {
|
|
139
|
+
for (const one of staged) {
|
|
140
|
+
if (existsSync(one.file)) {
|
|
141
|
+
const backup = `${one.file}.bak-${stamp}`
|
|
142
|
+
renameWithRetry(one.file, backup)
|
|
143
|
+
backups.push([one.file, backup])
|
|
144
|
+
} else {
|
|
145
|
+
backups.push([one.file, null])
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// ── phase 3: install. ──
|
|
149
|
+
for (const one of staged) {
|
|
150
|
+
commitStaged(one)
|
|
151
|
+
installed.push(one)
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
// Undo in reverse: remove what we installed, then put the backups back.
|
|
155
|
+
for (const one of installed) {
|
|
156
|
+
try {
|
|
157
|
+
rmSync(one.file, { force: true })
|
|
158
|
+
} catch {
|
|
159
|
+
/* best effort — the restore below is what matters */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const [file, backup] of backups) {
|
|
163
|
+
if (backup === null) continue
|
|
164
|
+
try {
|
|
165
|
+
renameWithRetry(backup, file)
|
|
166
|
+
} catch {
|
|
167
|
+
/* best effort; the thrown error still tells the caller nothing is consistent */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const one of staged) discardStaged(one)
|
|
171
|
+
throw error
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── success: drop the backups ──
|
|
175
|
+
for (const [, backup] of backups) {
|
|
176
|
+
if (backup === null) continue
|
|
177
|
+
try {
|
|
178
|
+
rmSync(backup, { force: true })
|
|
179
|
+
} catch {
|
|
180
|
+
/* a leftover .bak-… is inert; failing the whole write over it would be worse */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
100
183
|
}
|
|
101
184
|
|
|
102
185
|
/**
|
|
@@ -104,6 +187,9 @@ export function writeAtomicPair(entries) {
|
|
|
104
187
|
*
|
|
105
188
|
* The temporary name is unique per call (pid + random suffix). On failure the temporary file is removed —
|
|
106
189
|
* an orphan `.tmp-…` inside an assistant directory is not just litter: the roster scans that directory.
|
|
190
|
+
* **The staging write itself is inside the try** for the same reason: a `writeFileSync` that fails part way
|
|
191
|
+
* (a full disk, an EACCES) leaves a partial temporary, and that is exactly the litter this promises not to
|
|
192
|
+
* leave. Measured by `probes/probe-io.mjs` — the same defect the pair version had.
|
|
107
193
|
*
|
|
108
194
|
* @param {string} file - destination path.
|
|
109
195
|
* @param {string} text - content to write.
|
|
@@ -111,8 +197,8 @@ export function writeAtomicPair(entries) {
|
|
|
111
197
|
*/
|
|
112
198
|
export function writeAtomic(file, text) {
|
|
113
199
|
const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
|
|
114
|
-
writeFileSync(temporary, text, 'utf8')
|
|
115
200
|
try {
|
|
201
|
+
writeFileSync(temporary, text, 'utf8')
|
|
116
202
|
renameWithRetry(temporary, file)
|
|
117
203
|
} catch (error) {
|
|
118
204
|
try {
|
package/base-composition.mjs
CHANGED
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
* 5. nothing left → {@link BaseCompositionUnavailableError}, which callers turn into a **visible,
|
|
26
26
|
* typed** state instead of a 500.
|
|
27
27
|
*
|
|
28
|
+
* One mode is not on this chain at all: {@link UNION_MODE_ID} ships nothing, because it is *synthesised*
|
|
29
|
+
* from the four above (in `composition.mjs`, which owns the splitter). Nothing in this file resolves it.
|
|
30
|
+
*
|
|
28
31
|
* Why `readDocument` and not `compositionInventory()`: the inventory is **flattened**
|
|
29
32
|
* (`entryId`/`moduleName`/`enabled`/`condition`), so the `isolate` realms a service row must sit inside
|
|
30
33
|
* are gone (measured 2026-09-25; `parse-composition.mjs` rules the same route out for the same reason).
|
|
@@ -44,9 +47,30 @@ export const LEGACY_DIR_ENV = 'DSH_SHIPPED_PRESETS_DIR'
|
|
|
44
47
|
/** Explicit override for the declarative file layout (a directory holding `<mode>.patch.yml`). */
|
|
45
48
|
export const PATCH_DIR_ENV = 'DSH_PRESET_PATCH_DIR'
|
|
46
49
|
|
|
47
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* The **shipped** base modes — the four the host declares.
|
|
52
|
+
*
|
|
53
|
+
* Kept here so discovery has no opinion about the UI. Everything that means "ask the host" keys off exactly
|
|
54
|
+
* these four: legacy/patch discovery, the CI fixture materialisation, and the `readDocument(<mode>)` sweep in
|
|
55
|
+
* `index.mjs`. The picker offers one more ({@link UNION_MODE_ID}) and it is deliberately **absent** from this
|
|
56
|
+
* list — there is no shipped file or document behind it, so nothing may go looking for one.
|
|
57
|
+
*/
|
|
48
58
|
export const BASE_MODE_IDS = ['standard', 'ptc', 'minimal', 'cordis']
|
|
49
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The fifth base mode a user may build on: all four shipped compositions unioned into one.
|
|
62
|
+
*
|
|
63
|
+
* **The id is `all`, not `custom`.** `custom` is the user preset this feature writes
|
|
64
|
+
* (`$DSH_HOME/.agent-presets/custom/`), so a base mode by that name would make the resolver ask the host for
|
|
65
|
+
* `readDocument('custom')` and hand the preset its own composition back — the self-inclusion the picker has
|
|
66
|
+
* always refused. The id is the machine name; the label is the user's (`base.all.label`), exactly as
|
|
67
|
+
* `cordis` is shown as 「创造模式」.
|
|
68
|
+
*
|
|
69
|
+
* Its text is synthesised in `composition.mjs` from the four shipped ones. Only the *name* lives here: the
|
|
70
|
+
* synthesis needs the composition splitter, and importing that would be a cycle.
|
|
71
|
+
*/
|
|
72
|
+
export const UNION_MODE_ID = 'all'
|
|
73
|
+
|
|
50
74
|
/**
|
|
51
75
|
* Thrown when no route yields a base composition.
|
|
52
76
|
*
|
package/client.js
CHANGED
|
@@ -124,6 +124,7 @@ try {
|
|
|
124
124
|
"meta.version": "插件版本",
|
|
125
125
|
"meta.versionHint": "安装时不钉版本号会受 pnpm 发布冷却期影响(默认 24 小时),可能装到较旧的版本。要换版本请按 README 的钉版本命令重装,然后重启 DSH。",
|
|
126
126
|
"api.saved": "已保存({name},基础模式 {mode})。新建会话即生效,当前会话保持原配置。",
|
|
127
|
+
"api.savedWithExclusiveRows": "已保存({name},基础模式 {mode})。两套壳注册同名工具、不能同时启用 —— 已自动关掉:{rows}。新建会话即生效,当前会话保持原配置。",
|
|
127
128
|
"api.created": "已创建「{name}」。现在可以为它写系统提示词。",
|
|
128
129
|
"api.duplicated": "已复制自「{from}」。两份从此各改各的。",
|
|
129
130
|
"api.deleted": "已删除「{name}」。正在使用它的会话不受影响;新建会话时它不再出现。",
|
|
@@ -141,8 +142,8 @@ try {
|
|
|
141
142
|
"api.selfCheckFailed": "生成结果自检失败,已放弃写入:{detail}",
|
|
142
143
|
"api.internalError": "宿主内部错误:{detail}",
|
|
143
144
|
"api.bodyTooLarge": "请求体过大(上限 {max} 字节)",
|
|
144
|
-
"api.repairNotNeeded": "
|
|
145
|
-
"api.repaired": "已按本机这条 dsh 线关闭 {count}
|
|
145
|
+
"api.repairNotNeeded": "这个助手在本机没有起不来的行,无需修复。",
|
|
146
|
+
"api.repaired": "已按本机这条 dsh 线关闭 {count} 个起不来的行({ids})。现在这个模式能重新出现在选择器里。",
|
|
146
147
|
"api.badAssistantId": "助手标识不合法:{id}(只能用 a-z、0-9 与连字符)",
|
|
147
148
|
"api.seedFailed": "复制模式模板失败:{detail}",
|
|
148
149
|
"api.metaWriteFailed": "写入 preset.yml 失败:{detail}",
|
|
@@ -156,6 +157,8 @@ try {
|
|
|
156
157
|
"api.baseCompositionUnavailable": "本机取不到基础模式「{mode}」的出厂组成,无法改动插件开关或基础模式(系统提示词可以单独保存)。",
|
|
157
158
|
"warn.approvalGateMissing": "审批闸门未启用:本机这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词不会弹审批。见「详情」。",
|
|
158
159
|
"warn.unresolvableRows": "有行在本机这条 DSH 线上无法解析:平台会把整个模式判为 broken,并从新会话的选择器里**静默丢弃**。点右侧的「按本线修复」即可(只关掉那几行,其它选择不动)。",
|
|
160
|
+
"warn.presetBroken": "平台把这个模式判为 broken,它会**从新会话的选择器里消失** —— 而上面的「无法解析」检查解释不了为什么(多半是某一行依赖的服务/引擎没起来)。点右侧的「按本线修复」把那几行关掉即可。宿主给的原因见「详情」。",
|
|
161
|
+
"warn.exclusiveRowsActive": "两条互斥的壳同时开着(它们注册同名工具):平台会把整个模式判为 broken,并从新会话的选择器里**静默丢弃**。把其中一条关掉即可 —— 在设置页保存一次也会自动避让。",
|
|
159
162
|
"warn.baseCompositionUnavailable": "取不到本机这条 DSH 线的出厂组成:基础模式与插件开关暂时不能改(系统提示词不受影响,照常可保存)。已尝试的途径写在宿主日志里。",
|
|
160
163
|
"warn.approvalGateMissing.label": "审批闸门未启用",
|
|
161
164
|
"warn.approvalGateMissing.hint": "这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词**不会**弹审批。设置页不受影响;要恢复保护请升级 DSH,或把「custom_prompt 工具」那一行关掉。",
|
|
@@ -269,7 +272,9 @@ try {
|
|
|
269
272
|
"base.minimal.label": "极简模式",
|
|
270
273
|
"base.minimal.note": "只有 Shell 与终端,共 7 行;没有文件、检索、技能、子代理。系统提示词不是极简那一套(persona 行被本模式替换)",
|
|
271
274
|
"base.cordis.label": "Cordis 模式",
|
|
272
|
-
"base.cordis.note": "标准模式 + 读写运行时的 Cordis 工具集,可让 agent 自己改 harness"
|
|
275
|
+
"base.cordis.note": "标准模式 + 读写运行时的 Cordis 工具集,可让 agent 自己改 harness",
|
|
276
|
+
"base.all.label": "自定义模式",
|
|
277
|
+
"base.all.note": "不继承任何单一原生模式:以标准模式的全套行为底,补上只有 PTC / 极简 / 创造模式才有的行,全部都能逐行开关"
|
|
273
278
|
}
|
|
274
279
|
|
|
275
280
|
const EN = {
|
|
@@ -338,6 +343,7 @@ try {
|
|
|
338
343
|
"meta.version": "Plugin version",
|
|
339
344
|
"meta.versionHint": "Installing without a pinned version is subject to pnpm’s release cooldown (24 h by default) and can land on an older release. To change version, reinstall with the pinned command from the README and restart DSH.",
|
|
340
345
|
"api.saved": "Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.",
|
|
346
|
+
"api.savedWithExclusiveRows": "Saved ({name}, base mode {mode}). The two shell implementations register the same tool names and cannot both be on — turned off automatically: {rows}. A new session picks it up; the current one keeps its configuration.",
|
|
341
347
|
"api.created": "Created {name}. You can write its system prompt now.",
|
|
342
348
|
"api.duplicated": "Copied from {from}. The two are independent from now on.",
|
|
343
349
|
"api.deleted": "Deleted {name}. Sessions already using it keep running; it no longer appears for new sessions.",
|
|
@@ -355,8 +361,8 @@ try {
|
|
|
355
361
|
"api.selfCheckFailed": "The generated composition failed its self-check, so nothing was written: {detail}",
|
|
356
362
|
"api.internalError": "Unexpected host error: {detail}",
|
|
357
363
|
"api.bodyTooLarge": "Request body too large (limit {max} bytes)",
|
|
358
|
-
"api.repairNotNeeded": "Nothing to repair —
|
|
359
|
-
"api.repaired": "Disabled {count} row(s) this dsh line cannot
|
|
364
|
+
"api.repairNotNeeded": "Nothing to repair — every row of this assistant can start on this machine.",
|
|
365
|
+
"api.repaired": "Disabled {count} row(s) this dsh line cannot start ({ids}). The mode can appear in the pickers again.",
|
|
360
366
|
"api.badAssistantId": "Invalid assistant id: {id} (a-z, 0-9 and hyphens only)",
|
|
361
367
|
"api.seedFailed": "Copying the mode template failed: {detail}",
|
|
362
368
|
"api.metaWriteFailed": "Writing preset.yml failed: {detail}",
|
|
@@ -373,6 +379,8 @@ try {
|
|
|
373
379
|
"warn.noDescription": "No description: the new-session mode picker will show it as \"no description yet\".",
|
|
374
380
|
"warn.approvalGateMissing": "The approval gate is off: this DSH build has no tools/pre-execute event, so in-session prompt rewrites do not ask for approval. See the details.",
|
|
375
381
|
"warn.unresolvableRows": "Some rows cannot be resolved on this DSH line: the platform marks the whole mode broken and silently drops it from the new-session picker. Click Fix for this line — it only turns those rows off and leaves your other choices alone.",
|
|
382
|
+
"warn.presetBroken": "The platform marks this mode broken, so it **disappears from the new-session picker** — and the \"unresolvable\" check above cannot explain why (usually a row whose service or engine never started). Click Fix for this line to turn those rows off. The host's own reason is in Details.",
|
|
383
|
+
"warn.exclusiveRowsActive": "Both shells are on, and they register the same tool names: the platform marks the whole mode broken and silently drops it from the new-session picker. Turn one of them off — saving from the settings page also resolves it automatically.",
|
|
376
384
|
"warn.approvalGateMissing.label": "Approval gate is off",
|
|
377
385
|
"warn.approvalGateMissing.hint": "This DSH build has no tools/pre-execute event, so in-session prompt rewrites do NOT ask for approval. The settings page is unaffected; upgrade DSH or turn the custom_prompt tool row off to restore the gate.",
|
|
378
386
|
"warn.noName": "No name: the mode picker will show the directory id (e.g. custom).",
|
|
@@ -483,7 +491,9 @@ try {
|
|
|
483
491
|
"base.minimal.label": "Minimal",
|
|
484
492
|
"base.minimal.note": "Shell and terminal only, 7 rows; no files, search, skills or subagents. The system prompt is not minimal's either (this mode replaces the persona row)",
|
|
485
493
|
"base.cordis.label": "Cordis",
|
|
486
|
-
"base.cordis.note": "Standard plus the Cordis toolset, letting the agent modify its own harness"
|
|
494
|
+
"base.cordis.note": "Standard plus the Cordis toolset, letting the agent modify its own harness",
|
|
495
|
+
"base.all.label": "Custom",
|
|
496
|
+
"base.all.note": "Inherits no single shipped mode: standard's full row list plus the rows only PTC / Minimal / Cordis declare, every one of them switchable"
|
|
487
497
|
}
|
|
488
498
|
|
|
489
499
|
const TRANSLATIONS = { zh: ZH, en: EN }
|
|
@@ -1067,7 +1077,21 @@ try {
|
|
|
1067
1077
|
const code = result !== null && typeof result === "object" && typeof result.code === "string" ? result.code : ""
|
|
1068
1078
|
if (code !== "") {
|
|
1069
1079
|
const template = translate("api." + code, "", shellT)
|
|
1070
|
-
if (template !== "")
|
|
1080
|
+
if (template !== "") {
|
|
1081
|
+
/**
|
|
1082
|
+
* `{mode}` 是**基础模式**,宿主只能交出机器名(`standard` / `ptc` / `minimal` /
|
|
1083
|
+
* `cordis` / `all`)。直接印出来就是「基础模式 all」—— 中文读者看不懂,
|
|
1084
|
+
* 而且和壳自己的叫法(标准模式 / PTC 模式 / 极简模式 / 创造模式 / 自定义模式)对不上,
|
|
1085
|
+
* 一条原生质感的提示不该长这样。页面手上有 `base.<id>.label`,用它;
|
|
1086
|
+
* 词典里没有这个 id 时(旧宿主 / 未来新增)退回机器名,宁可难看也不要空。
|
|
1087
|
+
*/
|
|
1088
|
+
const params = result.params
|
|
1089
|
+
const shown =
|
|
1090
|
+
params !== null && typeof params === "object" && typeof params.mode === "string"
|
|
1091
|
+
? { ...params, mode: translate("base." + params.mode + ".label", params.mode, shellT) }
|
|
1092
|
+
: params
|
|
1093
|
+
return fillPlaceholders(template, shown)
|
|
1094
|
+
}
|
|
1071
1095
|
}
|
|
1072
1096
|
if (result !== null && typeof result === "object") {
|
|
1073
1097
|
if (typeof result.error === "string" && result.error !== "") return result.error
|
|
@@ -2009,8 +2033,13 @@ try {
|
|
|
2009
2033
|
"div",
|
|
2010
2034
|
{ key: code, className: "cpfe-warn-row" },
|
|
2011
2035
|
react.createElement("p", { className: "cpfe-warn" }, "⚠ " + t("warn." + code)),
|
|
2012
|
-
//
|
|
2013
|
-
|
|
2036
|
+
// 本线起不来的行可以一键修(只关掉那几行;用户创建它的那个版本可能早于播种改为派生的版本)。
|
|
2037
|
+
//
|
|
2038
|
+
// ★ 两个 code 都要给按钮:`presetBroken` 说的是"平台判它 broken,而『无法解析』解释不了"
|
|
2039
|
+
// —— 那正是**修复按钮唯一还帮得上的时候**。原先只认 `unresolvableRows`,于是
|
|
2040
|
+
// 第一轮修复之后 `unresolvable` 变空、按钮消失,而告警文案还在叫用户"点右侧的
|
|
2041
|
+
// 按本线修复"。实测(2026-10-01,桌面端)就是卡在这一步:能看见病,没有药。
|
|
2042
|
+
code === "unresolvableRows" || code === "presetBroken"
|
|
2014
2043
|
? react.createElement(
|
|
2015
2044
|
A.Button,
|
|
2016
2045
|
{ disabled: busy, onClick: repairRowsNow },
|