dsh-custom-mode 2.3.0 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +19 -7
- package/README.zh.md +19 -7
- package/client.js +130 -30
- package/composition.mjs +22 -5
- package/index.mjs +133 -30
- package/locales.mjs +8 -2
- package/package.json +1 -1
- package/preset-backend/declarative.mjs +6 -9
- package/preset-backend/parse-composition.mjs +51 -3
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: 3c201bfc473b860dec48b561a8467590683b3394
|
|
5
|
+
README.zh.md: 7e1fdbb9503908d8e285d9087653aa964510fd3c
|
package/README.md
CHANGED
|
@@ -10,9 +10,21 @@ Edit your DeepSeek Harness (`dsh`) assistants' system prompts, base modes and pl
|
|
|
10
10
|
|
|
11
11
|
[Install](#install-and-make-the-first-request) · [First use](#verify-the-whole-path) · [Updates](#plugin-updates) · [Screenshots](#screenshots) · [Compatibility](#compatibility-and-versioning) · [Help](#help-and-docs)
|
|
12
12
|
|
|
13
|
+
## Editing and draft safety
|
|
14
|
+
|
|
15
|
+
This section describes editing and draft protection in `2.3.1`. Confirm that exact version is available in [Releases](https://github.com/BOWLUNA/dsh-custom-mode/releases) before installing.
|
|
16
|
+
|
|
17
|
+
Select **New assistant…** in the page header to move to the existing creation form and focus its name input. Creating the assistant still requires choosing its starting settings, naming it and submitting the form. The currently selected assistant remains the editing target until creation succeeds. Enter is ignored while text is being composed; ordinary Enter submits the completed name.
|
|
18
|
+
|
|
19
|
+
Drafts remain in memory for the **current browser document**, including when you leave and return to the settings page. They are not a disk backup: reloading the document, closing the tab or restarting/unloading the plugin can lose unsaved edits. A leave-page warning cannot restore text after you confirm leaving. Copy or export important draft text first. When duplicating with pending edits, confirm that the copy uses the saved configuration; the original draft is retained.
|
|
20
|
+
|
|
21
|
+
Restoring the starting prompt, importing a file or loading saved history replaces only the prompt draft. If that would overwrite unsaved prompt text, confirm the replacement first; canceling keeps the text. Saving is still required to write the files.
|
|
22
|
+
|
|
23
|
+
If a save reports a conflict, copy the complete draft before selecting **Discard edits and reload**. A routine refresh keeps the dirty draft and its original baseline; it does not resolve the conflict. Compare the newly loaded files with your copied text before applying the intended edits again.
|
|
24
|
+
|
|
13
25
|
## Install and make the first request
|
|
14
26
|
|
|
15
|
-
These instructions describe version `2.3.
|
|
27
|
+
These instructions describe version `2.3.1`. Confirm that the exact version is available in [Releases](https://github.com/BOWLUNA/dsh-custom-mode/releases), and check the [exact-host results and acceptance limits](docs/CURRENT_COMPATIBILITY.md) before installing or updating.
|
|
16
28
|
|
|
17
29
|
Start with an installed [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) and a model configured through its own interface. Check `dsh --version` against the exact hosts in the compatibility record. This package does not install the host or configure model credentials.
|
|
18
30
|
|
|
@@ -22,21 +34,21 @@ Pin the plugin version; a bare name can resolve an older build during pnpm's rel
|
|
|
22
34
|
|
|
23
35
|
```sh
|
|
24
36
|
dsh --version
|
|
25
|
-
dsh plugin --profile web add dsh-custom-mode@2.3.
|
|
37
|
+
dsh plugin --profile web add dsh-custom-mode@2.3.1
|
|
26
38
|
```
|
|
27
39
|
|
|
28
40
|
Restart the process that serves this Web profile using its usual launcher. For a local CLI instance, the existing launch form is `dsh web --port 3081 --no-open`; use a free port and open the URL printed by that process. Restart only that instance. Installing a bundle does not refresh an already running host.
|
|
29
41
|
|
|
30
42
|
### Desktop and Windows
|
|
31
43
|
|
|
32
|
-
In the desktop app, use its **Plugins** page to install `dsh-custom-mode@2.3.
|
|
44
|
+
In the desktop app, use its **Plugins** page to install `dsh-custom-mode@2.3.1`, then restart the app. This project's scripts deliberately refuse `--profile desktop`; do not modify its profile files by hand. Windows/Electron acceptance remains separate from Web acceptance; see the compatibility record.
|
|
33
45
|
|
|
34
46
|
On Windows, the npm installation above can use the host's `dsh` command from PowerShell. Only the optional clone installer requires Git Bash (plus Node.js and npm). If `dsh` is not on PATH, use the host's supplied launcher, or replace `dsh` with `node "<host-install>/node_modules/@deepseek-ai/dsh/lib/bin.js"`. Do not point that path at this plugin's directory unless the host is actually installed there.
|
|
35
47
|
|
|
36
48
|
### Verify the whole path
|
|
37
49
|
|
|
38
50
|
1. Open Settings → Custom mode and check the displayed version. A page that opens is only the first check.
|
|
39
|
-
2.
|
|
51
|
+
2. Select **New assistant…** in the page header to focus the creation form. Choose a starting setting such as Writing, enter a recognizable name and select New assistant. The created assistant becomes the editing target.
|
|
40
52
|
3. Set a temporary prompt such as `For the next readiness check, reply with exactly CUSTOM_MODE_READY.` Save and wait for the page to confirm that it was applied. If it says the file was saved but application failed, select Retry activation before continuing.
|
|
41
53
|
4. Start a **new session**, open its mode/assistant selector, and explicitly choose that named assistant. Send `Readiness check.` through a model already configured in DSH.
|
|
42
54
|
5. Success means the assistant is selectable without a broken-preset warning and returns the expected readiness reply without a prompt-assembly error. Restore your intended prompt after the check. If it fails, use the [current recovery steps](docs/TROUBLESHOOTING.md#current-recovery-steps).
|
|
@@ -76,7 +88,7 @@ The prompt is stored in `prompt.md` and reread when the next model step is assem
|
|
|
76
88
|
|
|
77
89
|
## Upgrade, clone development, and removal
|
|
78
90
|
|
|
79
|
-
The page header shows the **running plugin version**.
|
|
91
|
+
The page header shows the **running plugin version**. When the host provides its native update capability, select **Check for updates** to ask the host for eligible versions. A recommended version offers **Update in Plugins**, which opens the official plugin details for its own confirmation, progress and result. The custom page does not install a package itself. Save pending drafts and resolve activation/refresh warnings before leaving the page.
|
|
80
92
|
|
|
81
93
|
On `0.2.1-rc.2`, `0.2.1-rc.4`, `0.2.0-rc.2` and `0.1.7-rc.2`, the native version-query API is absent: use **Open plugin details** or **Update guide** and the manual fixed-version path below. File, tarball and source-linked installations remain managed by their original source; checking does not silently convert them to npm. Permission denial, network failure, filtered versions or a partial result do not mean the plugin is current. A separate **Installed on disk** version does not prove that the running code has changed.
|
|
82
94
|
|
|
@@ -88,13 +100,13 @@ That command targets Web/CLI only. Desktop users must use an update path actuall
|
|
|
88
100
|
|
|
89
101
|
For source development, follow [the isolated setup](CONTRIBUTING.md#isolated-development). The optional `./install.sh --profile web` keeps the source-linked workflow and preserves existing prompt, metadata and composition. Its local package/code rollback and recovery paths are explained in [package and release assets](docs/PUBLISHING.md#package-and-release-assets).
|
|
90
102
|
|
|
91
|
-
`./uninstall.sh` removes the plugin but keeps assistant data. `--purge` explicitly deletes the managed assistant directories and their prompts; keep your own backup before using it.
|
|
103
|
+
`./uninstall.sh` removes the plugin but keeps assistant data. `--purge` explicitly deletes the managed assistant directories and their prompts; keep your own backup before using it. In `2.3.1`, a failed or unverifiable host removal stops cleanup and purge, preserves the remaining files and exits with an error. Resolve the reported host error before retrying; desktop removal stays in the app's Plugins page.
|
|
92
104
|
|
|
93
105
|
## Compatibility and versioning
|
|
94
106
|
|
|
95
107
|
Declared host range: `>=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0 || >=0.2.0-0 <0.3.0-0 || >=0.2.1-alpha.1 <0.3.0-0 || >=0.2.2-alpha.2 <0.3.0-0`
|
|
96
108
|
|
|
97
|
-
The plugin version is its own stable `x.y.z` line; it does not mirror DSH. On 2026-
|
|
109
|
+
The plugin version is its own stable `x.y.z` line; it does not mirror DSH. On 2026-11-04 the public host tags were `latest=0.2.1-rc.5`, `next=0.2.2-rc.1`, and `alpha=0.2.2-alpha.3`. Tags move; the declared range and completed checks are separate facts. See [exact versions, acceptance limits and historical evidence](docs/CURRENT_COMPATIBILITY.md) for the tested scope.
|
|
98
110
|
|
|
99
111
|
Release requires the compatibility and browser workflows. The unit runner, real prompt-component checks, packed-install checks and browser checks cover different boundaries. Windows/Electron, a real model response and GPU execution must each have their own evidence; they are not inferred from a page screenshot or unit count. Headless mode does not start an agent-preset session.
|
|
100
112
|
|
package/README.zh.md
CHANGED
|
@@ -10,9 +10,21 @@
|
|
|
10
10
|
|
|
11
11
|
[安装](#安装并完成首次请求) · [首次使用](#验证完整路径) · [更新](#plugin-updates) · [截图](#使用截图) · [兼容](#兼容与版本) · [求助](#帮助与文档)
|
|
12
12
|
|
|
13
|
+
## 编辑与草稿保护
|
|
14
|
+
|
|
15
|
+
本节说明`2.3.1`的编辑与草稿保护行为;安装前请先确认[发布版本](https://github.com/BOWLUNA/dsh-custom-mode/releases)中已有该精确版本。
|
|
16
|
+
|
|
17
|
+
点击页首「**新建助手…**」即可定位到既有新建表单并聚焦名称输入框。仍需选择起步设置、填写名称并提交表单;创建成功前,当前选中的助手仍是编辑对象。文字仍在组合输入时不会用Enter提交,普通Enter可提交完成的名称。
|
|
18
|
+
|
|
19
|
+
草稿仅保存在**当前浏览器文档的内存**中,离开并返回设置页时仍可继续编辑。它不是磁盘备份:刷新文档、关闭标签页或重启/卸载插件可能丢失未保存编辑;确认离开后的文本无法靠离页提醒恢复。先复制或导出重要草稿;有待保存编辑时复制助手,须确认副本使用已保存配置,原草稿继续保留。
|
|
20
|
+
|
|
21
|
+
恢复起步提示词、导入文件或载入已保存历史只替换提示词草稿;会覆盖未保存提示词时,先确认替换,取消则保留原文。仍需保存才写入文件。
|
|
22
|
+
|
|
23
|
+
保存报告冲突时,先复制完整草稿,再明确选择「**放弃修改并重新读取**」。普通读取会保留脏草稿及其原基线,不能解除冲突;请将重新载入的文件与复制文本对比后,再应用确实需要的编辑。
|
|
24
|
+
|
|
13
25
|
## 安装并完成首次请求
|
|
14
26
|
|
|
15
|
-
本说明描述版本 `2.3.
|
|
27
|
+
本说明描述版本 `2.3.1`。安装或更新前,先确认[发布版本](https://github.com/BOWLUNA/dsh-custom-mode/releases)中已有该精确版本,并核对[精确宿主结果与验收边界](docs/CURRENT_COMPATIBILITY.zh.md)。
|
|
16
28
|
|
|
17
29
|
先安装 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),并在宿主自己的界面中配置可用模型。用 `dsh --version` 对照兼容记录中的精确宿主版本。本插件不安装宿主,也不配置模型凭据。
|
|
18
30
|
|
|
@@ -22,21 +34,21 @@
|
|
|
22
34
|
|
|
23
35
|
```sh
|
|
24
36
|
dsh --version
|
|
25
|
-
dsh plugin --profile web add dsh-custom-mode@2.3.
|
|
37
|
+
dsh plugin --profile web add dsh-custom-mode@2.3.1
|
|
26
38
|
```
|
|
27
39
|
|
|
28
40
|
用原有启动方式重启为该 Web profile 提供服务的进程。本地 CLI 的既有启动形式是 `dsh web --port 3081 --no-open`;选择空闲端口,打开该进程打印的地址。只重启这个实例。安装 bundle 不会刷新已经运行的宿主。
|
|
29
41
|
|
|
30
42
|
### 桌面端与 Windows
|
|
31
43
|
|
|
32
|
-
在桌面应用的 **Plugins(插件)** 页安装 `dsh-custom-mode@2.3.
|
|
44
|
+
在桌面应用的 **Plugins(插件)** 页安装 `dsh-custom-mode@2.3.1`,然后重启应用。本项目脚本明确拒绝 `--profile desktop`,不要手改它的 profile 文件。Windows/Electron 与 Web 分别验收,详见兼容记录。
|
|
33
45
|
|
|
34
46
|
Windows 上可在 PowerShell 中用宿主提供的 `dsh` 执行上面的 npm 安装命令。只有可选的 clone 安装脚本需要 Git Bash,另需 Node.js 和 npm。PATH 中没有 `dsh` 时,使用宿主自带启动器,或将 `dsh` 替换为 `node "<host-install>/node_modules/@deepseek-ai/dsh/lib/bin.js"`。除非宿主确实装在本插件目录中,否则不要把这个路径指向插件目录。
|
|
35
47
|
|
|
36
48
|
### 验证完整路径
|
|
37
49
|
|
|
38
50
|
1. 打开设置 → 自定义模式,检查显示版本。页面能打开只是第一步。
|
|
39
|
-
2.
|
|
51
|
+
2. 点击页首「**新建助手…**」聚焦新建表单,选择写作等起步设置,输入容易辨认的名称,再点「新增助手」;新建的助手会成为编辑对象。
|
|
40
52
|
3. 临时把提示词设为 `下一次就绪检查只回复 CUSTOM_MODE_READY。`,保存并等页面确认已应用。若提示文件已保存但应用失败,先点「重试生效」。
|
|
41
53
|
4. 新建一个**会话**,打开该会话的模式/助手选择器,明确选择刚才命名的助手。通过 DSH 中已经配置的模型发送 `就绪检查。`
|
|
42
54
|
5. 成功意味着助手可以选择、没有预设损坏告警,并返回预期就绪回复且不出现提示词组装错误。检查后恢复自己的提示词。失败时先看[当前自救步骤](docs/TROUBLESHOOTING.zh.md#当前自救步骤)。
|
|
@@ -76,7 +88,7 @@ Windows 上可在 PowerShell 中用宿主提供的 `dsh` 执行上面的 npm 安
|
|
|
76
88
|
|
|
77
89
|
## 升级、clone 开发与卸载
|
|
78
90
|
|
|
79
|
-
|
|
91
|
+
页首显示**运行中的插件版本**。宿主提供原生更新能力时,点「**检查更新**」让宿主查询可用版本。有推荐版本后,「**在插件管理中更新**」会打开官方插件详情,由它承接确认、进度与结果;自定义页不直接安装包。离开前先保存待处理草稿,并处理生效/读取告警。
|
|
80
92
|
|
|
81
93
|
`0.2.1-rc.2`、`0.2.1-rc.4`、`0.2.0-rc.2`和`0.1.7-rc.2`没有原生版本查询API,可用「**打开插件详情**」或「**更新指引**」,再按下方固定版本方式手动更新。文件、tarball与源码链接安装继续由原来源管理,不会因检查更新而悄悄转成npm。权限拒绝、网络失败、版本被筛除或结果不完整都不代表已是最新。「**磁盘安装版本**」与运行版本不同,不能据此认定新代码已运行。
|
|
82
94
|
|
|
@@ -88,13 +100,13 @@ Windows 上可在 PowerShell 中用宿主提供的 `dsh` 执行上面的 npm 安
|
|
|
88
100
|
|
|
89
101
|
源码开发按[隔离准备步骤](CONTRIBUTING.zh.md#隔离开发)进行。可选的`./install.sh --profile web`保留源码链接方式,已有提示词、元数据和组成不被覆盖。本地包/代码回滚与恢复路径见[包与发布资产](docs/PUBLISHING.zh.md#包与发布资产)。
|
|
90
102
|
|
|
91
|
-
`./uninstall.sh` 移除插件但保留助手数据。`--purge`
|
|
103
|
+
`./uninstall.sh` 移除插件但保留助手数据。`--purge` 明确删除受管助手目录及提示词,使用前请自行备份。`2.3.1`中,宿主卸载失败或其结果无法核实时,会停止清理与purge、保留剩余文件并以错误退出;处理报告的宿主错误后再重试。桌面端卸载仍使用应用内Plugins页。
|
|
92
104
|
|
|
93
105
|
## 兼容与版本
|
|
94
106
|
|
|
95
107
|
声明的宿主范围:`>=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0 || >=0.2.0-0 <0.3.0-0 || >=0.2.1-alpha.1 <0.3.0-0 || >=0.2.2-alpha.2 <0.3.0-0`
|
|
96
108
|
|
|
97
|
-
插件版本走独立的稳定 `x.y.z` 版本线,不镜像 DSH。2026-
|
|
109
|
+
插件版本走独立的稳定 `x.y.z` 版本线,不镜像 DSH。2026-11-04 公开宿主标签为 `latest=0.2.1-rc.5`、`next=0.2.2-rc.1`、`alpha=0.2.2-alpha.3`。标签会移动,声明范围与已完成检查是两件事。实测范围见[精确版本、验收边界与历史证据](docs/CURRENT_COMPATIBILITY.zh.md)。
|
|
98
110
|
|
|
99
111
|
发布必须通过兼容性与浏览器工作流。单元、真实提示词组件、打包安装和浏览器检查分别覆盖不同边界。Windows/Electron、真实模型回复、GPU 执行都必须单独留证,不能从页面截图或单元计数推断。headless 不启动使用 agent preset 的会话。
|
|
100
112
|
|
package/client.js
CHANGED
|
@@ -139,6 +139,9 @@ try {
|
|
|
139
139
|
"assistant.brokenHint": "此模式在本机无法启动,不会出现在新会话菜单里。点「按本线修复」只关闭那些行。",
|
|
140
140
|
"assistant.switchHint": "切换助手保留各自的未保存草稿;列表中的「未保存」标记即为此。",
|
|
141
141
|
"btn.create": "新增助手",
|
|
142
|
+
"btn.newAssistant": "新建助手…",
|
|
143
|
+
"assistant.duplicateSavedConfirm": "复制使用磁盘上已保存的配置,不包含当前未保存的修改。继续复制已保存版本?当前草稿会保留。",
|
|
144
|
+
"prompt.replaceConfirm": "替换当前提示词草稿?尚未保存的提示词会被替换,无法从保存历史中恢复。请先复制保留需要的内容。",
|
|
142
145
|
"create.heading": "新建助手",
|
|
143
146
|
"btn.creating": "创建中…",
|
|
144
147
|
"btn.duplicate": "复制",
|
|
@@ -188,7 +191,7 @@ try {
|
|
|
188
191
|
"meta.version": "插件版本",
|
|
189
192
|
"meta.versionHint": "未钉版本时,可能装到较旧的发布。要换版本,按 README 钉版本重装,然后重启。",
|
|
190
193
|
"api.saved": "已保存({name},{mode})。下一步组装时会重读已保存的提示词。",
|
|
191
|
-
"api.conflict": "
|
|
194
|
+
"api.conflict": "磁盘上的助手配置已经变了,这次没有写入。请先导出或复制保留草稿,再重新读取并合并修改。",
|
|
192
195
|
"api.savedWithExclusiveRows": "已保存({name},{mode})。已关闭不能同时启用的行:{rows}。",
|
|
193
196
|
"api.created": "已创建「{name}」。可继续编辑;使用时请在新会话的模式菜单选择它。",
|
|
194
197
|
"api.savedPrompt": "已保存系统提示词({name})。下一步组装时会重读这个文件。",
|
|
@@ -441,6 +444,9 @@ try {
|
|
|
441
444
|
"assistant.brokenHint": "This mode cannot start here, so it will not appear for a new session. Fix for this line turns off only those rows.",
|
|
442
445
|
"assistant.switchHint": "Switching assistants keeps each draft; the \"Unsaved\" marker in the list is exactly that.",
|
|
443
446
|
"btn.create": "New assistant",
|
|
447
|
+
"btn.newAssistant": "New assistant…",
|
|
448
|
+
"assistant.duplicateSavedConfirm": "Copy the saved configuration from disk? Unsaved changes are not included, and your current draft will be kept.",
|
|
449
|
+
"prompt.replaceConfirm": "Replace the current prompt draft? Unsaved prompt text will be replaced and cannot be recovered from saved history. Copy any content you need to keep first.",
|
|
444
450
|
"create.heading": "New assistant",
|
|
445
451
|
"btn.creating": "Creating…",
|
|
446
452
|
"btn.duplicate": "Duplicate",
|
|
@@ -490,7 +496,7 @@ try {
|
|
|
490
496
|
"meta.version": "Plugin version",
|
|
491
497
|
"meta.versionHint": "Without a pinned version, an older release may be installed. To change it, reinstall with the version pin from the README, then restart.",
|
|
492
498
|
"api.saved": "Saved ({name}, {mode}). The next model step rereads the saved prompt.",
|
|
493
|
-
"api.conflict": "The
|
|
499
|
+
"api.conflict": "The assistant configuration on disk has changed. Nothing was written. Export or copy your draft, then reload and merge your changes.",
|
|
494
500
|
"api.savedWithExclusiveRows": "Saved ({name}, {mode}). Turned off rows that cannot be on together: {rows}.",
|
|
495
501
|
"api.created": "Created {name}. Continue editing here, then choose it in the mode menu for a new session.",
|
|
496
502
|
"api.savedPrompt": "System prompt saved ({name}). The next model step rereads this file.",
|
|
@@ -1139,6 +1145,39 @@ try {
|
|
|
1139
1145
|
)
|
|
1140
1146
|
}
|
|
1141
1147
|
|
|
1148
|
+
// Private to this module instance in this document. Leaving a settings
|
|
1149
|
+
// section must not lose a draft; no prompt is written to browser storage.
|
|
1150
|
+
const draftContexts = new WeakMap()
|
|
1151
|
+
function mountDraftProtection(ctx) {
|
|
1152
|
+
let session = draftContexts.get(ctx)
|
|
1153
|
+
if (session === undefined) {
|
|
1154
|
+
const memory = { entries: {}, selected: "", newName: "", syncNeeded: false, refreshNeeded: null }
|
|
1155
|
+
session = { memory, owners: new Set(), beforeUnload: (event) => {
|
|
1156
|
+
if (memory.newName === "" && Object.keys(memory.entries).length === 0) return
|
|
1157
|
+
event.preventDefault()
|
|
1158
|
+
event.returnValue = ""
|
|
1159
|
+
} }
|
|
1160
|
+
draftContexts.set(ctx, session)
|
|
1161
|
+
}
|
|
1162
|
+
ctx.effect(() => {
|
|
1163
|
+
const owner = {}
|
|
1164
|
+
session.owners.add(owner)
|
|
1165
|
+
if (session.owners.size === 1 && typeof window.addEventListener === "function") window.addEventListener("beforeunload", session.beforeUnload)
|
|
1166
|
+
return () => {
|
|
1167
|
+
session.owners.delete(owner)
|
|
1168
|
+
if (session.owners.size !== 0) return
|
|
1169
|
+
if (typeof window.removeEventListener === "function") window.removeEventListener("beforeunload", session.beforeUnload)
|
|
1170
|
+
session.memory.entries = {}
|
|
1171
|
+
session.memory.selected = ""
|
|
1172
|
+
session.memory.newName = ""
|
|
1173
|
+
session.memory.syncNeeded = false
|
|
1174
|
+
session.memory.refreshNeeded = null
|
|
1175
|
+
draftContexts.delete(ctx)
|
|
1176
|
+
}
|
|
1177
|
+
}, "custom-mode: draft protection")
|
|
1178
|
+
return session.memory
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1142
1181
|
/** `「名称」副本`-style copy name, from a locale template. */
|
|
1143
1182
|
function copyName(template, name) {
|
|
1144
1183
|
return String(template).replace("{name}", name)
|
|
@@ -1325,6 +1364,7 @@ try {
|
|
|
1325
1364
|
}
|
|
1326
1365
|
|
|
1327
1366
|
function CustomModeSection(props) {
|
|
1367
|
+
const draftMemory = props.draftMemory
|
|
1328
1368
|
// Compatibility: a shell that does not hand us a bound `t` (older or
|
|
1329
1369
|
// changed settings contract) must still render real copy. Falling back to
|
|
1330
1370
|
// the Chinese dictionary means the worst case is "Chinese text", never a
|
|
@@ -1390,28 +1430,38 @@ try {
|
|
|
1390
1430
|
const [list, setListState] = react.useState(null)
|
|
1391
1431
|
const heldRoster = react.useRef(list)
|
|
1392
1432
|
const setList = (value) => { heldRoster.current = value; setListState(value) }
|
|
1393
|
-
const [selected,
|
|
1433
|
+
const [selected, setSelectedState] = react.useState(draftMemory.selected)
|
|
1434
|
+
const setSelected = (value) => { draftMemory.selected = value; setSelectedState(value) }
|
|
1394
1435
|
/** id → { payload, saved, value }: loaded state, baseline and live draft. */
|
|
1395
|
-
const [entries, setEntriesState] = react.useState(
|
|
1436
|
+
const [entries, setEntriesState] = react.useState(draftMemory.entries)
|
|
1396
1437
|
const heldEntries = react.useRef(entries)
|
|
1397
1438
|
const setEntries = (next) => {
|
|
1398
1439
|
heldEntries.current = typeof next === "function" ? next(heldEntries.current) : next
|
|
1440
|
+
draftMemory.entries = Object.fromEntries(Object.entries(heldEntries.current).filter(([, held]) => !sameDraft(held.value, held.saved)))
|
|
1399
1441
|
setEntriesState(heldEntries.current)
|
|
1400
1442
|
}
|
|
1401
1443
|
const [status, setStatus] = react.useState("")
|
|
1402
1444
|
const [failed, setFailed] = react.useState(false)
|
|
1403
1445
|
const [busy, setBusy] = react.useState(false)
|
|
1404
1446
|
const [pending, setPending] = react.useState("")
|
|
1405
|
-
const [syncNeeded, setSyncNeededState] = react.useState(
|
|
1406
|
-
const [refreshNeeded, setRefreshNeededState] = react.useState(
|
|
1407
|
-
const navigationState = react.useRef({ syncNeeded
|
|
1408
|
-
const setSyncNeeded = (value) => { navigationState.current.syncNeeded = value; setSyncNeededState(value) }
|
|
1409
|
-
const setRefreshNeeded = (value) => { navigationState.current.refreshNeeded = value; setRefreshNeededState(value) }
|
|
1447
|
+
const [syncNeeded, setSyncNeededState] = react.useState(draftMemory.syncNeeded)
|
|
1448
|
+
const [refreshNeeded, setRefreshNeededState] = react.useState(draftMemory.refreshNeeded)
|
|
1449
|
+
const navigationState = react.useRef({ syncNeeded, refreshNeeded, newName: draftMemory.newName })
|
|
1450
|
+
const setSyncNeeded = (value) => { draftMemory.syncNeeded = value; navigationState.current.syncNeeded = value; setSyncNeededState(value) }
|
|
1451
|
+
const setRefreshNeeded = (value) => { draftMemory.refreshNeeded = value; navigationState.current.refreshNeeded = value; setRefreshNeededState(value) }
|
|
1410
1452
|
// A ref closes the interval before React renders disabled controls. Only the
|
|
1411
1453
|
// owning operation may apply a response or release the busy state.
|
|
1412
1454
|
const operation = react.useRef({ alive: true, current: null })
|
|
1413
|
-
const [newName, setNewNameState] = react.useState(
|
|
1414
|
-
const setNewName = (value) => { navigationState.current.newName = value; setNewNameState(value) }
|
|
1455
|
+
const [newName, setNewNameState] = react.useState(draftMemory.newName)
|
|
1456
|
+
const setNewName = (value) => { draftMemory.newName = value; navigationState.current.newName = value; setNewNameState(value) }
|
|
1457
|
+
const createSection = react.useRef(null)
|
|
1458
|
+
const createNameInput = react.useRef(null)
|
|
1459
|
+
const editorSection = react.useRef(null)
|
|
1460
|
+
const [focusCreated, setFocusCreated] = react.useState("")
|
|
1461
|
+
const focusCreate = () => {
|
|
1462
|
+
createSection.current?.scrollIntoView?.({ block: "start", behavior: "auto" })
|
|
1463
|
+
createNameInput.current?.focus?.({ preventScroll: true })
|
|
1464
|
+
}
|
|
1415
1465
|
const [runtimeVersion, setRuntimeVersion] = react.useState("")
|
|
1416
1466
|
const [updateResult, setUpdateResult] = react.useState(null)
|
|
1417
1467
|
const [updateNotice, setUpdateNotice] = react.useState("")
|
|
@@ -1444,7 +1494,9 @@ try {
|
|
|
1444
1494
|
const repairRowsNow = async () => {
|
|
1445
1495
|
if (draft === null) return
|
|
1446
1496
|
return runOperation("status.repairing", "msg.saveFailed", async (token) => {
|
|
1447
|
-
const
|
|
1497
|
+
const baseRevision = entry?.payload?.revision
|
|
1498
|
+
const result = await postJson(ROUTES.repair, { id: selected,
|
|
1499
|
+
...(typeof baseRevision === "string" ? { baseRevision } : {}) })
|
|
1448
1500
|
if (!ownsOperation(token)) return
|
|
1449
1501
|
showResult(result, "msg.repaired", "msg.saveFailed")
|
|
1450
1502
|
if (result?.ok === true || result?.persisted === true) {
|
|
@@ -1457,9 +1509,17 @@ try {
|
|
|
1457
1509
|
const repairedIds = Array.isArray(result.params?.repairedIds) ? result.params.repairedIds : []
|
|
1458
1510
|
if (repairedIds.length > 0 && draft !== null) {
|
|
1459
1511
|
// 草稿里的 `overrides` 是"显式**启用**"语义,所以关闭 = false。
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1512
|
+
setEntries((previous) => {
|
|
1513
|
+
const before = previous[selected]
|
|
1514
|
+
const overrides = { ...before.value.overrides }
|
|
1515
|
+
const savedOverrides = { ...before.saved.overrides }
|
|
1516
|
+
for (const rowId of repairedIds) { overrides[rowId] = false; savedOverrides[rowId] = false }
|
|
1517
|
+
const ownRevision = typeof result.revision === "string" && before.payload.revision === baseRevision
|
|
1518
|
+
return { ...previous, [selected]: { ...before,
|
|
1519
|
+
value: { ...before.value, overrides },
|
|
1520
|
+
...(ownRevision ? { payload: { ...before.payload, revision: result.revision },
|
|
1521
|
+
saved: { ...before.saved, overrides: savedOverrides } } : {}) } }
|
|
1522
|
+
})
|
|
1463
1523
|
}
|
|
1464
1524
|
await refreshAfterWrite(result, "msg.repaired", selected, token, () => reload(selected, undefined, token))
|
|
1465
1525
|
}
|
|
@@ -1497,6 +1557,12 @@ try {
|
|
|
1497
1557
|
const payload = entry === undefined ? null : entry.payload
|
|
1498
1558
|
const editorReady = draft !== null && payload !== null
|
|
1499
1559
|
const dirty = editorReady && !sameDraft(draft, entry.saved)
|
|
1560
|
+
react.useEffect(() => {
|
|
1561
|
+
if (focusCreated === "" || focusCreated !== selected || !editorReady) return
|
|
1562
|
+
editorSection.current?.scrollIntoView?.({ block: "start", behavior: "auto" })
|
|
1563
|
+
editorSection.current?.focus?.({ preventScroll: true })
|
|
1564
|
+
setFocusCreated("")
|
|
1565
|
+
}, [focusCreated, selected, editorReady])
|
|
1500
1566
|
|
|
1501
1567
|
/** Which assistants hold unsaved edits — the list marks them. */
|
|
1502
1568
|
const dirtyIds = new Set()
|
|
@@ -1575,8 +1641,14 @@ try {
|
|
|
1575
1641
|
const fresh = draftOf(result)
|
|
1576
1642
|
setEntries((previous) => {
|
|
1577
1643
|
const before = previous[id]
|
|
1578
|
-
const keep = discard !== true && before !== undefined && !sameDraft(before.value, before.saved)
|
|
1579
|
-
|
|
1644
|
+
const keep = discard !== true && before !== undefined && !sameDraft(before.value, before.saved)
|
|
1645
|
+
// A dirty draft owns the revision it was loaded from. A read may
|
|
1646
|
+
// discover someone else's change, but cannot authorize overwriting it.
|
|
1647
|
+
if (keep && typeof before.payload?.revision === "string") return { ...previous, [id]: { ...before,
|
|
1648
|
+
// Diagnostics may recover independently of this draft. Refresh
|
|
1649
|
+
// those fields without changing its editable baseline or token.
|
|
1650
|
+
value: { ...before.value, warnings: fresh.warnings, broken: fresh.broken, baseUnavailable: fresh.baseUnavailable } } }
|
|
1651
|
+
return { ...previous, [id]: { payload: result, saved: fresh, value: keep ? before.value : fresh } }
|
|
1580
1652
|
})
|
|
1581
1653
|
}
|
|
1582
1654
|
|
|
@@ -1856,13 +1928,17 @@ try {
|
|
|
1856
1928
|
if (!ownsOperation(token)) return
|
|
1857
1929
|
showResult(result, from === undefined ? "msg.created" : "msg.duplicated", "msg.createFailed")
|
|
1858
1930
|
if (result?.ok === true || result?.persisted === true) {
|
|
1859
|
-
setNewName("")
|
|
1931
|
+
if (from === undefined) setNewName("")
|
|
1932
|
+
setFocusCreated(result.id)
|
|
1860
1933
|
await refreshAfterWrite(result, from === undefined ? "msg.created" : "msg.duplicated", result.id, token, () => reload(result.id, undefined, token))
|
|
1861
1934
|
}
|
|
1862
1935
|
})
|
|
1863
1936
|
}
|
|
1864
1937
|
|
|
1865
|
-
const duplicate = () =>
|
|
1938
|
+
const duplicate = () => {
|
|
1939
|
+
if (dirty && (typeof window.confirm !== "function" || !window.confirm(t("assistant.duplicateSavedConfirm")))) return
|
|
1940
|
+
return create({ from: draft.id, name: copyName(t("assistant.copyName"), draft.name || draft.id) })
|
|
1941
|
+
}
|
|
1866
1942
|
|
|
1867
1943
|
/**
|
|
1868
1944
|
* Move the selected assistant one slot in the picker order.
|
|
@@ -1872,10 +1948,18 @@ try {
|
|
|
1872
1948
|
*/
|
|
1873
1949
|
const move = async (direction) => {
|
|
1874
1950
|
return runOperation("status.reordering", "msg.reorderFailed", async (token) => {
|
|
1875
|
-
const
|
|
1951
|
+
const baseRevisions = Object.fromEntries(Object.entries(heldEntries.current)
|
|
1952
|
+
.filter(([, held]) => typeof held.payload?.revision === "string")
|
|
1953
|
+
.map(([id, held]) => [id, held.payload.revision]))
|
|
1954
|
+
const result = await postJson(ROUTES.reorder, { id: draft.id, direction, baseRevisions })
|
|
1876
1955
|
if (!ownsOperation(token)) return
|
|
1877
1956
|
showResult(result, "msg.reordered", "msg.reorderFailed")
|
|
1878
1957
|
if (result?.ok === true || result?.persisted === true) {
|
|
1958
|
+
setEntries((previous) => Object.fromEntries(Object.entries(previous).map(([id, held]) => {
|
|
1959
|
+
const revision = result.revisions?.[id]
|
|
1960
|
+
return [id, typeof revision === "string" && typeof baseRevisions[id] === "string" &&
|
|
1961
|
+
held.payload?.revision === baseRevisions[id] ? { ...held, payload: { ...held.payload, revision } } : held]
|
|
1962
|
+
})))
|
|
1879
1963
|
await refreshAfterWrite(result, "msg.reordered", draft.id, token, () => reload(draft.id, undefined, token))
|
|
1880
1964
|
}
|
|
1881
1965
|
})
|
|
@@ -1888,16 +1972,21 @@ try {
|
|
|
1888
1972
|
* 只改**草稿**,不写盘:这样"一键回到官方"既立刻可见,又可被「重新读取」撤销 ——
|
|
1889
1973
|
* 与页面其余部分一样,一切改动都要点保存才落盘。
|
|
1890
1974
|
*/
|
|
1975
|
+
const allowPromptReplacement = () => {
|
|
1976
|
+
const held = heldEntries.current[selected]
|
|
1977
|
+
return held === undefined || sameDraft(held.value, held.saved) ||
|
|
1978
|
+
(typeof window.confirm === "function" && window.confirm(t("prompt.replaceConfirm")))
|
|
1979
|
+
}
|
|
1891
1980
|
const resetPrompt = () => {
|
|
1892
1981
|
const factory = typeof draft.factoryPrompt === "string" ? draft.factoryPrompt : ""
|
|
1893
|
-
if (factory === "") return
|
|
1982
|
+
if (factory === "" || !allowPromptReplacement()) return
|
|
1894
1983
|
update({ prompt: factory })
|
|
1895
1984
|
}
|
|
1896
1985
|
|
|
1897
1986
|
/** 把某个历史版本载入编辑器。与「恢复出厂」同一条纪律:只改草稿,保存前不落盘。 */
|
|
1898
1987
|
const loadVersion = async () => {
|
|
1899
1988
|
const picked = typeof draft.historyPick === "string" ? draft.historyPick : ""
|
|
1900
|
-
if (picked === "" || draft.id === undefined) return
|
|
1989
|
+
if (picked === "" || draft.id === undefined || !allowPromptReplacement()) return
|
|
1901
1990
|
return runOperation("status.reading", "msg.loadFailed", async (token) => {
|
|
1902
1991
|
// 版本键是序号(时间戳会在同一毫秒内撞车 —— 单测就是那样抓到它的)。
|
|
1903
1992
|
const response = await fetch(ROUTES.history + "?id=" + encodeURIComponent(draft.id) + "&n=" + encodeURIComponent(picked))
|
|
@@ -1952,6 +2041,7 @@ try {
|
|
|
1952
2041
|
setStatus(t("msg.importTooLarge"))
|
|
1953
2042
|
return
|
|
1954
2043
|
}
|
|
2044
|
+
if (!allowPromptReplacement()) return
|
|
1955
2045
|
return runOperation("status.importing", "msg.importFailed", async (token) => {
|
|
1956
2046
|
const text = await new Promise((resolve, reject) => {
|
|
1957
2047
|
const reader = new FileReader()
|
|
@@ -2014,7 +2104,7 @@ try {
|
|
|
2014
2104
|
}
|
|
2015
2105
|
|
|
2016
2106
|
const save = async () => {
|
|
2017
|
-
if (draft === null) return
|
|
2107
|
+
if (draft === null || navigationState.current.refreshNeeded !== null || navigationState.current.syncNeeded) return
|
|
2018
2108
|
return runOperation("status.saving", "msg.saveFailed", async (token) => {
|
|
2019
2109
|
const savedHistory = entry?.saved?.history
|
|
2020
2110
|
const newest = Array.isArray(savedHistory) && savedHistory.length > 0 ? savedHistory[0].n : undefined
|
|
@@ -2025,11 +2115,16 @@ try {
|
|
|
2025
2115
|
prompt: draft.prompt,
|
|
2026
2116
|
name: draft.name,
|
|
2027
2117
|
description: draft.description,
|
|
2028
|
-
...(typeof
|
|
2118
|
+
...(typeof entry?.payload?.revision === "string" ? { baseRevision: entry.payload.revision }
|
|
2119
|
+
: typeof newest === "number" ? { baseN: newest } : {}),
|
|
2029
2120
|
})
|
|
2030
2121
|
if (!ownsOperation(token)) return
|
|
2031
2122
|
if (result?.synchronized === false) setSyncNeeded(true)
|
|
2032
2123
|
else if (result?.synchronized === true) setSyncNeeded(false)
|
|
2124
|
+
if ((result?.ok === true || result?.persisted === true) && typeof result.revision === "string") {
|
|
2125
|
+
setEntries((previous) => ({ ...previous, [draft.id]: { ...previous[draft.id],
|
|
2126
|
+
payload: { ...previous[draft.id].payload, revision: result.revision }, saved: draft } }))
|
|
2127
|
+
}
|
|
2033
2128
|
if (result !== null && result !== undefined && result.ok === true) {
|
|
2034
2129
|
setFailed(false)
|
|
2035
2130
|
const compositionChanged =
|
|
@@ -2104,7 +2199,7 @@ try {
|
|
|
2104
2199
|
]
|
|
2105
2200
|
const createGroup = react.createElement(
|
|
2106
2201
|
"section",
|
|
2107
|
-
{ className: "cpfe-create" },
|
|
2202
|
+
{ className: "cpfe-create", ref: createSection },
|
|
2108
2203
|
react.createElement("h2", { className: "cpfe-h" }, t("create.heading")),
|
|
2109
2204
|
react.createElement(SectionHint, { text: t("posture.hint") }),
|
|
2110
2205
|
react.createElement(
|
|
@@ -2145,13 +2240,15 @@ try {
|
|
|
2145
2240
|
"div",
|
|
2146
2241
|
{ className: "cpfe-newrow" },
|
|
2147
2242
|
react.createElement(A.Input, {
|
|
2243
|
+
ref: createNameInput,
|
|
2148
2244
|
className: "cpfe-newinput",
|
|
2149
2245
|
value: newName,
|
|
2150
2246
|
disabled: busy,
|
|
2151
2247
|
placeholder: t("assistant.newPlaceholder"),
|
|
2152
2248
|
"aria-label": t("assistant.newPlaceholder"),
|
|
2153
2249
|
onKeyDown: (event) => {
|
|
2154
|
-
if (event.key === "Enter" &&
|
|
2250
|
+
if (event.key === "Enter" && !event.isComposing && !event.nativeEvent?.isComposing &&
|
|
2251
|
+
event.keyCode !== 229 && event.nativeEvent?.keyCode !== 229 && busy !== true) create()
|
|
2155
2252
|
},
|
|
2156
2253
|
onChange: (event) => {
|
|
2157
2254
|
setNewName(event.target.value)
|
|
@@ -2241,7 +2338,9 @@ try {
|
|
|
2241
2338
|
const assistantList = react.createElement(
|
|
2242
2339
|
"section",
|
|
2243
2340
|
null,
|
|
2244
|
-
react.createElement("
|
|
2341
|
+
react.createElement("div", { className: "cpfe-sec-head" },
|
|
2342
|
+
react.createElement("h2", { className: "cpfe-h" }, t("assistant.heading")),
|
|
2343
|
+
react.createElement(A.Button, { variant: "outline", disabled: busy, onClick: focusCreate, "data-create-entry": true }, t("btn.newAssistant"))),
|
|
2245
2344
|
react.createElement(SectionHint, { text: t("assistant.hint") }),
|
|
2246
2345
|
list === null
|
|
2247
2346
|
? react.createElement(
|
|
@@ -2713,7 +2812,7 @@ try {
|
|
|
2713
2812
|
editorReady
|
|
2714
2813
|
? react.createElement(
|
|
2715
2814
|
"section",
|
|
2716
|
-
|
|
2815
|
+
{ ref: editorSection, tabIndex: -1, "data-assistant-editor": true },
|
|
2717
2816
|
react.createElement("h2", { className: "cpfe-h" }, t("editor.heading")),
|
|
2718
2817
|
react.createElement(SectionHint, { text: fillPlaceholders(t("editor.hint"), { name: draft.name || draft.id }) }),
|
|
2719
2818
|
react.createElement(
|
|
@@ -2728,7 +2827,7 @@ try {
|
|
|
2728
2827
|
{
|
|
2729
2828
|
variant: "primary",
|
|
2730
2829
|
icon: A.IconCheckOutline14 === undefined ? null : react.createElement(A.IconCheckOutline14),
|
|
2731
|
-
disabled: busy || syncNeeded || editorReady === false || dirty === false,
|
|
2830
|
+
disabled: busy || syncNeeded || refreshNeeded !== null || editorReady === false || dirty === false,
|
|
2732
2831
|
onClick: save,
|
|
2733
2832
|
},
|
|
2734
2833
|
pending === "status.saving" ? t("btn.saving") : t("btn.save"),
|
|
@@ -2863,6 +2962,7 @@ try {
|
|
|
2863
2962
|
console.warn("dsh-custom-mode: slots service unavailable; settings page not registered")
|
|
2864
2963
|
return
|
|
2865
2964
|
}
|
|
2965
|
+
const draftMemory = mountDraftProtection(ctx)
|
|
2866
2966
|
try { mountUpdateServices(ctx) }
|
|
2867
2967
|
catch { console.warn("dsh-custom-mode: optional native update services unavailable") }
|
|
2868
2968
|
slots.inject("settings.section", () => {
|
|
@@ -2880,7 +2980,7 @@ try {
|
|
|
2880
2980
|
label: () => translate("nav", undefined, navT === null ? undefined : navT),
|
|
2881
2981
|
locale: NS,
|
|
2882
2982
|
},
|
|
2883
|
-
CustomModeSection,
|
|
2983
|
+
function CustomModeSettings(props) { return CustomModeSection({ ...props, draftMemory }) },
|
|
2884
2984
|
)
|
|
2885
2985
|
})
|
|
2886
2986
|
} catch (error) {
|
package/composition.mjs
CHANGED
|
@@ -24,6 +24,18 @@
|
|
|
24
24
|
|
|
25
25
|
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
26
26
|
import { join } from 'node:path'
|
|
27
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
28
|
+
import { decodeJsExpression, evaluateDisabledPredicate } from './preset-backend/parse-composition.mjs'
|
|
29
|
+
|
|
30
|
+
// Capabilities belong to the request that observed them, never to a module-global
|
|
31
|
+
// host context. Store only known booleans, not service objects or arbitrary ctx access.
|
|
32
|
+
const predicateCapabilities = new AsyncLocalStorage()
|
|
33
|
+
export function withPredicateCapabilities(snapshot, work) {
|
|
34
|
+
const capabilities = typeof snapshot?.profileContext === 'boolean'
|
|
35
|
+
? Object.freeze({ profileContext: snapshot.profileContext })
|
|
36
|
+
: Object.freeze({})
|
|
37
|
+
return predicateCapabilities.run(capabilities, work)
|
|
38
|
+
}
|
|
27
39
|
|
|
28
40
|
// 出厂组成「从哪来」是两条 dsh 线唯一还没收进后端的一处:旧线是文件,0.1.7 是
|
|
29
41
|
// agentPresets.readDocument()。解析链整个搬到了 base-composition.mjs(含它的理由与实测),
|
|
@@ -460,7 +472,7 @@ function setDisabled(segmentText, disabled) {
|
|
|
460
472
|
const shipped = lines[index].trim().replace(/^disabled:\s*/, '')
|
|
461
473
|
if (disabled) {
|
|
462
474
|
lines[index] = `${' '.repeat(indent)}disabled: true${carriage}`
|
|
463
|
-
} else if (shipped.startsWith('!!js') && evalDisabledExpression(shipped)
|
|
475
|
+
} else if (shipped.startsWith('!!js') && evalDisabledExpression(shipped) === false) {
|
|
464
476
|
// 显式打开,但出厂那行是平台表达式、且它在本机求值就是"开":这不是覆盖,而是**撤销覆盖** ——
|
|
465
477
|
// 保持出厂表达式原样,这一行就回到"跟随平台"。(原先这里删掉整行,结果是文件既不是出厂原样、
|
|
466
478
|
// 也不是显式覆盖,页面却显示"未拨过" —— 文件与显示同时失真。)
|
|
@@ -496,9 +508,8 @@ function setDisabled(segmentText, disabled) {
|
|
|
496
508
|
function evalDisabledExpression(expression) {
|
|
497
509
|
if (typeof expression !== 'string' || !expression.startsWith('!!js')) return undefined
|
|
498
510
|
try {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
return fn(process) === true
|
|
511
|
+
const source = decodeJsExpression(expression.slice(4))
|
|
512
|
+
return evaluateDisabledPredicate(source, predicateCapabilities.getStore())
|
|
502
513
|
} catch {
|
|
503
514
|
// 求值失败与「表达式不存在」都是 undefined。调用方若已经确认这是 `!!js`,
|
|
504
515
|
// 必须把 undefined 当成关闭:挂载侧同样按关闭处理,两边不能一个开一个关。
|
|
@@ -1020,13 +1031,19 @@ export function overridesOf(text, modeId) {
|
|
|
1020
1031
|
// 某个字面量一致 —— 只看求值结果,会把"表达式被换成字面量"误判成"没动过"。
|
|
1021
1032
|
const sameForm = baseRow !== undefined
|
|
1022
1033
|
&& baseRow.disabled === row.disabled
|
|
1023
|
-
&& baseRow.disabledExpression === row.disabledExpression
|
|
1034
|
+
&& predicateForm(baseRow.disabledExpression) === predicateForm(row.disabledExpression)
|
|
1024
1035
|
if (sameForm) continue
|
|
1025
1036
|
overrides[id] = row.disabled !== true
|
|
1026
1037
|
}
|
|
1027
1038
|
return overrides
|
|
1028
1039
|
}
|
|
1029
1040
|
|
|
1041
|
+
/** YAML quoting is transport syntax, while literal versus expression remains significant. */
|
|
1042
|
+
function predicateForm(expression) {
|
|
1043
|
+
if (typeof expression !== 'string') return expression
|
|
1044
|
+
try { return decodeJsExpression(expression.slice(4)) } catch { return expression }
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1030
1047
|
/**
|
|
1031
1048
|
* 按允许名单生成显式开关。
|
|
1032
1049
|
*
|
package/index.mjs
CHANGED
|
@@ -55,7 +55,8 @@ import {
|
|
|
55
55
|
effectiveRosterRows,
|
|
56
56
|
} from './preset-backend/index.mjs'
|
|
57
57
|
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
58
|
-
import {
|
|
58
|
+
import { createHash } from 'node:crypto'
|
|
59
|
+
import { basename, dirname, join } from 'node:path'
|
|
59
60
|
import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR, dshHome } from './paths.mjs'
|
|
60
61
|
import { ensureUnsetDefaultPreset } from './defaults.mjs'
|
|
61
62
|
import {
|
|
@@ -76,6 +77,7 @@ import {
|
|
|
76
77
|
setBaseCompositions,
|
|
77
78
|
setShippedPresetsDir,
|
|
78
79
|
unresolvableRows,
|
|
80
|
+
withPredicateCapabilities,
|
|
79
81
|
} from './composition.mjs'
|
|
80
82
|
import { presetMetaText, readPresetMeta, writePresetMeta, presetMetaPath, PRESET_META_PATH } from './meta.mjs'
|
|
81
83
|
import { listHistory, readVersion, recordExternalChange, recordPrompt, HISTORY_SOURCE } from './journal.mjs'
|
|
@@ -373,11 +375,42 @@ export function brokenRowIds(message) {
|
|
|
373
375
|
return [...new Set(ids)]
|
|
374
376
|
}
|
|
375
377
|
|
|
378
|
+
/**
|
|
379
|
+
* An opaque content revision of the complete editable state. Length framing and
|
|
380
|
+
* an explicit missing marker distinguish absent files from empty ones. History
|
|
381
|
+
* is deliberately excluded: losing an audit write must not disable conflict checks.
|
|
382
|
+
*/
|
|
383
|
+
export function stateRevision(directory) {
|
|
384
|
+
return revisionWithEntries(directory)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function revisionWithEntries(directory, entries = []) {
|
|
388
|
+
const replacements = new Map(entries.map(([file, text]) => [basename(file), Buffer.from(text, 'utf8')]))
|
|
389
|
+
const hash = createHash('sha256')
|
|
390
|
+
for (const name of ['prompt.md', 'preset.yml', 'agent.cordis.yml']) {
|
|
391
|
+
hash.update(name + '\0')
|
|
392
|
+
let bytes
|
|
393
|
+
try {
|
|
394
|
+
bytes = replacements.has(name) ? replacements.get(name) : readFileSync(join(directory, name))
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error.code !== 'ENOENT') throw error
|
|
397
|
+
hash.update('missing\0')
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
hash.update('present:' + String(bytes.length) + '\0')
|
|
401
|
+
hash.update(bytes)
|
|
402
|
+
}
|
|
403
|
+
return hash.digest('hex')
|
|
404
|
+
}
|
|
405
|
+
|
|
376
406
|
export function readState(rows, id, options = {}) {
|
|
377
407
|
const directory = assistantDir(rows, id)
|
|
378
408
|
if (directory === undefined) return unknownAssistant(id)
|
|
379
409
|
const recovery = restoreBackups(directory)
|
|
380
410
|
if (recovery.ok !== true) return { ok: false, code: 'recoveryRequired', params: { path: directory, detail: recovery.detail }, error: '助手文件需要恢复:' + recovery.detail }
|
|
411
|
+
// Take the token before reading the displayed fields. A later external edit
|
|
412
|
+
// can then cause a conservative conflict, never bless an older displayed value.
|
|
413
|
+
const revision = stateRevision(directory)
|
|
381
414
|
const composition = compositionFile(directory)
|
|
382
415
|
if (!existsSync(composition)) return { ok: false, code: 'compositionMissing', params: { path: composition }, error: '找不到组成文件:' + composition }
|
|
383
416
|
const text = readFileSync(composition, 'utf8')
|
|
@@ -423,6 +456,7 @@ export function readState(rows, id, options = {}) {
|
|
|
423
456
|
return {
|
|
424
457
|
ok: true,
|
|
425
458
|
id,
|
|
459
|
+
revision,
|
|
426
460
|
mode,
|
|
427
461
|
modes: BASE_MODES,
|
|
428
462
|
rows: rowTree,
|
|
@@ -503,33 +537,75 @@ const writeChains = new Map()
|
|
|
503
537
|
/**
|
|
504
538
|
* 打开页面之后、按下保存之前,`prompt.md` 可能已被会话工具或手工改过。
|
|
505
539
|
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
540
|
+
* 新页面用 baseRevision 比较三个编辑文件的原始内容。旧页面的 JSON 数字
|
|
541
|
+
* baseN 仍比较提示词历史;没有 token 的旧 API 保留覆盖语义。
|
|
542
|
+
* 冲突判定与审计写入隔离:历史不可写时也不能放行过期草稿。
|
|
509
543
|
*
|
|
510
544
|
* @param {string} directory
|
|
511
545
|
* @param {string} prompt - the text this save wants to write.
|
|
512
546
|
* @param {unknown} baseN
|
|
547
|
+
* @param {unknown} baseRevision - opaque revision returned when the draft was loaded.
|
|
513
548
|
* @returns {object|null} a `conflict` result, or null when the write may proceed.
|
|
514
549
|
*/
|
|
515
|
-
function
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
550
|
+
function refuseStaleState(directory, prompt, baseN, baseRevision) {
|
|
551
|
+
const diskPrompt = readPrompt(directory)
|
|
552
|
+
let stale = false
|
|
553
|
+
if (typeof baseRevision === 'string') {
|
|
554
|
+
try {
|
|
555
|
+
stale = stateRevision(directory) !== baseRevision
|
|
556
|
+
} catch {
|
|
557
|
+
// An unreadable editable file cannot prove the loaded baseline is current.
|
|
558
|
+
stale = true
|
|
559
|
+
}
|
|
560
|
+
} else if (typeof baseN === 'number') {
|
|
561
|
+
stale = diskPrompt.ok !== true || (diskPrompt.text !== prompt && readVersion(directory, baseN) !== diskPrompt.text)
|
|
562
|
+
}
|
|
563
|
+
if (diskPrompt.ok === true && diskPrompt.text !== prompt) {
|
|
564
|
+
try {
|
|
565
|
+
recordExternalChange(directory, diskPrompt.text)
|
|
566
|
+
} catch {
|
|
567
|
+
/* 审计留痕失败不改变上面已经完成的冲突判定 */
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return stale ? {
|
|
571
|
+
ok: false,
|
|
572
|
+
code: 'conflict',
|
|
573
|
+
params: typeof baseRevision === 'string' ? {} : { baseN },
|
|
574
|
+
error: '磁盘上的助手文件已经变了或无法核对,这次保存没有写入。',
|
|
575
|
+
note: '磁盘上的助手文件已经变了或无法核对,这次保存没有写入。',
|
|
576
|
+
} : null
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Reordering writes every assistant's metadata, so validate all held baselines first. */
|
|
580
|
+
function reorderState(rows, input) {
|
|
581
|
+
const baselines = input?.baseRevisions
|
|
582
|
+
if (baselines !== null && typeof baselines === 'object') {
|
|
583
|
+
for (const [id, revision] of Object.entries(baselines)) {
|
|
584
|
+
const directory = assistantDir(rows, id)
|
|
585
|
+
if (directory === undefined || typeof revision !== 'string') {
|
|
586
|
+
return { ok: false, code: 'conflict', params: {}, error: '助手列表或文件已经变了,这次排序没有写入。' }
|
|
527
587
|
}
|
|
588
|
+
const conflict = refuseStaleState(directory, readPrompt(directory).text ?? '', undefined, revision)
|
|
589
|
+
if (conflict !== null) return conflict
|
|
528
590
|
}
|
|
529
|
-
} catch {
|
|
530
|
-
/* 审计留痕失败不挡住这次保存 */
|
|
531
591
|
}
|
|
532
|
-
|
|
592
|
+
const ids = new Map(assistantsFromRoster(rows).map(({ id }) => [assistantDir(rows, id), id]))
|
|
593
|
+
const revisions = {}
|
|
594
|
+
const result = reorderAssistant(rows, input, (name, description, directory, options) => {
|
|
595
|
+
const rendered = presetMetaText(name, description, directory, options)
|
|
596
|
+
if (rendered.ok !== true) return rendered
|
|
597
|
+
try {
|
|
598
|
+
const entries = [[presetMetaPath(directory), rendered.text]]
|
|
599
|
+
const revision = revisionWithEntries(directory, entries)
|
|
600
|
+
writeAtomic(presetMetaPath(directory), rendered.text)
|
|
601
|
+
revisions[ids.get(directory)] = revision
|
|
602
|
+
return { ok: true, name: rendered.name }
|
|
603
|
+
} catch (error) {
|
|
604
|
+
return { ok: false, code: 'metaWriteFailed', params: { detail: describe(error) }, error: '写入 preset.yml 失败:' + describe(error) }
|
|
605
|
+
}
|
|
606
|
+
})
|
|
607
|
+
// Failed reorders can roll back earlier writes; none of their tokens may be adopted.
|
|
608
|
+
return result.ok === true ? { ...result, revisions } : result
|
|
533
609
|
}
|
|
534
610
|
|
|
535
611
|
function serializedWrite(key, work) {
|
|
@@ -549,13 +625,15 @@ function serializedWrite(key, work) {
|
|
|
549
625
|
* 的行集合也就不变,这是所有选项里破坏性最小的一个。
|
|
550
626
|
*
|
|
551
627
|
* @param {string} directory - the assistant's preset directory.
|
|
552
|
-
* @param {{id: string, mode: string, prompt: string, name: string, description: string, displayName?: string, baseN?: number}} input
|
|
628
|
+
* @param {{id: string, mode: string, prompt: string, name: string, description: string, displayName?: string, baseN?: number, baseRevision?: string}} input
|
|
553
629
|
* - the validated save; `name` decides whether `preset.yml` is written, `displayName` is what the response
|
|
554
630
|
* reports (the disk name when the request omitted one — issue #9). `baseN` is the same stale-save
|
|
555
631
|
* token as {@link saveState}: a JSON number only.
|
|
556
632
|
* @returns {object} the shape `saveState` returns, with `code: 'savedPromptOnly'`.
|
|
557
633
|
*/
|
|
558
|
-
export function savePromptOnly(directory, { id, mode, prompt, name, description, displayName, baseN }) {
|
|
634
|
+
export function savePromptOnly(directory, { id, mode, prompt, name, description, displayName, baseN, baseRevision }) {
|
|
635
|
+
const recovery = restoreBackups(directory)
|
|
636
|
+
if (recovery.ok !== true) return { ok: false, code: 'recoveryRequired', params: { path: directory, detail: recovery.detail }, error: '助手文件需要恢复:' + recovery.detail }
|
|
559
637
|
// 与正常保存同一条纪律:提示词与 preset.yml 一起换名,失败则两个都不动。
|
|
560
638
|
let metaText = null
|
|
561
639
|
if (name !== '') {
|
|
@@ -563,13 +641,18 @@ export function savePromptOnly(directory, { id, mode, prompt, name, description,
|
|
|
563
641
|
if (rendered.ok !== true) return rendered
|
|
564
642
|
metaText = rendered.text
|
|
565
643
|
}
|
|
566
|
-
const conflict =
|
|
644
|
+
const conflict = refuseStaleState(directory, prompt, baseN, baseRevision)
|
|
567
645
|
if (conflict !== null) return conflict
|
|
646
|
+
let revision
|
|
568
647
|
try {
|
|
569
|
-
|
|
648
|
+
const entries = [
|
|
570
649
|
[promptFile(directory), prompt],
|
|
571
650
|
...metaText === null ? [] : [[presetMetaPath(directory), metaText]],
|
|
572
|
-
]
|
|
651
|
+
]
|
|
652
|
+
// Return our exact submitted generation, without a post-commit read that
|
|
653
|
+
// could fail or accidentally adopt a different writer's subsequent change.
|
|
654
|
+
revision = revisionWithEntries(directory, entries)
|
|
655
|
+
writeAtomicPair(entries)
|
|
573
656
|
} catch (error) {
|
|
574
657
|
return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
|
|
575
658
|
}
|
|
@@ -583,6 +666,7 @@ export function savePromptOnly(directory, { id, mode, prompt, name, description,
|
|
|
583
666
|
id,
|
|
584
667
|
mode,
|
|
585
668
|
code: 'savedPromptOnly',
|
|
669
|
+
revision,
|
|
586
670
|
// 响应里的名字用**磁盘上的那个**(issue #9):请求省略 name 时不该把 params.name 报成裸目录 id。
|
|
587
671
|
params: { name: (displayName === undefined || displayName === '' ? name : displayName) === '' ? id : (displayName === undefined || displayName === '' ? name : displayName), mode },
|
|
588
672
|
note: '已保存系统提示词(本机取不到基础模式的出厂组成,插件开关与基础模式未改动)。下一步组装时会重读这个文件。',
|
|
@@ -669,6 +753,7 @@ export function saveState(rows, input) {
|
|
|
669
753
|
description,
|
|
670
754
|
displayName: effectiveName,
|
|
671
755
|
baseN: input !== null && typeof input === 'object' ? input.baseN : undefined,
|
|
756
|
+
baseRevision: input !== null && typeof input === 'object' ? input.baseRevision : undefined,
|
|
672
757
|
})
|
|
673
758
|
}
|
|
674
759
|
return {
|
|
@@ -701,14 +786,17 @@ export function saveState(rows, input) {
|
|
|
701
786
|
if (rendered.ok !== true) return rendered
|
|
702
787
|
metaText = rendered.text
|
|
703
788
|
}
|
|
704
|
-
const conflict =
|
|
789
|
+
const conflict = refuseStaleState(directory, prompt, input?.baseN, input?.baseRevision)
|
|
705
790
|
if (conflict !== null) return conflict
|
|
791
|
+
let revision
|
|
706
792
|
try {
|
|
707
|
-
|
|
793
|
+
const entries = [
|
|
708
794
|
[compositionFile(directory), composition],
|
|
709
795
|
[promptFile(directory), prompt],
|
|
710
796
|
...metaText === null ? [] : [[presetMetaPath(directory), metaText]],
|
|
711
|
-
]
|
|
797
|
+
]
|
|
798
|
+
revision = revisionWithEntries(directory, entries)
|
|
799
|
+
writeAtomicPair(entries)
|
|
712
800
|
} catch (error) {
|
|
713
801
|
return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
|
|
714
802
|
}
|
|
@@ -732,6 +820,7 @@ export function saveState(rows, input) {
|
|
|
732
820
|
id,
|
|
733
821
|
mode,
|
|
734
822
|
code: 'savedWithExclusiveRows',
|
|
823
|
+
revision,
|
|
735
824
|
// `rows` 用行 id:页面自己按 `row.<id>.label` 渲染,英文界面不会因为这条掉回中文。
|
|
736
825
|
params: { name: displayName, mode, rows: autoOff.join(', ') },
|
|
737
826
|
note:
|
|
@@ -744,6 +833,7 @@ export function saveState(rows, input) {
|
|
|
744
833
|
id,
|
|
745
834
|
mode,
|
|
746
835
|
code: 'saved',
|
|
836
|
+
revision,
|
|
747
837
|
params: { name: displayName, mode },
|
|
748
838
|
note: '已保存(' + displayName + ',基础模式 ' + mode + ')。下一步组装时会重读已保存的提示词。',
|
|
749
839
|
}
|
|
@@ -976,6 +1066,8 @@ export function repairComposition(rows, input) {
|
|
|
976
1066
|
const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
|
|
977
1067
|
const directory = assistantDir(rows, id)
|
|
978
1068
|
if (directory === undefined) return unknownAssistant(id)
|
|
1069
|
+
const conflict = refuseStaleState(directory, readPrompt(directory).text ?? '', undefined, input?.baseRevision)
|
|
1070
|
+
if (conflict !== null) return conflict
|
|
979
1071
|
const file = compositionFile(directory)
|
|
980
1072
|
if (!existsSync(file)) return { ok: false, code: 'compositionMissing', params: { path: file }, error: '找不到组成文件:' + file }
|
|
981
1073
|
const text = readFileSync(file, 'utf8')
|
|
@@ -1006,7 +1098,9 @@ export function repairComposition(rows, input) {
|
|
|
1006
1098
|
}
|
|
1007
1099
|
// **就地**关闭那几行,不重渲染:重渲染会顺手丢掉 base 之外的自有行,而用户的数据不该被这样动。
|
|
1008
1100
|
const rendered = disableRowsInPlace(text, bad.map((row) => row.id))
|
|
1101
|
+
let revision
|
|
1009
1102
|
try {
|
|
1103
|
+
revision = revisionWithEntries(directory, [[file, rendered]])
|
|
1010
1104
|
writeAtomic(file, rendered)
|
|
1011
1105
|
} catch (error) {
|
|
1012
1106
|
return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
|
|
@@ -1016,6 +1110,7 @@ export function repairComposition(rows, input) {
|
|
|
1016
1110
|
ok: true,
|
|
1017
1111
|
id,
|
|
1018
1112
|
code: 'repaired',
|
|
1113
|
+
revision,
|
|
1019
1114
|
// `ids` 是给人看的一串;`repairedIds` 是给**页面**用的数组 —— 页面必须把这几行写进它自己的
|
|
1020
1115
|
// 草稿,否则下一次保存会按草稿重渲染,把刚才的修复原样撤销(issue #8 实测:
|
|
1021
1116
|
// 修复写进了磁盘,但草稿里那几行仍是启用的,于是保存后 ghost 行又回来了)。
|
|
@@ -1422,7 +1517,15 @@ export function apply(ctx) {
|
|
|
1422
1517
|
detail: outcome.failures.map((failure) => failure.error ?? failure.code ?? 'unknown').join('; '),
|
|
1423
1518
|
})
|
|
1424
1519
|
|
|
1425
|
-
const
|
|
1520
|
+
const capabilitySnapshot = () => {
|
|
1521
|
+
try {
|
|
1522
|
+
if (typeof scope.get === 'function') return { profileContext: Boolean(scope.get('profileContext')) }
|
|
1523
|
+
} catch {
|
|
1524
|
+
/* Optional host capability is unknown; keep predicate warning/fail-safe. */
|
|
1525
|
+
}
|
|
1526
|
+
return {}
|
|
1527
|
+
}
|
|
1528
|
+
const handle = (request, pathname) => withPredicateCapabilities(capabilitySnapshot(), async () => {
|
|
1426
1529
|
try {
|
|
1427
1530
|
const url = new URL(request.url)
|
|
1428
1531
|
if (request.method === 'GET' && pathname === ROUTE_PATH) {
|
|
@@ -1488,7 +1591,7 @@ export function apply(ctx) {
|
|
|
1488
1591
|
const result = pathname === STATE_PATH ? saveState(rows, parsed)
|
|
1489
1592
|
: pathname === CREATE_PATH ? createAssistant(rows, parsed)
|
|
1490
1593
|
: pathname === REPAIR_PATH ? repairComposition(rows, parsed)
|
|
1491
|
-
: pathname === REORDER_PATH ?
|
|
1594
|
+
: pathname === REORDER_PATH ? reorderState(rows, parsed)
|
|
1492
1595
|
: await deleteAssistant(rows, parsed, presetRemover())
|
|
1493
1596
|
if (result.ok !== true) return json(result, 400)
|
|
1494
1597
|
const synced = await resyncPresets()
|
|
@@ -1507,7 +1610,7 @@ export function apply(ctx) {
|
|
|
1507
1610
|
// ——中文界面里会蹦出一句英文。带 code 后页面用自己的词典渲染,原始串只作为 `detail` 参数。
|
|
1508
1611
|
return json({ ok: false, code: 'internalError', params: { detail: describe(error) }, error: describe(error) }, 500)
|
|
1509
1612
|
}
|
|
1510
|
-
}
|
|
1613
|
+
})
|
|
1511
1614
|
|
|
1512
1615
|
// One registration per exact path — the platform's Fetch registry matches exact paths, not prefixes —
|
|
1513
1616
|
// each declaring the methods it owns. The method table doubles as the guarantee that a prefetched
|
package/locales.mjs
CHANGED
|
@@ -101,6 +101,9 @@ export const zh = {
|
|
|
101
101
|
'assistant.brokenHint': '此模式在本机无法启动,不会出现在新会话菜单里。点「按本线修复」只关闭那些行。',
|
|
102
102
|
'assistant.switchHint': '切换助手保留各自的未保存草稿;列表中的「未保存」标记即为此。',
|
|
103
103
|
'btn.create': '新增助手',
|
|
104
|
+
'btn.newAssistant': '新建助手…',
|
|
105
|
+
'assistant.duplicateSavedConfirm': '复制使用磁盘上已保存的配置,不包含当前未保存的修改。继续复制已保存版本?当前草稿会保留。',
|
|
106
|
+
'prompt.replaceConfirm': '替换当前提示词草稿?尚未保存的提示词会被替换,无法从保存历史中恢复。请先复制保留需要的内容。',
|
|
104
107
|
'create.heading': '新建助手',
|
|
105
108
|
'btn.creating': '创建中…',
|
|
106
109
|
'btn.duplicate': '复制',
|
|
@@ -150,7 +153,7 @@ export const zh = {
|
|
|
150
153
|
'meta.version': '插件版本',
|
|
151
154
|
'meta.versionHint': '未钉版本时,可能装到较旧的发布。要换版本,按 README 钉版本重装,然后重启。',
|
|
152
155
|
'api.saved': '已保存({name},{mode})。下一步组装时会重读已保存的提示词。',
|
|
153
|
-
'api.conflict': '
|
|
156
|
+
'api.conflict': '磁盘上的助手配置已经变了,这次没有写入。请先导出或复制保留草稿,再重新读取并合并修改。',
|
|
154
157
|
'api.savedWithExclusiveRows': '已保存({name},{mode})。已关闭不能同时启用的行:{rows}。',
|
|
155
158
|
'api.created': '已创建「{name}」。可继续编辑;使用时请在新会话的模式菜单选择它。',
|
|
156
159
|
'api.savedPrompt': '已保存系统提示词({name})。下一步组装时会重读这个文件。',
|
|
@@ -425,6 +428,9 @@ export const en = {
|
|
|
425
428
|
'assistant.brokenHint': 'This mode cannot start here, so it will not appear for a new session. Fix for this line turns off only those rows.',
|
|
426
429
|
'assistant.switchHint': 'Switching assistants keeps each draft; the "Unsaved" marker in the list is exactly that.',
|
|
427
430
|
'btn.create': 'New assistant',
|
|
431
|
+
'btn.newAssistant': 'New assistant…',
|
|
432
|
+
'assistant.duplicateSavedConfirm': 'Copy the saved configuration from disk? Unsaved changes are not included, and your current draft will be kept.',
|
|
433
|
+
'prompt.replaceConfirm': 'Replace the current prompt draft? Unsaved prompt text will be replaced and cannot be recovered from saved history. Copy any content you need to keep first.',
|
|
428
434
|
'create.heading': 'New assistant',
|
|
429
435
|
'btn.creating': 'Creating…',
|
|
430
436
|
'btn.duplicate': 'Duplicate',
|
|
@@ -474,7 +480,7 @@ export const en = {
|
|
|
474
480
|
'meta.version': 'Plugin version',
|
|
475
481
|
'meta.versionHint': 'Without a pinned version, an older release may be installed. To change it, reinstall with the version pin from the README, then restart.',
|
|
476
482
|
'api.saved': 'Saved ({name}, {mode}). The next model step rereads the saved prompt.',
|
|
477
|
-
'api.conflict': 'The
|
|
483
|
+
'api.conflict': 'The assistant configuration on disk has changed. Nothing was written. Export or copy your draft, then reload and merge your changes.',
|
|
478
484
|
'api.savedWithExclusiveRows': 'Saved ({name}, {mode}). Turned off rows that cannot be on together: {rows}.',
|
|
479
485
|
'api.created': 'Created {name}. Continue editing here, then choose it in the mode menu for a new session.',
|
|
480
486
|
'api.savedPrompt': 'System prompt saved ({name}). The next model step rereads this file.',
|
package/package.json
CHANGED
|
@@ -26,20 +26,17 @@
|
|
|
26
26
|
import { existsSync, readFileSync, renameSync, rmSync } from 'node:fs'
|
|
27
27
|
import { basename, dirname, join } from 'node:path'
|
|
28
28
|
import { pathToFileURL } from 'node:url'
|
|
29
|
-
import { parseComposition } from './parse-composition.mjs'
|
|
29
|
+
import { parseComposition, evaluateDisabledPredicate, profileContextPredicate } from './parse-composition.mjs'
|
|
30
30
|
|
|
31
31
|
export const BACKEND_ID = 'declarative'
|
|
32
32
|
|
|
33
|
-
/**
|
|
33
|
+
/** Preserve the host-native predicate node so the loader evaluates it against its real Entry.ctx. */
|
|
34
34
|
function resolveDisabled(value) {
|
|
35
35
|
if (value !== null && typeof value === 'object' && typeof value.__js === 'string') {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// 表达式求值不了时按"关掉"处理:宁可少一行能力,也不要让整条 preset 挂载失败。
|
|
41
|
-
return true
|
|
42
|
-
}
|
|
36
|
+
// An unknown ctx service can return undefined rather than throw, which would
|
|
37
|
+
// make the native loader enable it. Keep unsupported conditions truly off.
|
|
38
|
+
if (profileContextPredicate(value.__js) === null && evaluateDisabledPredicate(value.__js) === undefined) return true
|
|
39
|
+
return { __jsExpr: value.__js }
|
|
43
40
|
}
|
|
44
41
|
return value === true
|
|
45
42
|
}
|
|
@@ -39,6 +39,56 @@ export class CompositionParseError extends Error {
|
|
|
39
39
|
const isBlank = (line) => line.trim() === '' || line.trim().startsWith('#')
|
|
40
40
|
const indentOf = (line) => line.length - line.trimStart().length
|
|
41
41
|
|
|
42
|
+
/** Decode the YAML scalar around a tagged JS source; never execute the expression. */
|
|
43
|
+
export function decodeJsExpression(raw, lineNo = 0) {
|
|
44
|
+
const source = String(raw).trim()
|
|
45
|
+
if (source === '') throw new CompositionParseError('!!js 后面没有表达式', lineNo)
|
|
46
|
+
if (source.startsWith("'")) {
|
|
47
|
+
if (!/^'(?:[^']|'')*'$/.test(source)) throw new CompositionParseError('!!js 单引号标量无效', lineNo)
|
|
48
|
+
return source.slice(1, -1).replace(/''/g, "'")
|
|
49
|
+
}
|
|
50
|
+
if (source.startsWith('"')) {
|
|
51
|
+
try {
|
|
52
|
+
const decoded = JSON.parse(source)
|
|
53
|
+
if (typeof decoded !== 'string') throw new Error('Expected a string')
|
|
54
|
+
return decoded
|
|
55
|
+
} catch {
|
|
56
|
+
throw new CompositionParseError('!!js 双引号标量无效', lineNo)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return source
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The only context-service predicate currently understood by the editor. */
|
|
63
|
+
export function profileContextPredicate(source) {
|
|
64
|
+
const match = /^(!{0,2})\s*ctx\s*\.\s*get\s*\(\s*(['"])profileContext\2\s*\)$/.exec(source)
|
|
65
|
+
return match === null ? null : match[1].length
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Preserve existing non-context evaluation semantics for installed composition
|
|
70
|
+
* code, while unknown context references stay unsupported rather than guessed.
|
|
71
|
+
*/
|
|
72
|
+
export function evaluateDisabledPredicate(source, capabilities = {}) {
|
|
73
|
+
const negations = profileContextPredicate(source)
|
|
74
|
+
if (negations !== null) {
|
|
75
|
+
if (typeof capabilities?.profileContext !== 'boolean') return undefined
|
|
76
|
+
return negations === 1 ? !capabilities.profileContext : capabilities.profileContext
|
|
77
|
+
}
|
|
78
|
+
const code = source
|
|
79
|
+
.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, '')
|
|
80
|
+
.replace(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g, '')
|
|
81
|
+
if (/\bctx\b/.test(code)) return undefined
|
|
82
|
+
try {
|
|
83
|
+
// Composition files are installed executable code. No real host context is
|
|
84
|
+
// supplied, and the ctx parameter shadows any unrelated global of that name.
|
|
85
|
+
const evaluate = new Function('process', 'ctx', `return (${source});`)
|
|
86
|
+
return Boolean(evaluate(process, undefined))
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
42
92
|
/**
|
|
43
93
|
* Parse a composition document into plain JS.
|
|
44
94
|
*
|
|
@@ -156,9 +206,7 @@ export function parseScalar(raw, lineNo = 0) {
|
|
|
156
206
|
const value = raw.trim()
|
|
157
207
|
if (value === '') return null
|
|
158
208
|
if (value.startsWith('!!js')) {
|
|
159
|
-
|
|
160
|
-
if (source === '') throw new CompositionParseError('!!js 后面没有表达式', lineNo)
|
|
161
|
-
return { __js: source }
|
|
209
|
+
return { __js: decodeJsExpression(value.slice(4), lineNo) }
|
|
162
210
|
}
|
|
163
211
|
if (value === 'true') return true
|
|
164
212
|
if (value === 'false') return false
|