dsh-agent-persona 0.1.6 → 0.1.8
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/CHANGELOG.md +70 -0
- package/README.md +15 -11
- package/README_EN.md +17 -12
- package/lib/client.js +79 -21
- package/lib/index.d.ts +15 -9
- package/lib/index.js +401 -103
- package/package.json +11 -7
- package/scripts/generate-remote.mjs +179 -0
- package/src/client.ts +3312 -0
- package/src/endpoints.ts +33 -0
- package/src/index.ts +2229 -0
- package/src/remote.ts +58 -0
- package/src/typert.ts +276 -0
- package/tsconfig.client.json +13 -0
- package/tsconfig.json +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,76 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.1.8] - 2026-10-23
|
|
8
|
+
|
|
9
|
+
这一版几乎全部来自一次逐行读代码的审查:不改功能,修的是「它看起来在做 A,实际在做 B」的地方。
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **人设文件的守卫改成按能力判定,不再按工具名。** 原来维护两张工具名白名单,名单外的工具(`str_replace_editor`、
|
|
14
|
+
`terminal_send`,以及任何后来装上的插件工具)无论参数指向哪里都放行;`apply_patch` 的补丁正文更是完全看不见。
|
|
15
|
+
现在问的是「这次调用会不会写到受保护的文件」:参数里的路径(解析 `~` 与相对路径)、shell 行的重定向目标与写入型
|
|
16
|
+
命令、补丁正文里的 `*** Update File:` 目标,命中主文件、历史文件及其 `.bak-*`/`.tmp-*` 副本就拒。
|
|
17
|
+
- **不再误杀只读的重定向。** `jq . <人设文件> > /tmp/c.json` 以前因为同一行里既有路径又有 `>` 而被拒;现在看的是
|
|
18
|
+
`>` 后面那个目标,写到别处就放行,写到人设文件才拒(`2>/dev/null` 也不再算写入)。
|
|
19
|
+
- **`prefix` 规则不再把隔壁项目算进来。** `raw.startsWith(value)` 让 `/tmp/ws` 匹配到 `/tmp/ws-legacy`;现在两侧
|
|
20
|
+
`resolve` 后按分隔符比较,Windows 上按大小写不敏感比,`exact` 也开始认 `~/`。
|
|
21
|
+
- **正则规则不再能卡死事件循环。** 长度上限 500 字符,编译缓存上限 64 条(原来既无上限也无淘汰)。
|
|
22
|
+
- **局部更新不再毁字段。** `savePersona({ id, name })` 以前会把正文清空、把停用的人设重新启用(缺字段一律取默认值);
|
|
23
|
+
现在每个字段都是「传了才覆盖」。
|
|
24
|
+
- **保存失败不再留下脏内存。** 所有写操作先克隆当前 store 再改,持久化成功后才替换内存与时间戳;此前 `commit`
|
|
25
|
+
不刷新 `storeStamp`,写进去的内容下一次 reload 就被自己丢掉。
|
|
26
|
+
- **版本历史坏掉不再让保存永久失败。** 记录前先校验链能否重放到当前正文,不能就重新以当前正文为基准(自愈),
|
|
27
|
+
而不是抛「版本历史损坏」;历史文件损坏时先复制成 `.bak-<时间戳>` 再重建,不再直接覆盖。
|
|
28
|
+
- **`{"personas": []}` 不再静默吞掉全部版本。** 数组被当成对象接受,版本写到数组属性上,`JSON.stringify` 一序列化就没了。
|
|
29
|
+
- **删除人设会清掉它的历史。** 之前 `personas.history.json` 只增不减。
|
|
30
|
+
- **`duplicatePersona` 的副本保留注入方式。** 复制 `replace` 人设会退化成 `append`。
|
|
31
|
+
- **备份文件名不再同秒覆盖。** 时间戳加到毫秒。
|
|
32
|
+
- **`clipMarkdown` 不再劈开代理对。** 按 UTF-16 长度切片时可能停在半个 emoji 上,现在退一位。
|
|
33
|
+
- **RPC 失败只回 `error.message`。** 以前把 `error.stack` 整段回给浏览器。
|
|
34
|
+
- **配置里的 `tuneProvider` / `tuneModel` 进了 schema。** 代码一直在读它们,README 一直在写它们,只有校验层不认。
|
|
35
|
+
- **设置页保存不再吞掉在途输入。** 保存返回时用服务端数据覆盖草稿,保存期间继续敲的字会消失;现在只有草稿没被改过
|
|
36
|
+
才覆盖,并用 `saveSeq` 丢弃过期响应。
|
|
37
|
+
- **恢复版本、切换开关不再与在途保存打架。** 恢复前取消自动保存定时器并推进 `saveSeq`;版本 diff 请求带令牌,
|
|
38
|
+
过期响应不会覆盖新选择。
|
|
39
|
+
- **关掉设置页不会再丢最后一次编辑。** 卸载时若草稿是脏的且自动保存开着,补一次保存。
|
|
40
|
+
- **`Cmd/Ctrl+S` 不再能重入。** 保存中按快捷键会并发发两个请求。
|
|
41
|
+
- **toast 不再常驻。** `onDone` 每次渲染都是新函数,宿主 `Toast` 的 `useEffect` 依赖它 → 定时器被不断续命。
|
|
42
|
+
- **自动保存不再每次都弹两句话。** 去掉了先报的「正在自动保存…」,只留结果。
|
|
43
|
+
- **偏好同步只发改动的那一项。** 以前发整份 prefs,两个标签页会互相覆盖。
|
|
44
|
+
- **设置页刷新后偏好与存储一致。** `view.prefs` 现在也回填到界面状态,不只是写 localStorage。
|
|
45
|
+
- **导入失败后重新拉取。** replace 模式中途失败会留下删了一半的列表。
|
|
46
|
+
- **开关的无障碍语义。** `role="switch"` + `aria-checked` + `aria-describedby`(说明不再塞进 `aria-label` 变成一句话)。
|
|
47
|
+
- **`injectStyles` 缓存不再指向已移除的节点。**
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- `files` 补上 `src`、`scripts/generate-remote.mjs` 与两份 tsconfig:以前 `npm ci` 之后 `prepare` 会用构建产物
|
|
52
|
+
覆盖源码,仓库里「生成产物漂移」的校验因此永远是绿的,而消费者从一个 tarball 里 rebuild 会直接失败。
|
|
53
|
+
- CI 增加矩阵(Node 22 / 24)、`git diff --exit-code` 漂移门,以及一个把 tarball 装进空目录再 import 的冒烟 job。
|
|
54
|
+
- `release.yml` 的手动触发默认 `dry_run: true`(以前默认直接发布)。
|
|
55
|
+
- peer 依赖加上上界 `<0.3.0`:`>=` 表示「任何未来版本都能用」,这不成立。
|
|
56
|
+
|
|
57
|
+
### Known
|
|
58
|
+
|
|
59
|
+
- `exports["./client"]` 仍然没有 `types`:`lib/client.js` 是 esbuild 打成的一整个 IIFE,没有对应的 `.d.ts` 产物。
|
|
60
|
+
补它需要单独的类型入口,留给下一版。
|
|
61
|
+
|
|
62
|
+
## [0.1.7] - 2026-10-23
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- **开关的 toast 只讲自己那件事。** 提示文案原来只对两个显示开关按 `patch` 取,其余一律按
|
|
67
|
+
`next.autosave` 计算,于是拨动「禁止模型改写人设文件」或「保存人设版本历史」时弹出来的是
|
|
68
|
+
「编辑后自动保存」——一个这次根本没碰的开关的话。现在每个偏好各有一条按方向分岔的句子
|
|
69
|
+
(`PREF_STATUS`),键从 `patch` 上读,未知键退化成「已保存」。
|
|
70
|
+
- **同一句提示连拨两次也会再弹一次。** toast 原来用固定 `key`,第二次拨回同一状态时 React 复用
|
|
71
|
+
同一个组件实例:文案一样、key 一样,定时器继续走,屏幕上看不到新提示。现在 `status` 每次写入都
|
|
72
|
+
推进一个 `statusSeq`,toast 按它做 key,重复的句子也会重新挂载。这也是「开启没反应、关闭才弹」
|
|
73
|
+
的成因之一:两个方向的文案本来就相同,第二次写入被吞掉了。
|
|
74
|
+
- 顺带把「关闭自动保存」的文案从「已关闭:编辑后自动保存」改成「已关闭:改完要自己点保存」。原文案
|
|
75
|
+
在关闭状态下仍然宣称会自动保存。
|
|
76
|
+
|
|
7
77
|
## [0.1.6] - 2026-10-22
|
|
8
78
|
|
|
9
79
|
### Added
|
package/README.md
CHANGED
|
@@ -145,7 +145,8 @@ dsh plugin --profile web remove dsh-agent-persona # 卸载
|
|
|
145
145
|
| 编辑后自动保存 | 关 | 停手约 1 秒自动保存编辑区内容 | 立即;切换到人设规则行(工作区/会话)时**不**自动保存,避免存下半截选择 |
|
|
146
146
|
| 在对话页显示「人设」标签 | 开 | 是否注册对话页那个视图 | 立即(运行时注册/注销) |
|
|
147
147
|
| 注册到 dsh-better-sidebar | 开 | 是否把「人设」页面注册进 dsh-better-sidebar 的右侧栏 | 立即;**没装该插件时这个开关不显示** |
|
|
148
|
-
| 禁止模型改写人设文件 | 开 |
|
|
148
|
+
| 禁止模型改写人设文件 | 开 | 按**能力**判定:任何会写入/删除人设存储(含它的历史文件与备份副本)的工具调用都拒绝,读取、`grep`、把路径写进笔记都不受影响;关掉后不拦 | 立即;对**下一次工具调用**生效 |
|
|
149
|
+
| 保存人设版本历史 | 关 | 每次保存记一条可重放的 diff(最多 200 条,可查看/对比/恢复) | 立即;关掉后不再记新版本,已有历史仍可查看 |
|
|
149
150
|
|
|
150
151
|
偏好存在人设文件的 `prefs` 字段里(不是浏览器),所以它和数据的可见范围一致;浏览器里只留一份镜像,
|
|
151
152
|
供"加载时决定要不要注册视图"用。
|
|
@@ -179,8 +180,9 @@ dsh plugin --profile web remove dsh-agent-persona # 卸载
|
|
|
179
180
|
|
|
180
181
|
```
|
|
181
182
|
~/.dsh/dsh-agent-persona/
|
|
182
|
-
├── personas.json
|
|
183
|
-
|
|
183
|
+
├── personas.json # 人设数据 + 偏好,权限 600,原子写入
|
|
184
|
+
├── personas.history.json # 版本历史(开关打开后才出现)
|
|
185
|
+
└── state.json # 加载心跳,每次加载时重写
|
|
184
186
|
```
|
|
185
187
|
|
|
186
188
|
正文里可以写 `{{model}}` 和 `{{cwd}}`,渲染时替换成当前的值;其他 `{{...}}` 会被去掉(未注册的变量会让
|
|
@@ -218,16 +220,18 @@ AI 只产出建议稿,点「采用」或「追加」才会进编辑框,再
|
|
|
218
220
|
## 几句实话
|
|
219
221
|
|
|
220
222
|
- **人设是行为约束,不是安全边界**。它约束模型怎么说,不阻止模型用工具。
|
|
221
|
-
-
|
|
222
|
-
|
|
223
|
-
|
|
223
|
+
- 人设文件的防篡改是**能力层面**的判定:插件注册了全局 `tools.guard`,它不看工具叫什么名字,而是看这次调用会不会写到
|
|
224
|
+
受保护的文件 —— 参数里的路径(解析 `~` 与相对路径)、shell 命令里的重定向目标、写入型命令(删、移、原地改、解释器
|
|
225
|
+
`-c`),以及补丁正文里的 `*** Update File:` 目标。受保护的是主文件 `personas.json`、它的历史文件和它们的备份/临时副本。
|
|
226
|
+
- 它仍然只是行为约束,绕过面写在明处:本机 `danger-full-access` 下改插件代码、改 profile 补丁文件都能绕过;脚本里
|
|
227
|
+
运行时拼出来的路径静态看不出;`$VAR` 展开看不出;别名/函数包装也看不出。要更结实得在本机层面做:
|
|
224
228
|
`chflags uchg <人设文件>`(自己改之前先 `chflags nouchg`),或者把 DSH 跑在受限沙箱里、让人设文件属于别的用户。
|
|
225
|
-
-
|
|
229
|
+
- 判定**按单次工具调用**生效:同一条命令里既读又写,写的那部分照样被拒,只读的部分不会被连坐。
|
|
226
230
|
- 多处同时匹配时**只取第一条**,不拼接;规则是线性扫的,几十条没问题,几百条就该重新设计了。
|
|
227
231
|
- 一个 profile 里只装一次。同一个进程挂两个同 id 的行会让组合起不来。
|
|
228
|
-
- **测试的边界**:仓库里的测试是**单元测试 + 源码审计**(
|
|
229
|
-
|
|
230
|
-
|
|
232
|
+
- **测试的边界**:仓库里的测试是**单元测试 + 源码审计**(236 条宿主断言 + 187 条客户端断言 + 生成产物漂移校验;
|
|
233
|
+
这个数字由测试自己统计,不会随文件改动而失真),它们保证逻辑与代码形状,**不驱动浏览器** —— 界面行为
|
|
234
|
+
(视图渲染、开关、侧边栏卡片)目前靠人工核对。
|
|
231
235
|
|
|
232
236
|
## 开发
|
|
233
237
|
|
|
@@ -246,7 +250,7 @@ docs/ 架构、开发笔记、设计决策
|
|
|
246
250
|
npm install
|
|
247
251
|
npm run build # generate:remote → tsc → esbuild(产出 lib/)
|
|
248
252
|
npm run typecheck # 两个 tsconfig 都不报错
|
|
249
|
-
npm test #
|
|
253
|
+
npm test # 236 + 187 条断言 + 生成产物漂移校验
|
|
250
254
|
dsh --profile web --dump-config | grep -c 'id: agent-persona' # 组合自检,应输出 1
|
|
251
255
|
```
|
|
252
256
|
|
package/README_EN.md
CHANGED
|
@@ -194,8 +194,9 @@ error.
|
|
|
194
194
|
|
|
195
195
|
```
|
|
196
196
|
~/.dsh/dsh-agent-persona/
|
|
197
|
-
├── personas.json
|
|
198
|
-
|
|
197
|
+
├── personas.json # personas + preferences, mode 600, written atomically
|
|
198
|
+
├── personas.history.json # version history (only while the switch is on)
|
|
199
|
+
└── state.json # load heartbeat, rewritten on every load
|
|
199
200
|
```
|
|
200
201
|
|
|
201
202
|
The body may use `{{model}}` and `{{cwd}}`; other `{{...}}` are de-braced (an unregistered variable would make the
|
|
@@ -229,18 +230,22 @@ Agent 人设 page (`保存在 …`), so it is never a mystery which file you are
|
|
|
229
230
|
|
|
230
231
|
- **A persona is a behavioural constraint, not a security boundary.** It shapes what the model says; it does not
|
|
231
232
|
stop tools.
|
|
232
|
-
- Tamper protection is
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
233
|
+
- Tamper protection is decided by **capability, not by tool name**: a global `tools.guard` asks whether this call
|
|
234
|
+
would write a protected file — the paths in its arguments (resolved through `~` and relative paths), redirect
|
|
235
|
+
targets and writing commands in a shell line (delete, move, in-place edit, interpreter `-c`), and the targets
|
|
236
|
+
named by `*** Update File:` markers in a patch body. Protected: `personas.json`, its history file, and their
|
|
237
|
+
backup and temp copies.
|
|
238
|
+
- It is still a behavioural measure, and the bypass surface is stated plainly: on `danger-full-access` you can
|
|
239
|
+
edit the plugin or the profile patch; a path assembled at runtime inside a script, `$VAR` expansion, or an
|
|
240
|
+
alias/function wrapper is invisible to a static read. Harder options live at the machine level: `chflags uchg
|
|
241
|
+
<store>` (run `chflags nouchg` before editing it yourself), or a restricted sandbox with the store owned by
|
|
242
|
+
another user.
|
|
243
|
+
- The decision is per **tool call**: a line that both reads and writes has its writing half refused; the reading
|
|
244
|
+
half is not implicated.
|
|
240
245
|
- Only the **first** match is used — no concatenation. Matching is a linear scan; dozens of rules are fine,
|
|
241
246
|
hundreds want a redesign.
|
|
242
247
|
- Install once per profile: two rows with the same id in one process make the composition fail.
|
|
243
|
-
- **Test boundary**: the suite is unit tests plus source audits (
|
|
248
|
+
- **Test boundary**: the suite is unit tests plus source audits (236 host assertions, 187 client assertions, and a
|
|
244
249
|
generated-artifact drift check; the numbers are counted by the suites themselves, so they cannot fall behind
|
|
245
250
|
the files). It does not drive a browser — UI behaviour is checked by hand today.
|
|
246
251
|
|
|
@@ -261,7 +266,7 @@ docs/ architecture, development notes, design decisions
|
|
|
261
266
|
npm install
|
|
262
267
|
npm run build # generate:remote → tsc → esbuild (writes lib/)
|
|
263
268
|
npm run typecheck # both tsconfigs, no errors
|
|
264
|
-
npm test #
|
|
269
|
+
npm test # 236 + 187 assertions plus the generated-artifact drift check
|
|
265
270
|
dsh --profile web --dump-config | grep -c 'id: agent-persona' # composition self-check, expects 1
|
|
266
271
|
```
|
|
267
272
|
|
package/lib/client.js
CHANGED
|
@@ -229,13 +229,24 @@
|
|
|
229
229
|
return { ...DEFAULT_PREFS };
|
|
230
230
|
}
|
|
231
231
|
};
|
|
232
|
-
const
|
|
232
|
+
const PREF_STATUS = {
|
|
233
|
+
autosave: (on) => on ? "\u5DF2\u5F00\u542F\uFF1A\u7F16\u8F91\u540E\u505C\u6B62\u8F93\u5165\u7EA6 1 \u79D2\u5373\u81EA\u52A8\u4FDD\u5B58" : "\u5DF2\u5173\u95ED\uFF1A\u6539\u5B8C\u8981\u81EA\u5DF1\u70B9\u4FDD\u5B58",
|
|
234
|
+
showTab: (on) => on ? "\u5DF2\u663E\u793A\u300C\u4EBA\u8BBE\u300D\u6807\u7B7E\uFF08\u5237\u65B0\u9875\u9762\u540E\u51FA\u73B0\uFF09" : "\u5DF2\u9690\u85CF\u300C\u4EBA\u8BBE\u300D\u6807\u7B7E\uFF08\u5237\u65B0\u9875\u9762\u540E\u751F\u6548\uFF09",
|
|
235
|
+
showSidebar: (on) => on ? "\u5DF2\u6CE8\u518C\u5230 dsh-better-sidebar" : "\u5DF2\u4ECE dsh-better-sidebar \u6CE8\u9500",
|
|
236
|
+
guard: (on) => on ? "\u5DF2\u5F00\u542F\uFF1A\u6A21\u578B\u7684\u5199\u5165\u4E0E\u547D\u4EE4\u5DE5\u5177\u4E0D\u80FD\u518D\u6539\u4EBA\u8BBE\u5B58\u50A8" : "\u5DF2\u5173\u95ED\uFF1A\u6A21\u578B\u53EF\u4EE5\u76F4\u63A5\u6539\u5199\u4EBA\u8BBE\u5B58\u50A8",
|
|
237
|
+
history: (on) => on ? "\u5DF2\u5F00\u542F\uFF1A\u6BCF\u6B21\u4FDD\u5B58\u90FD\u4F1A\u8BB0\u4E00\u6761\u7248\u672C\u5386\u53F2" : "\u5DF2\u5173\u95ED\uFF1A\u4E0D\u518D\u8BB0\u65B0\u7248\u672C\uFF0C\u5DF2\u6709\u5386\u53F2\u4ECD\u53EF\u67E5\u770B\u4E0E\u6062\u590D"
|
|
238
|
+
};
|
|
239
|
+
const prefStatus = (patch) => {
|
|
240
|
+
const key = Object.keys(patch ?? {}).find((name) => Object.prototype.hasOwnProperty.call(PREF_STATUS, name));
|
|
241
|
+
if (key === void 0) return "\u5DF2\u4FDD\u5B58";
|
|
242
|
+
return PREF_STATUS[key](patch[key] === true);
|
|
243
|
+
};
|
|
233
244
|
const persistPrefs = (api, unwrap, current, patch, report) => {
|
|
234
245
|
const next = writePrefs({ ...current, ...patch });
|
|
235
|
-
report({ prefs: next, status: prefStatus(
|
|
246
|
+
report({ prefs: next, status: prefStatus(patch) });
|
|
236
247
|
syncSidebarModule();
|
|
237
248
|
if (api === void 0 || typeof api.savePrefs !== "function") return next;
|
|
238
|
-
void api.savePrefs(
|
|
249
|
+
void api.savePrefs(patch).then((result) => {
|
|
239
250
|
const view = unwrap === void 0 ? result : unwrap(result);
|
|
240
251
|
const stored = view !== void 0 && view !== null && view.prefs !== void 0 && view.prefs !== null ? writePrefs(view.prefs) : void 0;
|
|
241
252
|
report(stored === void 0 ? { view } : { prefs: { ...next, ...stored }, view });
|
|
@@ -485,6 +496,10 @@
|
|
|
485
496
|
.wsp-switch input:checked + .wsp-switch-track { background: var(--wsp-success); }
|
|
486
497
|
.wsp-switch input:checked + .wsp-switch-track::after { transform: translateX(12px); }
|
|
487
498
|
.wsp-switch input:focus-visible + .wsp-switch-track { outline: 2px solid var(--wsp-accent); outline-offset: 2px; }
|
|
499
|
+
.wsp-sr-only {
|
|
500
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
|
501
|
+
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
502
|
+
}
|
|
488
503
|
.wsp-switch-text { font-size: 11.5px; color: var(--wsp-muted); }
|
|
489
504
|
|
|
490
505
|
/* \u2500\u2500 editor / preview \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
@@ -745,7 +760,8 @@
|
|
|
745
760
|
`;
|
|
746
761
|
let styleElement;
|
|
747
762
|
const injectStyles = () => {
|
|
748
|
-
if (styleElement !== void 0) return styleElement;
|
|
763
|
+
if (styleElement !== void 0 && styleElement.isConnected) return styleElement;
|
|
764
|
+
if (styleElement !== void 0) styleElement.remove();
|
|
749
765
|
styleElement = document.createElement("style");
|
|
750
766
|
styleElement.setAttribute("data-dsh-agent-persona", "");
|
|
751
767
|
styleElement.textContent = CSS;
|
|
@@ -1140,18 +1156,27 @@
|
|
|
1140
1156
|
const Badge = ({ children, tone }) => h("span", {
|
|
1141
1157
|
className: tone === void 0 ? "wsp-badge" : `wsp-badge wsp-badge-${tone}`
|
|
1142
1158
|
}, children);
|
|
1159
|
+
let switchId = 0;
|
|
1143
1160
|
const Switch = ({ checked, disabled, label, hint, onChange }) => {
|
|
1161
|
+
const descriptionId = React.useMemo(() => `wsp-switch-hint-${switchId += 1}`, []);
|
|
1144
1162
|
const control = h("label", { className: "wsp-switch", title: hint === void 0 ? label : void 0 }, [
|
|
1145
1163
|
h("input", {
|
|
1146
1164
|
key: "i",
|
|
1147
1165
|
type: "checkbox",
|
|
1166
|
+
// Role and state, not just appearance: a checkbox announced as "checkbox" next to a track that
|
|
1167
|
+
// says "switch" is two different controls to a screen reader. The hint belongs in the
|
|
1168
|
+
// description, not in the name — a name that reads a whole sentence is unusable in a list.
|
|
1169
|
+
role: "switch",
|
|
1170
|
+
"aria-checked": checked === true,
|
|
1148
1171
|
checked,
|
|
1149
1172
|
disabled,
|
|
1150
|
-
"aria-label":
|
|
1173
|
+
"aria-label": label,
|
|
1174
|
+
"aria-describedby": hint === void 0 ? void 0 : descriptionId,
|
|
1151
1175
|
onChange: (event) => onChange(event.target.checked)
|
|
1152
1176
|
}),
|
|
1153
1177
|
h("span", { key: "t", className: "wsp-switch-track" }),
|
|
1154
|
-
h("span", { key: "l", className: "wsp-switch-text" }, label)
|
|
1178
|
+
h("span", { key: "l", className: "wsp-switch-text" }, label),
|
|
1179
|
+
hint === void 0 ? null : h("span", { key: "d", id: descriptionId, className: "wsp-sr-only" }, hint)
|
|
1155
1180
|
]);
|
|
1156
1181
|
return hint === void 0 ? control : h(Tooltip, { label: hint, side: "top", delayMs: 260, maxWidth: 320 }, control);
|
|
1157
1182
|
};
|
|
@@ -1187,6 +1212,10 @@
|
|
|
1187
1212
|
busy: "",
|
|
1188
1213
|
error: "",
|
|
1189
1214
|
status: "",
|
|
1215
|
+
// Bumped on every status write. The toast is keyed on it, so the same sentence twice in a
|
|
1216
|
+
// row (toggling one switch off and on again) remounts instead of silently extending the
|
|
1217
|
+
// toast that is already on screen.
|
|
1218
|
+
statusSeq: 0,
|
|
1190
1219
|
transfer: void 0
|
|
1191
1220
|
};
|
|
1192
1221
|
this.mounted = false;
|
|
@@ -1195,14 +1224,25 @@
|
|
|
1195
1224
|
this.importTextRef = React.createRef();
|
|
1196
1225
|
this.importHighlightRef = React.createRef();
|
|
1197
1226
|
this.importTimer = void 0;
|
|
1227
|
+
this.statusSeq = 0;
|
|
1228
|
+
this.saveSeq = 0;
|
|
1229
|
+
this.versionSeq = 0;
|
|
1230
|
+
this.clearStatus = () => this.onChange({ status: "" });
|
|
1198
1231
|
this.onChange = (patch) => {
|
|
1199
|
-
if (this.mounted)
|
|
1232
|
+
if (!this.mounted) return;
|
|
1233
|
+
if (patch !== null && typeof patch === "object" && "status" in patch) {
|
|
1234
|
+
this.statusSeq += 1;
|
|
1235
|
+
this.setState({ ...patch, statusSeq: this.statusSeq });
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
this.setState(patch);
|
|
1200
1239
|
};
|
|
1201
1240
|
this.onKeyDown = (event) => {
|
|
1202
1241
|
if (!(event.metaKey || event.ctrlKey) || String(event.key).toLowerCase() !== "s") return;
|
|
1203
1242
|
const root = this.rootRef.current;
|
|
1204
1243
|
if (root === null || !root.contains(event.target)) return;
|
|
1205
1244
|
event.preventDefault();
|
|
1245
|
+
if (this.state.busy === "save") return;
|
|
1206
1246
|
void this.save();
|
|
1207
1247
|
};
|
|
1208
1248
|
}
|
|
@@ -1214,6 +1254,11 @@
|
|
|
1214
1254
|
void this.load();
|
|
1215
1255
|
}
|
|
1216
1256
|
componentWillUnmount() {
|
|
1257
|
+
if (this.autosaveTimer !== void 0 && this.isDirty()) {
|
|
1258
|
+
clearTimeout(this.autosaveTimer);
|
|
1259
|
+
this.autosaveTimer = void 0;
|
|
1260
|
+
void this.save();
|
|
1261
|
+
}
|
|
1217
1262
|
clearTimeout(this.autosaveTimer);
|
|
1218
1263
|
clearTimeout(this.importTimer);
|
|
1219
1264
|
this.mounted = false;
|
|
@@ -1263,12 +1308,13 @@
|
|
|
1263
1308
|
this.onChange({ busy: "load", error: "" });
|
|
1264
1309
|
try {
|
|
1265
1310
|
const view = this.unwrap(await api.listPersonas());
|
|
1266
|
-
|
|
1311
|
+
const stored = view?.prefs !== void 0 && view?.prefs !== null ? writePrefs(view.prefs) : void 0;
|
|
1267
1312
|
const wanted = keepOpen === true ? this.state.openId : void 0;
|
|
1268
1313
|
const persona = wanted === void 0 ? void 0 : (view.personas || []).find((item) => item.id === wanted);
|
|
1269
1314
|
this.onChange({
|
|
1270
1315
|
view,
|
|
1271
1316
|
busy: "",
|
|
1317
|
+
...stored === void 0 ? {} : { prefs: { ...this.state.prefs, ...stored } },
|
|
1272
1318
|
openId: persona === void 0 ? void 0 : wanted,
|
|
1273
1319
|
draft: persona === void 0 ? void 0 : this.draftFrom(persona)
|
|
1274
1320
|
});
|
|
@@ -1368,11 +1414,14 @@
|
|
|
1368
1414
|
const api = this.api();
|
|
1369
1415
|
const versions = this.state.versions;
|
|
1370
1416
|
if (api === void 0 || versions === void 0 || index < 0) return;
|
|
1417
|
+
const seq = this.versionSeq += 1;
|
|
1371
1418
|
this.onChange({ versionsBusy: "diff", versionIndex: index, versionsError: "" });
|
|
1372
1419
|
try {
|
|
1373
1420
|
const diff = this.unwrap(await api.versionDiff({ id: versions.id, index }));
|
|
1421
|
+
if (seq !== this.versionSeq) return;
|
|
1374
1422
|
this.onChange({ versionsBusy: "", versionDiff: diff });
|
|
1375
1423
|
} catch (error) {
|
|
1424
|
+
if (seq !== this.versionSeq) return;
|
|
1376
1425
|
this.onChange({ versionsBusy: "", versionDiff: void 0, versionsError: String(error && error.message || error) });
|
|
1377
1426
|
}
|
|
1378
1427
|
}
|
|
@@ -1381,6 +1430,9 @@
|
|
|
1381
1430
|
const api = this.api();
|
|
1382
1431
|
const versions = this.state.versions;
|
|
1383
1432
|
if (api === void 0 || versions === void 0) return;
|
|
1433
|
+
clearTimeout(this.autosaveTimer);
|
|
1434
|
+
this.autosaveTimer = void 0;
|
|
1435
|
+
this.saveSeq += 1;
|
|
1384
1436
|
this.onChange({ versionsBusy: "restore", versionsError: "" });
|
|
1385
1437
|
try {
|
|
1386
1438
|
const view = this.unwrap(await api.restoreVersion({ id: versions.id, index }));
|
|
@@ -1584,7 +1636,6 @@
|
|
|
1584
1636
|
clearTimeout(this.autosaveTimer);
|
|
1585
1637
|
this.autosaveTimer = setTimeout(() => {
|
|
1586
1638
|
this.autosaveTimer = void 0;
|
|
1587
|
-
this.onChange({ status: "\u6B63\u5728\u81EA\u52A8\u4FDD\u5B58\u2026" });
|
|
1588
1639
|
void this.save();
|
|
1589
1640
|
}, 1200);
|
|
1590
1641
|
}
|
|
@@ -1767,6 +1818,7 @@
|
|
|
1767
1818
|
});
|
|
1768
1819
|
} catch (error) {
|
|
1769
1820
|
this.onChange({ transfer: { ...transfer, busy: false, error: `\u5BFC\u5165\u5931\u8D25\uFF1A${String(error && error.message || error)}` } });
|
|
1821
|
+
await this.load(true);
|
|
1770
1822
|
}
|
|
1771
1823
|
}
|
|
1772
1824
|
async pickImportFile(event) {
|
|
@@ -1786,6 +1838,8 @@
|
|
|
1786
1838
|
const api = this.api();
|
|
1787
1839
|
const draft = this.state.draft;
|
|
1788
1840
|
if (api === void 0 || draft === void 0) return;
|
|
1841
|
+
if (this.state.busy === "save") return;
|
|
1842
|
+
const seq = this.saveSeq += 1;
|
|
1789
1843
|
this.onChange({ busy: "save", error: "", status: "" });
|
|
1790
1844
|
try {
|
|
1791
1845
|
const view = this.unwrap(await api.savePersona({
|
|
@@ -1797,14 +1851,13 @@
|
|
|
1797
1851
|
text: draft.text,
|
|
1798
1852
|
targets: draft.targets
|
|
1799
1853
|
}));
|
|
1854
|
+
if (seq !== this.saveSeq) return;
|
|
1800
1855
|
const persona = (view.personas || []).find((item) => item.id === draft.id);
|
|
1801
|
-
this.
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
draft: persona === void 0 ? void 0 : this.draftFrom(persona),
|
|
1805
|
-
status: view.notice ?? "\u5DF2\u4FDD\u5B58\uFF0C\u4E0B\u4E00\u6761\u6D88\u606F\u5C31\u4F1A\u7528\u4E0A"
|
|
1806
|
-
});
|
|
1856
|
+
const latest = this.state.draft;
|
|
1857
|
+
const draftAfter = latest === draft && persona !== void 0 ? this.draftFrom(persona) : latest;
|
|
1858
|
+
this.onChange({ view, busy: "", draft: draftAfter, status: view.notice ?? "\u5DF2\u4FDD\u5B58\uFF0C\u4E0B\u4E00\u6761\u6D88\u606F\u5C31\u4F1A\u7528\u4E0A" });
|
|
1807
1859
|
} catch (error) {
|
|
1860
|
+
if (seq !== this.saveSeq) return;
|
|
1808
1861
|
this.onChange({ busy: "", error: String(error && error.message || error) });
|
|
1809
1862
|
}
|
|
1810
1863
|
}
|
|
@@ -2425,7 +2478,6 @@ ${state.proposal.trim()}
|
|
|
2425
2478
|
onClick: () => this.onChange({ filter: "" })
|
|
2426
2479
|
}, "\u6E05\u7A7A")
|
|
2427
2480
|
]);
|
|
2428
|
-
const hiddenNote = null;
|
|
2429
2481
|
const conflictNotice = conflicts.size === 0 ? null : h("div", { className: "wsp-conflict-notice", key: "conflicts" }, [
|
|
2430
2482
|
h(IconWarningOutline16, { key: "i" }),
|
|
2431
2483
|
`${conflicts.size} \u6761\u4EBA\u8BBE\u7684\u8303\u56F4\u53EF\u80FD\u88AB\u4E0A\u9762\u7684\u4EBA\u8BBE\u5148\u547D\u4E2D\uFF08\u9F20\u6807\u60AC\u505C\u5BF9\u5E94\u5361\u7247\u770B\u539F\u56E0\uFF09`
|
|
@@ -2472,10 +2524,13 @@ ${state.proposal.trim()}
|
|
|
2472
2524
|
}
|
|
2473
2525
|
}, [h(IconPlusOutline16, { key: "i" }), h("span", { key: "t" }, busy ? "\u6B63\u5728\u65B0\u5EFA\u2026" : "\u65B0\u5EFA\u4EBA\u8BBE")])];
|
|
2474
2526
|
const status = state.error !== "" ? h("div", { className: "wsp-status wsp-status-err", key: "st", "aria-live": "polite" }, state.error) : state.status === "" ? null : h(Toast, {
|
|
2475
|
-
|
|
2527
|
+
// Keyed on the sequence, not on a constant: a repeated sentence has to mount a fresh
|
|
2528
|
+
// toast. With a fixed key React reuses the instance, its dismiss timer keeps running,
|
|
2529
|
+
// and the second flip of a switch produced no toast at all.
|
|
2530
|
+
key: `st-${String(state.statusSeq)}`,
|
|
2476
2531
|
text: state.status,
|
|
2477
2532
|
icon: h(IconCheckOutline14, {}),
|
|
2478
|
-
onDone:
|
|
2533
|
+
onDone: this.clearStatus
|
|
2479
2534
|
});
|
|
2480
2535
|
const history = state.history;
|
|
2481
2536
|
const historyModal = h(Modal, {
|
|
@@ -2580,7 +2635,7 @@ ${state.proposal.trim()}
|
|
|
2580
2635
|
key: "guard",
|
|
2581
2636
|
checked: prefs.guard !== false,
|
|
2582
2637
|
label: "\u7981\u6B62\u6A21\u578B\u6539\u5199\u4EBA\u8BBE\u6587\u4EF6",
|
|
2583
|
-
hint: "\
|
|
2638
|
+
hint: "\u6309\u80FD\u529B\u5224\u5B9A\uFF1A\u4EFB\u4F55\u4F1A\u5199\u5165\u6216\u5220\u9664\u4EBA\u8BBE\u5B58\u50A8\uFF08\u542B\u5B83\u7684\u5386\u53F2\u6587\u4EF6\u4E0E\u5907\u4EFD\u526F\u672C\uFF09\u7684\u5DE5\u5177\u8C03\u7528\u90FD\u4F1A\u88AB\u62D2\uFF0C\u4E0D\u770B\u5DE5\u5177\u53EB\u4EC0\u4E48\u540D\u5B57\uFF1B\u8BFB\u53D6\u3001\u641C\u7D22\u3001\u628A\u8DEF\u5F84\u5199\u8FDB\u7B14\u8BB0\u90FD\u4E0D\u53D7\u5F71\u54CD\u3002\u8FD9\u662F\u884C\u4E3A\u5C42\u9762\u7684\u9650\u5236\uFF0C\u4E0D\u662F\u5B89\u5168\u8FB9\u754C\uFF1A\u672C\u673A\u4E0A\u7684\u5176\u5B83\u7A0B\u5E8F\u4ECD\u53EF\u4FEE\u6539\u3002",
|
|
2584
2639
|
onChange: (next) => this.setPref({ guard: next })
|
|
2585
2640
|
}),
|
|
2586
2641
|
h(Switch, {
|
|
@@ -2772,13 +2827,16 @@ ${state.proposal.trim()}
|
|
|
2772
2827
|
])
|
|
2773
2828
|
])
|
|
2774
2829
|
]));
|
|
2775
|
-
return h("div", rootProps, [header, healthNote, prefsRow, transferRow, searchRow,
|
|
2830
|
+
return h("div", rootProps, [header, healthNote, prefsRow, transferRow, searchRow, conflictNotice, h("div", { className: "wsp-cards", key: "cards" }, cards), status, historyModal, confirmModal, versionsModal, importModal]);
|
|
2776
2831
|
}
|
|
2777
2832
|
}
|
|
2778
2833
|
const inject = ["slots", "remote", "connection"];
|
|
2779
2834
|
function apply(ctx) {
|
|
2780
2835
|
const style = injectStyles();
|
|
2781
|
-
ctx.effect(() => () =>
|
|
2836
|
+
ctx.effect(() => () => {
|
|
2837
|
+
style.remove();
|
|
2838
|
+
if (styleElement === style) styleElement = void 0;
|
|
2839
|
+
}, `${NS}: styles`);
|
|
2782
2840
|
const prefs = readPrefs();
|
|
2783
2841
|
if (prefs.showTab) ctx.slots.inject("conversation.view", () => ctx.slots.register({
|
|
2784
2842
|
name: "conversation.view",
|
package/lib/index.d.ts
CHANGED
|
@@ -52,11 +52,17 @@ export declare const name = "agent-persona";
|
|
|
52
52
|
* when the row loads, and a wrong value fails loudly instead of being ignored.
|
|
53
53
|
*/
|
|
54
54
|
export declare const Config: Schema<Schemastery.ObjectS<{
|
|
55
|
-
/** Where the personas live. Empty
|
|
55
|
+
/** Where the personas live. Empty selects the default store under the DSH state directory. */
|
|
56
56
|
storePath: Schema<string, string>;
|
|
57
|
+
/** Provider and model the AI-tuning button calls. Empty means whatever DSH is set to. */
|
|
58
|
+
tuneProvider: Schema<string, string>;
|
|
59
|
+
tuneModel: Schema<string, string>;
|
|
57
60
|
}>, Schemastery.ObjectT<{
|
|
58
|
-
/** Where the personas live. Empty
|
|
61
|
+
/** Where the personas live. Empty selects the default store under the DSH state directory. */
|
|
59
62
|
storePath: Schema<string, string>;
|
|
63
|
+
/** Provider and model the AI-tuning button calls. Empty means whatever DSH is set to. */
|
|
64
|
+
tuneProvider: Schema<string, string>;
|
|
65
|
+
tuneModel: Schema<string, string>;
|
|
60
66
|
}>>;
|
|
61
67
|
export declare const inject: string[];
|
|
62
68
|
/** Persona injection modes: append after the preset persona, or replace it. */
|
|
@@ -92,7 +98,7 @@ export declare const normalizePrefs: (raw: any) => {
|
|
|
92
98
|
/** Opt-in version history: off by default, because it writes a second file on every save. */
|
|
93
99
|
history: boolean;
|
|
94
100
|
};
|
|
95
|
-
/** Filename-safe local timestamp for a store backup. */
|
|
101
|
+
/** Filename-safe local timestamp for a store backup (milliseconds keep same-second backups apart). */
|
|
96
102
|
export declare const backupStamp: (now?: Date) => string;
|
|
97
103
|
/**
|
|
98
104
|
* Copy the store aside before it is overwritten for the first time after a failed load. An exception here
|
|
@@ -101,14 +107,14 @@ export declare const backupStamp: (now?: Date) => string;
|
|
|
101
107
|
export declare const backupStoreFile: (storePath: any, stamp: any) => string;
|
|
102
108
|
/**
|
|
103
109
|
* The store is this plugin's own instruction source, so a session running under a persona must not be able
|
|
104
|
-
* to rewrite or delete that persona. This
|
|
105
|
-
*
|
|
110
|
+
* to rewrite or delete that persona. This guard refuses any call that would write the store (or the history
|
|
111
|
+
* file beside it), decided by what the arguments resolve to rather than by which tools happen to exist.
|
|
106
112
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
113
|
+
* Honest limits, all deliberate: it constrains behaviour rather than enforcing security (a plugin edit, a
|
|
114
|
+
* manual edit, or a path assembled at runtime inside a script still get through), it reads only the strings
|
|
115
|
+
* a tool call carries, and a command line is analysed statically — no shell is run to expand variables.
|
|
110
116
|
*/
|
|
111
|
-
export declare const personaStoreGuardReason: (execution: any, storePath: any) => string;
|
|
117
|
+
export declare const personaStoreGuardReason: (execution: any, storePath: any, cwd: any) => string;
|
|
112
118
|
export declare const sanitizePersona: (text: any, warn?: () => void) => string;
|
|
113
119
|
/**
|
|
114
120
|
* Opt-in per-persona version history.
|