dsh-edit-turn 0.2.1 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +418 -62
- package/lib/client.js +641 -129
- package/lib/index.js +194 -64
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-edit-turn
|
|
2
2
|
|
|
3
|
-
**DeepSeek Harness 的「编辑某一轮」插件 ——
|
|
3
|
+
**DeepSeek Harness 的「编辑某一轮」插件 —— 点用户消息旁的编辑按钮,就地改写那一句。保存即生效:改后的内容成为模型后续读到的版本,这一条消息下面的对话原样不动。** 改写走官方 surface-replace 契约(一条替换事件同时完成"删旧 + 立新",被替换的内容从模型上下文里消失),**默认不重跑模型**。会话日志是 append-only 的,**原始字节一个都不改写**。
|
|
4
4
|
|
|
5
5
|
[中文](#中文) · [English](#english)
|
|
6
6
|
|
|
@@ -16,20 +16,21 @@ DSH 的会话日志是 append-only 的事件流:说错的提示词、问偏的
|
|
|
16
16
|
|
|
17
17
|
- 悬停任意一条**你自己发过的消息**,右侧出现编辑按钮;
|
|
18
18
|
- 点击后在该消息下方就地展开编辑器,预填原文;
|
|
19
|
-
-
|
|
20
|
-
-
|
|
19
|
+
- 保存后:**只有这一条消息被替换**——旧措辞从模型上下文里消失,改后的内容占住它的位置(因此后续每一轮读到的都是改后的版本);
|
|
20
|
+
- **它下面的助手回复、以及之后的全部对话,原样保留**,不重跑、不清空;
|
|
21
|
+
- 想让它立刻按改后的内容重新回答,把 `rerun` 打开即可。
|
|
21
22
|
|
|
22
23
|
### 特性
|
|
23
24
|
|
|
24
25
|
- **官方 seam,不改日志** —— 回退 = 追加一条带 `surfaceOp: { op: 'replace', startSeq, endSeq }` 的替换事件。原始事件全部留在会话文件里,只是不再进入 `deriveMessages()`。与官方 `/compact` 用的是同一套契约。
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
26
|
+
- **改写内容就是载体本身** —— 提示词编辑落下的是一条 `user/message` 替换事件,内容就是你改后的那句话。它既让模型读到你改后的版本,也在转录里显示成你自己的气泡;平台不会把它当成新输入去回答(这一点踩过坑:**追加**一条 user 消息会触发平台自动回答,越改越多)。
|
|
27
|
+
- **窗口最小化** —— 提示词编辑只遮蔽目标那一格(`shadowed = [target.seq]`);回复编辑才需要"到末尾"的窗口(回答变了,建立在它之上的一切都不再成立),因此助手消息(内含 tool_use)与它产生的 tool/result 永远一起走,**不可能留下悬空的调用/结果对**。
|
|
28
|
+
- **默认不重跑模型** —— 保存只写上下文,不产生任何模型调用。想"改完立刻重答",配置 `rerun: true`:那会走官方准入路径 `ctx.sessionController.prompt()`(它会自己 resume 冷会话,并恰好开一个新轮次)。
|
|
28
29
|
- **模型回答也能编辑** —— 回答无法被"替换":官方格式禁止 `assistant/message` 携带 `sourceEventSeqs`(已在真实校验器上验证)。做法是回退该回答及其后的内容,再**追加**一条带改写文本的助手消息——模型会把改写后的内容当成自己说过的话,对话可以继续。`source.editedBy` 会如实记录这段文字由插件写入。
|
|
29
|
-
-
|
|
30
|
+
- **改过的那条还能再改** —— 它仍然显示在原来的位置(由插件渲染成气泡),悬停就有编辑按钮。
|
|
31
|
+
- **一次点击即执行** —— 保存后不再有二次确认(`confirm: true` 可恢复两步确认,确认步里会写明这次保存会丢弃什么)。编辑器本身已经是用户主动打开的动作,保存的后果在 README 与确认步里说明,输入框本身保持干净。
|
|
30
32
|
- **中英双语 UI**,跟随 DSH 当前语言。
|
|
31
33
|
- **皮肤友好** —— 编辑器面板自带不透明表面(`--dshet-panel`)而不是借用主题的表面色变量。皮肤的本意就是让表面半透明、把插画透出来,而它只会给**自己的**元素补可读背景,插件类名不在其中;借用皮肤变量的面板会变成全透明,文字直接压在插画上。暗色分支走官方属性 `body[data-ds-dark-theme]`(与 `dsh-client-ui-theme` 及多个官方 UI 包一致),并用 `backdrop-filter` 与皮肤融合。
|
|
32
|
-
- **可配置载体**:万一某个 DSH 版本对空 system 节点处理不同,一行配置即可切回短标记载体。
|
|
33
34
|
- **宿主路由只限本机回环**,并校验 `Host` 与 `Origin`。
|
|
34
35
|
|
|
35
36
|
### 安装
|
|
@@ -49,12 +50,14 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
|
|
|
49
50
|
### 使用
|
|
50
51
|
|
|
51
52
|
1. 把鼠标移到你想改的那条**用户消息**上,点右侧的编辑图标(铅笔)。
|
|
52
|
-
2.
|
|
53
|
-
3.
|
|
53
|
+
2. 消息下方展开编辑器,原文已预填。改完点「**保存**」——**一次点击即执行**:旧措辞从模型上下文里移除,改后的内容占住它的位置,**不叫模型**。(配置 `rerun: true` 时按钮为「保存并重跑」。)
|
|
54
|
+
3. 编辑器收起,那条消息就地变成你改后的内容;它下面的回复与之后的对话**原样不动**。
|
|
55
|
+
|
|
56
|
+
编辑器是**平台输入框那样的一个圆角盒子**:输入区在上、取消与主按钮在右下,聚焦时整圈描边高亮;**随文字长高**(空的时候不留空白),没有标题栏、没有常驻说明、没有拖拽手柄。键盘也和输入框一致:**Enter 保存、Shift+Enter 换行、Esc 取消**(确认步里 Esc 是退回上一步)。**输入法优先**:组合中的 Enter 与"候选选词的 Enter"(`isComposing` 或 `keyCode 229`,平台自己的输入框也是这两条一起挡)都不会被当成保存,组合期间也不重排输入框——此前少挡了 229 那半,拼音选词按回车会直接保存/关闭,看起来就是"打不了中文"。
|
|
54
57
|
|
|
55
58
|
编辑器里如果提示「这条消息包含图片或文件附件」,说明改写只保留文字,附件会被丢弃。
|
|
56
59
|
|
|
57
|
-
|
|
60
|
+
**编辑模型的回答**:悬停任意一条模型回复,同样会出现编辑入口。改完点「保存」——这条回答被替换为你写的内容,它之后的内容一并移除,模型从此把你写的内容当成自己说过的话,对话可以继续下去。
|
|
58
61
|
|
|
59
62
|
注意:回答里的**工具调用与思考过程**无法保留(替换后只留文字,编辑器会提示),因为工具调用的结果已经不再成立。
|
|
60
63
|
|
|
@@ -65,25 +68,27 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
|
|
|
65
68
|
```yaml
|
|
66
69
|
- id: dsh-edit-turn
|
|
67
70
|
config:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
confirm: true # 默认 true:确认后才执行回退
|
|
71
|
+
confirm: false # 默认 false:一次点击即执行;置 true 才要求二次确认
|
|
72
|
+
rerun: false # 默认 false:保存只写上下文;置 true 则同时重跑该轮
|
|
71
73
|
```
|
|
72
74
|
|
|
73
75
|
| 字段 | 默认 | 含义 |
|
|
74
76
|
|---|---|---|
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
77
|
-
| `confirm` | `false` | 客户端保存后是否先要求一次确认。默认**关闭**(一次点击即执行:编辑器本身已是用户主动打开的动作,面板里也写明了保存的后果);设为 `true` 可恢复两步确认。 |
|
|
77
|
+
| `confirm` | `false` | 客户端保存后是否先要求一次确认。默认**关闭**(一次点击即执行:编辑器本身已是用户主动打开的动作;设为 `true` 时确认步里会写明这次保存会丢弃什么); |
|
|
78
|
+
| `rerun` | `false` | 保存后是否立刻重跑该轮。默认**关闭**:改后的内容进上下文即可,等你下次发消息时模型才读到它。设为 `true` 则保存即重跑(走官方 `sessionController.prompt()`)。 |
|
|
78
79
|
|
|
79
80
|
### 工作原理
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
一次编辑就是**一条官方替换事件**。读事件流(`sessionQuery.readSession`,回退到活动会话的 `snapshotEvents()`),用官方折叠规则算出当前 surface 顺序,定位目标消息的节点,然后:
|
|
83
|
+
|
|
84
|
+
- 提示词编辑:窗口 = **仅那一格**(`[target.seq]`),载体就是改后的文本;
|
|
85
|
+
- 回复编辑:窗口 = `surface[目标下标 .. 末尾]`(回答变了,建立在它之上的一切都不再成立);先在日志里**开一个新轮次**(`turn/start` + `step/start`),载体是空 `developer/message`(空内容不投影给模型),随后把改写文本作为一条助手消息追加进这个轮次,再 `step/end` + `turn/end` 收尾。
|
|
82
86
|
|
|
83
|
-
|
|
87
|
+
追加一条:
|
|
84
88
|
|
|
85
89
|
```js
|
|
86
|
-
|
|
90
|
+
// 回复编辑的形状;提示词编辑的载体就是改后的那条 user/message
|
|
91
|
+
session.append('developer/message', { turn, step, message: { role: 'developer', content: [] } }, {
|
|
87
92
|
surfaceOp: { op: 'replace', startSeq, endSeq },
|
|
88
93
|
sourceEventSeqs: shadowed, // 完整覆盖:官方校验要求列出全部被遮蔽的节点
|
|
89
94
|
})
|
|
@@ -91,7 +96,7 @@ session.append('system/message', { turn, step, message: { role: 'system', conten
|
|
|
91
96
|
|
|
92
97
|
落盘后等一次持久化检查点(`sessions.flush`),保证重启也能看到。
|
|
93
98
|
|
|
94
|
-
|
|
99
|
+
**第二步(可选):重跑。** 默认不做。`rerun: true` 时:
|
|
95
100
|
|
|
96
101
|
```js
|
|
97
102
|
await ctx.sessionController.prompt({ requestId, sessionId, mode: 'queue', content: [{ type: 'text', text: 新内容 }] })
|
|
@@ -103,11 +108,24 @@ await ctx.sessionController.prompt({ requestId, sessionId, mode: 'queue', conten
|
|
|
103
108
|
|
|
104
109
|
| 类型 | 名称 | 说明 |
|
|
105
110
|
|---|---|---|
|
|
106
|
-
| 路由 | `GET /dsh-edit-turn/state?sessionId=` |
|
|
107
|
-
| 路由 | `POST /dsh-edit-turn/apply` | `{ sessionId, seq \| messageId \| turn, text }` →
|
|
111
|
+
| 路由 | `GET /dsh-edit-turn/state?sessionId=` | 可编辑轮次、已遮蔽行账本(`hidden` + `revisions`)、surface、忙碌状态 |
|
|
112
|
+
| 路由 | `POST /dsh-edit-turn/apply` | `{ sessionId, seq \| messageId \| turn, text }` → 写入改后内容(`rerun: true` 时另加重跑);响应含 `applied` / `reran` / `shadowed` / `replacementSeq` |
|
|
108
113
|
| 工具 | `edit_turn_targets` | 只读:列出该会话当前可编辑的轮次与原文(供 agent 自查) |
|
|
109
114
|
| 前端 | `conversation.input.overlay` | 每会话控制器:编辑入口、就地编辑器、被遮蔽行的隐藏账本 |
|
|
110
115
|
|
|
116
|
+
### 跨插件契约(给兄弟插件)
|
|
117
|
+
|
|
118
|
+
转录只为 **append surface 事件**建行(ui-chat 的 user/assistant 定义都以 `isAppendSurfaceEvent` 为 match 条件),所以一次就地编辑之后,**那一行永远锚在原来的 seq / messageId 上**——行里没有任何字段告诉别的插件"我现在在 seq N",而模型上下文里的活节点是替换事件。任何按 surface 校验的入口(例如兄弟插件 `dsh-delete-turn` 的删除按钮)只看原 seq 就已经死了,是否继续提供入口需要沿替换链找到活节点。为此外部消费者可以依赖:
|
|
119
|
+
|
|
120
|
+
1. **prompt 编辑保持单节点窗口**:`planRollback` 的 `mode === 'prompt'` 只遮蔽目标那一格(`shadowed = [target.seq]`),不会顺手回退到末尾;
|
|
121
|
+
2. **替换事件与目标同事件类型**:改 `user/message` 落下的就是 `user/message`(回复编辑不换类型就换不了位,见第 5 条);
|
|
122
|
+
3. **`sourceEventSeqs` 永远列全窗口**:官方校验要求完整覆盖,缺一个节点事件会被拒;
|
|
123
|
+
4. **每个替换都带语义标记**:`source.kind === 'plugin:dsh-edit-turn'` 且 `source.editedBy === 'dsh-edit-turn'`——包括那个不含文本的空 `developer/message` 载体,兄弟插件按 `editedBy` 识别即可,不必靠推断窗口形状;
|
|
124
|
+
5. **回复编辑 = 多节点回退 + 追加新回复**,不是就地替换:旧回复行没有入口是**设计如此**(新行自带入口)。不要为了保住旧行的入口往 `assistant/message` 上加 `sourceEventSeqs`——官方校验会直接拒;
|
|
125
|
+
6. **映射已公开**:`GET /dsh-edit-turn/state` 的 `revisions[]` 给出 `{ replacementSeq, startSeq, endSeq, shadowed }`(字段名与 `POST /apply` 响应一致),`hidden[]` 每题一格列出 `{ seq, turn, replacement }`。跨插件不必再从事件流自行推导。
|
|
126
|
+
|
|
127
|
+
**`source.kind` 的行为(有意为之)**:prompt 编辑落地的那条 `user/message` 的 `source.kind` 是 `plugin:dsh-edit-turn`,**不是 `user`**。因此凡是以 `source.kind === 'user'` 识别"人类提问"的消费者(例如按人类提问划分回复删除窗口的逻辑),会把这条改写后的提问当作**普通内容**。这是刻意的:它是一段插件写入的文本,不是新的用户发话,平台也就不该再回答它;需要识别它的消费者请用上面的 `revisions`/`editedBy`,不要放宽 `source.kind === 'user'` 的判定。
|
|
128
|
+
|
|
111
129
|
### 验证状态
|
|
112
130
|
|
|
113
131
|
本插件在 DSH `0.1.6-alpha.2` 与 `0.1.7-rc.1` 上完成以下验证:
|
|
@@ -116,22 +134,31 @@ await ctx.sessionController.prompt({ requestId, sessionId, mode: 'queue', conten
|
|
|
116
134
|
|---|---|---|
|
|
117
135
|
| 运行中的实例是否真的挂载了本插件(只读路由守卫探针,无需 token) | `npm run probe:loaded [端口]` | 通过:`/state` 返 400、`/apply` 返 405 —— 这两个状态码只有本插件会返回 |
|
|
118
136
|
| 浏览器半部到底问了什么、拿回了什么(只读诊断,含失败记录) | `GET /dsh-edit-turn/debug` | 通过:真实浏览器里确认客户端确实拿到 replies;并复现了旧 v3 会话的 session-not-found |
|
|
119
|
-
| 官方 append
|
|
120
|
-
| 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` |
|
|
137
|
+
| 官方 append **与读取**契约(真实校验器,进程内) | `npm run verify:contract` | 80 项通过(每段写入后都用加载器重读整份日志):替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 developer 载体不产生模型消息、**连续两次回退都被接受**、**编辑模型回答的完整机制被接受**、**跨插件依赖的不变量(单节点窗口 / 同事件类型 / 全窗口 sourceEventSeqs / 每个替换都带 `editedBy` 标记 / 改写后的 `source.kind` 不是 `user` / 修正回复不带 `sourceEventSeqs`)** |
|
|
138
|
+
| 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 105 项通过 |
|
|
121
139
|
| 客户端半部静态检查(注册、i18n 完整性、样式、皮肤可读性、版本三处同步、线协议) | `npm run verify:client` | 全部通过 |
|
|
122
140
|
| 实机前端产物校验(运行中的 DSH 是否在下发当前代码) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | 全部通过(含**宿主节点形状锚点**) |
|
|
141
|
+
| **真实浏览器渲染冒烟**(CDP 驱动已开着的标签页,只读) | `npm run verify:ui` | 全部通过:下发的字节就是刚改的字节、无槽位崩溃留下的空占位、回复铅笔在平台动作条里、被回滚的行保住动作条、**被回滚的用户行一定显示替代文本(消息不会凭空消失)**、**替代气泡一定排在时间/复制之上(顺序与正常消息一致)**、**改写后那颗铅笔一定在还活着的动作条末尾(已在原始会话、切走的会话、切回后三处现场确认)**、**回复铅笔一定排在动作条最左(复制之前,已现场确认)**、气泡上没有 `.dshet-floating` 压着文字、切走再切回入口仍在 |
|
|
123
142
|
| 真实 profile 安装 / 补丁合成 / 启动 / 工具契约 / 路由守卫 | `npm run verify:profile` | 全部通过 |
|
|
124
143
|
| **真实浏览器端到端**(agent-browser 驱动 Chrome 打开运行实例) | 手动 | 已完成:模型回答行出现「编辑这条回答」、点开编辑器预填真实回复原文、取消后网络层 0 个 apply 请求。截图见 `docs/reply-editor-in-browser.png` |
|
|
125
144
|
|
|
126
145
|
`verify:live` 针对**正在运行的实例**:用 DSH 启动时打印的 token 换取鉴权 cookie,读启动页里的客户端模块组,把含本插件的那一组下载下来,断言插件自己的标记确实在其中。它证明的是「浏览器刷新后会拿到当前代码」,而不是「源码看起来没问题」——前端改动后这是唯一能确认已生效的自动手段。
|
|
127
146
|
|
|
147
|
+
`verify:ui` 直接驱动一个**已经开着**的 Chrome 标签页(`--remote-debugging-port`,默认 9333):先缓存击穿地重载,断言浏览器此刻拿到的字节就是刚改的字节,再读 DOM 与 console——每个 assistant 单元格都渲染出了东西(没有被 `abdicate` 留下的空占位)、回复铅笔落在平台自己的动作条里、被回滚的行丢掉消息但保住时间/复制/删除、**并且每一条被折叠的用户消息都立着本插件的替代气泡**(这条正是「保存后消息不见了」的现场判据:行可以折叠,但不能折叠到无处显示替代文本)、**而且这颗气泡排在时间/复制的上方**(顺序与平台画一条正常消息时一致,否则时间戳看起来像属于下一条)、**改写后那颗铅笔排在还活着的那条动作条的末尾、气泡里不再留笔**(把 message 文本那一层留给文字,铅笔跟着行本身走)、**回复铅笔排在动作条最左、压在平台自己画的复制按钮之前**(那一格是 `display:contents`,DOM 序只能排第二,靠 flex `order` 才能争到第一位,现场按布局算 x 而不是数节点)、气泡上没有 `.dshet-floating` 压着文字、切走再切回会话入口仍在。它**只读**:从不打开编辑器、从不点保存、从不发 `/apply`。(这两条铅笔落位的断言已在真机重载后跑通,三处视图——原始会话、切走的会话、切回后——都确认了。)
|
|
148
|
+
|
|
128
149
|
`verify:profile` 会另起独立端口 + 独立 `DSH_HOME` 的沙箱实例,**只按 pid 结束自己启动的进程**,绝不触碰你正在用的 DSH。也可以手动指定:
|
|
129
150
|
|
|
130
151
|
```sh
|
|
131
152
|
DSH_BIN=/path/to/dsh/lib/bin.js PORT=4123 DSH_HOME=/tmp/dsh-edit-turn-home bash tools/verify-dsh-plugin.sh
|
|
132
153
|
```
|
|
133
154
|
|
|
134
|
-
|
|
155
|
+
**尚未验证的一环(诚实说明)**:`verify:ui` 覆盖的是「渲染成什么样」(只读),**真正按下保存**会发 `/apply` 并回退会话——这一步刻意留给手动端到端(见上表最后一行与 `docs/reply-editor-in-browser.png`),冒烟脚本永远不碰。另外本机没有 React / Playwright,所以 `verify:ui` 走的是裸 CDP 而不是框架。但纯点击路径本身已被自动覆盖:`test/client.dom.test.js` 用一个可读的 DOM 实现把真实的 `OverlayEntry` 跑起来,断言「点动作 → 编辑器出现并预填 → 点保存 → 进入确认步骤 → 点确认 → 发出正确请求 → 编辑器关闭」以及各失败分支的文案。这个测试是有意义的:把重绘标记改回旧写法时,其中 6 项会失败。
|
|
156
|
+
|
|
157
|
+
**「按下保存之后那一屏」同样已被覆盖**,因为这里出过一次真事故:保存成功后旧消息行被折叠,而替身气泡从未建起,整条消息在界面上消失(截图见 `docs/`,服务端状态其实早已回退成功)。三个用例分别钉住它的一半成因——①保存响应一到,替代气泡必须立刻出现(把紧随其后的 `/state` 卡住不返回也得过,证明不靠刷新兜底)②保存必须重新向宿主要一次 `/state` ③有替换记录却还没有替换文本时,行不得折叠。把 `lib/client.js` 里对应的三处修复逐一还原,这三个用例各自**恰好失败一次**,恢复后当时 94 项全绿(现 105 项)。
|
|
158
|
+
|
|
159
|
+
**「气泡排在时间/复制上面」同样已被覆盖**:替代气泡原先插在被折叠行的**后面**,行里剩下的时间戳与复制按钮于是排到了改写文本的上方,看起来像属于下一条消息。现在气泡插进行内、动作条**之前**(`placeRevisionBubble`),同时 `collapseRowContent` 的豁免名单加上它自己——气泡一旦进到行里,清空行内容的那一步就必须放过它;而没有动作条可留的行会整行消失,此时气泡改插到行**前面**,否则会跟着行一起被藏起来。两个用例分别钉住这两种情形(94 → 95 项):把定位那一处改回「插在行后」,恰好 4 个用例同时失败。`verify:ui` 加了现场断言「气泡必须在动作条之上」,静态检查加 2 条,`verify:live` 加 1 个下发标记。
|
|
160
|
+
|
|
161
|
+
**「两支铅笔该落在哪儿」同样已被覆盖**,这是按你给的规范改的:用户行那颗笔排在**动作条的最右边**(时间、复制、以及兄弟插件后插进来的按钮之后),回复行那颗笔排在**动作条的最左边**(复制按钮之前)。两处都不是"换个插入点"能办到的,各自卡在一个宿主限制上:折叠行原先把笔塞进替代气泡的字外留白里(要悬停改写文本才看得见,也够不着"复制");回复笔受宿主注册的 slot 约束,DOM 上必然排在平台自己画的复制按钮之后。现在的做法是:①`renderRevision` 把改写后的笔**交给那条活下来的动作条**(`injectRowAction(row, editTarget, …)`),同时把它从气泡里摘掉,只有行彻底没有条可留时才退回气泡字外留白;`applyDom` 在行可见时**只在没有替代气泡站位时**才拆掉行上的笔——否则会把上一行刚放进去的笔拆走。②回复笔靠新增的 `.dshet-reply-action` 加 flex `order:-1` 冲到最左,因为那一格宿主是 `display:contents`,DOM 序永远只能第二,只有顺序属性改得动;用户笔同理——兄弟插件(`dsh-delete-turn`)会在任意时刻把垃圾桶**追加**进同一条动作条,DOM 插入序保不住右端,所以 `.dshet-action-host` 带 `order:9`,无论谁后插都在它左边。颜色也一并对齐平台:`.dshet-action` 改用平台的 `--dsw-alias-label-tertiary`(悬停 `--dsw-alias-label-primary`,留一个等于默认主题取值的兜底),不再用自己的蓝灰墨色——此前它比旁边的复制/垃圾桶更蓝,看起来像另一个控件;实测两者计算色都是 `rgb(129, 133, 140)`。测试上:原"铅笔在字外留白"的用例改写为「铅笔并进那条活下来的条的末尾、气泡里不再留笔」,无条用例补上"留白里仍有笔",回复 entry 用例补 class 断言;静态检查加 4 条钉住这一批写法(含「气泡按内容收窄」),另加 1 条钉住 `order:9`,`verify:live` 加 2 个下发标记;`verify:ui` 的「行末尾的笔」断言改成**按布局量右边缘**(DOM 序不算数)并已在真机重载后跑通——现场那个用户条是 `时间 → 复制 → 垃圾桶 → 笔`,笔在最右(原始会话/切走的会话/切回后三处确认)。三处新写法各做过反向复现:把「笔交给动作条」「`.dshet-reply-action` class」「`applyDom` 的站位守卫」逐一还原,各有一个用例失败(守卫那处失败在「笔并进条末尾」用例上——它恰好也钉住了"刚放好又被拆走"这条回归)。当前 `npm run check` **105 项** + 契约 + 静态全绿。
|
|
135
162
|
|
|
136
163
|
**开发前置**:`npm test` 与两个 `verify:*` 需要 `@deepseek-ai/dsh-session` 与 `@deepseek-ai/dsh-tools` 可解析。本插件自身不依赖它们(宿主半部只 import `schemastery` 与 `dsh-tools`),测试需要一个装了 DSH 的 `node_modules`:
|
|
137
164
|
|
|
@@ -143,8 +170,8 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
|
|
|
143
170
|
|
|
144
171
|
- 可以编辑**你自己输入的消息**与**模型的回答**;注入的上下文行与系统提示词没有编辑入口。
|
|
145
172
|
- 编辑一条回答会把它替换为**纯文本**:该回答里的工具调用与思考过程会被移除(编辑器会提示),因为它们的结果已不再成立。
|
|
146
|
-
-
|
|
147
|
-
-
|
|
173
|
+
- 编辑一条用户消息**只替换那一条**,**默认不重跑模型**(`rerun: true` 才会保存后立刻重跑);编辑一条模型回答只替换内容,同样不问模型。
|
|
174
|
+
- **提示词编辑只动那一条**:它下面的回复与之后的对话**原样保留**。**回复编辑**才会移除该回答及其后的内容——回答变了,建立在它之上的一切都不再成立。想保留原文形成分支,需要走 `sessionController.fork({ sessionId, atSeq })`,尚未实现。
|
|
148
175
|
- **只改写文本**。消息里含图片/文件附件时,改写后只保留文字(编辑器会提示)。
|
|
149
176
|
- **会话必须当前在 DSH 中打开**,否则返回 `409 session-not-active`。
|
|
150
177
|
- **进行中拒绝编辑**:未闭合的轮次或正在压缩时返回 `409 busy`。
|
|
@@ -152,8 +179,84 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
|
|
|
152
179
|
- 回退后**系统提示词保持不变**(窗口永不包含 surface 节点 0,该节点也永不可编辑)。
|
|
153
180
|
- **旧会话(v3 格式日志)在 DSH 0.1.7 下读不到**:DSH 的会话读取层对这类日志返回 `session-not-found`,本插件因此在其中完全不工作(表现为没有编辑入口)。新写的会话是 v4,正常。
|
|
154
181
|
|
|
182
|
+
### 排查:编辑入口整个不见了
|
|
183
|
+
|
|
184
|
+
先分清是"插件没加载"还是"加载了但没渲染"——两者的现场完全不同,别猜。
|
|
185
|
+
|
|
186
|
+
1. **插件加载了吗?** 在插件目录跑 `npm run probe:loaded [端口]`(默认 3080)。返回 `400`/`405` 说明宿主半部在跑;返回 `404` 说明插件根本没加载。
|
|
187
|
+
2. **没加载时看宿主启动输出**,找这一行:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
dsh-edit-turn (dsh-edit-turn): failed to import
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
这行的原因几乎总是**依赖解析失败**,最常见的是:插件以 `link:` 方式安装(开发目录直接挂进 profile),而插件目录的 `node_modules` 是个指向某处 npx 缓存的软链——缓存被 `npx` 清理后软链变死链,静态 `import` 直接抛错,整个插件消失。修法是在插件目录里真实安装依赖:
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
cd <插件目录> && rm -f node_modules && npm install --omit=dev --legacy-peer-deps
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
注意区分两类依赖:宿主提供的 **peer**(`dsh-tools`、`dsh-settings`)由 DSH 加载器解析,可以不装;**普通 dependencies**(如 `schemastery`)必须能解析到,加载器不会替你找。
|
|
200
|
+
3. **加载了但没入口时**,用只读诊断路由看浏览器半部到底问了什么:
|
|
201
|
+
|
|
202
|
+
```sh
|
|
203
|
+
curl "http://127.0.0.1:<端口>/dsh-edit-turn/debug"
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
记录里有 `state` 且 `replies` 大于 0 → 数据到了客户端,问题在渲染;**一条记录都没有** → 客户端半部没跑起来(看浏览器控制台)。
|
|
207
|
+
|
|
208
|
+
4. **「历史加载失败 / 某个会话读不出来」**(`session-not-found`,控制台里出现 `SessionFormatError`):这可能是 0.2.0–0.2.3 的回复编辑留下的坏日志。用仓库里的修复工具扫描并用官方加载器验证、备份后截断:
|
|
209
|
+
|
|
210
|
+
```sh
|
|
211
|
+
node tools/repair-session.mjs ~/.dsh/sessions/<项目目录> --dry-run
|
|
212
|
+
node tools/repair-session.mjs ~/.dsh/sessions/<项目目录>
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
截断前的内容全部留在同目录的 `*.corrupt-*.bak` 里;修复后重启 DSH(或桌面版)即可重新打开该会话。
|
|
216
|
+
|
|
217
|
+
**「点了保存没反应」先看这里**:记录里有没有 `apply`。没有 → 点击丢在浏览器半边(此前的成因是宿主在 mousedown/mouseup 之间重建了行,按钮已改为 pointerdown 激活);有 `apply` 但 `ok:false` → 宿主拒绝了,`code` 就是原因(`busy`/`stale`/...)。
|
|
218
|
+
|
|
155
219
|
### 更新日志
|
|
156
220
|
|
|
221
|
+
**0.2.5** —— 修「每保存一次,模型的回答往下掉一格」。
|
|
222
|
+
|
|
223
|
+
- **修复:被回滚的「轮次尾巴」没有隐藏,堆成了一列空条**。回复编辑会留下旧轮次的尾巴(用时/用量/动作条)——它不是消息行,属于它的消息已经被回滚,尾巴上没有任何可操作的东西。此前我们按"消息行"的规则给它保留了动作条(那条规则本是为了让被回滚的用户消息还能复制/删除),于是**每保存一次就多一条空条**,把对话一格格顶下去。现在按 `data-chat-flow-kind="turn-tail"` 识别:尾巴整条隐藏(并清理旧版本给它打过的 `data-dshet-keep-actions`),消息行照旧保留动作条。新增用例「被回滚的轮次尾巴整条消失,而不是叠一条空条」+ 2 条静态检查;`verify:ui` 的现场断言改为三条事实——轮次尾巴整条隐藏、没有动作条的行整条隐藏、有动作条的消息行必须保住动作条(真机 6 条孤儿尾巴全部按新规则消失)。
|
|
224
|
+
- 补一句 0.2.4 的发布说明:`verify:ui` 与 `verify:live` 在 0.2.4 上均已通过;本缺陷是发布后由使用现场发现的。
|
|
225
|
+
|
|
226
|
+
**0.2.4** —— 修一个**会把会话写坏**的致命缺陷(回复编辑的写入非法),并把「读取路径校验」补进契约测试。
|
|
227
|
+
|
|
228
|
+
- **修复(致命):回复编辑会把整份会话写到读不出来**。旧写法把两样非法内容写进日志:①静默载体是一个空的 `system/message`,而格式**只允许 system-prompt 来源的 system 消息**;②修正消息带着**已经关闭的**原轮次 turn/step 追加。追加是宽松的(当场不报错),但**读取**会拒绝:DSH 从此对该会话返回 `session-not-found`,「历史加载失败:stored log is corrupt: SessionFormatError: system/message does not match an open turn and step」。现场表现就是那个会话里所有编辑入口消失、历史打不开。现在:回复编辑先在日志里**开一个新轮次**(`turn/start` + `step/start`),静默载体改为**空 `developer/message`**(空内容不投影给模型、替换不进转录、格式合法),修正消息与 `step/end`/`turn/end` 都在这个轮次内收尾;运行时从 `turn/start` 事件投影 `lastTurn`,所以它下一轮用 N+2,序号一致。
|
|
229
|
+
- **契约测试补上「读取路径」这一层**:以前只验 `append` 被接受,现在每一段写入之后都用与现代码同一套加载器(`Session.create` + 消息投影)**重新加载整个日志**,并模拟"回复编辑之后的下一个真实轮次",任何非法的轮次/来源/形状都会在这里失败——这个校验如果早有,上面的事故就不会发生(契约 73 → 80 项)。
|
|
230
|
+
- **附带产出 `tools/repair-session.mjs`**:扫描一个 `sessions` 目录,用同一套加载器找出被写坏的日志,**先备份**再在第一个非法事件处截断(日志要求 seq 从 0 连续,坏事件无法单独摘除;实践中被截掉的正好都是本插件的坏"回复编辑"组,真实对话保留)。本机 4 个受影响会话都已按此法修复,原文件留在同目录 `*.corrupt-*.bak`。
|
|
231
|
+
- 其余为 0.2.2/0.2.3 累积的浏览器半部修复(见下),一并随本版发布到 npm。
|
|
232
|
+
|
|
233
|
+
**0.2.3** —— 修浏览器半部的一批界面缺陷,并让它们以后能被自动验证。
|
|
234
|
+
|
|
235
|
+
- **修复:保存改写后整条消息从界面上消失(最严重的一处)**。保存成功的乐观更新只记下了「哪些行被回退」,既没记「替代文本现在哪一条 seq 上」,也没把它放进可编辑集合,替身气泡因取不到文本而从未建起;紧接着的折叠照常执行,行就空了——而行数没变,客户端又不会重新拉 `/state`,于是这一屏永远卡在乐观态(服务端其实早已回退成功)。现在保存响应里的 `replacementSeq` / `shadowed` 会补齐映射与条目、保存后必定重新拉一次 `/state`(在途请求排队而非被复用),并加了一条不变量:**替代文本没画出来之前,用户消息那行不许折叠**(最坏情况是多显示一会儿旧原文,而不是什么都不显示)。
|
|
236
|
+
|
|
237
|
+
- **修复:回复编辑笔根本不渲染**。条目用 `jsx(PencilIcon, null)` 交给宿主,而这个宿主的 `jsx` 读 `config.key` 前不判空——一抛错,槽位就把这条记录 `abdicate`(本页生命周期内永久除名),该 assistant 单元格渲染成一个空的 `data-slot-error` 占位,界面上只是"没有笔",没有任何报错。现在组件对缺失/为 `null` 的 props 全程防御(缺什么就只是不出这一颗笔),图标带配置对象渲染;崩溃现场与"条目被除名"的推断都写进了 `tools/verify-live-ui.mjs` 与 `test/client.dom.test.js`。
|
|
238
|
+
- **修复:被回滚的那行把平台自己的动作栏一起藏了**。此前 `setRowHidden` 对整行 `display:none`,时间戳、复制、删除(以及姊妹插件的入口)随之消失。现在行只收起消息内容,动作栏照常保留(`data-dshet-keep-actions`),样式表用 `:not([data-dshet-keep-actions])` 排除这类行。
|
|
239
|
+
- **修复:改写气泡里的编辑笔压住文字**。笔原本绝对定位在 60×40 的气泡内部并 `top:50%` 居中。现在笔移到字外的 gutter(`top:6px`),气泡用 `padding-inline-end` 把位置留出来。**(0.2.3 后期:这条留白退成兜底——行还有动作条时,笔改并进那条条里,见下一条。)**
|
|
240
|
+
- **修复:时间与复制条跑到了替代气泡头上**。替代气泡原先插在被折叠行的**后面**,行里剩下的时间戳与复制于是排在了改写文本的上方,读起来像属于下一条消息。现在气泡插进行内、动作条**之前**(`placeRevisionBubble`:幂等,仅在位置不对时才移动),并把 `collapseRowContent` 的豁免名单加上 `.dshet-revision`——气泡进了行,清空行内容的那一步就必须放过它;没有动作条可留的行会整行消失,此时气泡改插到行**前面**,消息照样可见(新增用例钉住这条)。气泡与动作条的间距对齐平台自己的 `gap:6px`。
|
|
241
|
+
- **修复:两支编辑笔不在该在的地方**。按规范——用户行那颗笔要排在**动作条最右**(时间、复制之后),回复行那颗笔要排在**动作条最左**(复制之前)。旧实现两处都不满足,且各卡在一个宿主限制上:折叠行把改写后的笔塞进替代气泡的字外留白(要悬停改写文本才可见,也够不着"复制");回复笔走宿主插槽注册,DOM 序必然排在平台自己画的复制按钮之后。现在 ①`renderRevision` 把笔交给那条活下来的动作条(`injectRowAction(row, editTarget, …)`)并同时从气泡里摘掉(`removeRowAction(bubble)`),无条可留才退回留白(`.dshet-revision-action` 成为纯兜底);`applyDom` 在行可见时**只有行前没有替代气泡站位**才拆行上的笔,否则会把刚放进去的笔拆走。②回复笔加 `.dshet-reply-action` + flex `order:-1` 冲到最左——那一格宿主是 `display:contents`,DOM 序只能第二,只有顺序属性改得动。另修替代气泡被撑成整行宽的药丸(挪进行内后 `align-self` 失效,补 `width:fit-content`)。
|
|
242
|
+
- **修复:用户笔被兄弟插件的按钮挤下最右**。`dsh-delete-turn` 的垃圾桶会在它自己的 pass 里 `appendChild` 到同一条动作条,落点在我们的笔之后(现场:`时间 → 复制 → 笔 → 垃圾桶`),而两边都不会再移动已有的节点,DOM 插入序永远保不住右端。现在 `.dshet-action-host` 带 flex `order:9`(该条是 `display:flex`,平台项都是 `order:0`;留白/浮动两处不是 flex 项,不受影响)——现场已确认变成 `时间 → 复制 → 垃圾桶 → 笔`。真机断言同步改成**按布局量右边缘**,不再数节点顺序;静态检查加 1 条钉住 `order:9`。
|
|
243
|
+
- **修复:笔的颜色和平台的图标不一样**。`.dshet-action` 原用本插件自己的蓝灰墨色(`--dshet-ink-dim`,`#4b5872`),在平台的中性灰图标(`--dsw-alias-label-tertiary`,`#81858c`)旁边显得像「选中/可用」状态。现在改用平台同一个变量(悬停用 `--dsw-alias-label-primary`,兜底值取默认主题的实测值),实测铅笔与复制按钮的计算色完全一致;静态检查加 2 条钉住这两个变量。
|
|
244
|
+
- **修复:插件依赖解析失败会让功能整体消失**。插件目录的 `node_modules` 若是指向某处 npx 缓存的软链,缓存被清理后静态 `import` 抛错,宿主只打印一行 `failed to import`,界面上毫无痕迹。现在解析失败会降级为"没有设置表单",插件照样加载。
|
|
245
|
+
- **修复:编辑笔的位置**。回复行的编辑笔此前排在时间戳之后(行的收尾信息之后),随后改为落在平台动作图标里;最终按规范定在**回复动作条最左**(见上一条),用户行的笔则落在**其动作条最右**。
|
|
246
|
+
- 新增:`npm run verify:ui`——真机渲染冒烟(裸 CDP 驱动已开的标签页,只读):缓存击穿重载、断言下发字节即当前代码、读 DOM 与 console 断言四个缺陷的表现,切走再切回会话验证入口还在。
|
|
247
|
+
- 补强:`test/client.dom.test.js` 的 `jsx` 桩复刻宿主契约(`config` 为 `null` 直接抛),并新增平台真实行结构 fixture;`verify:live` 修好启动页模块组 href 的相对路径解析,锚点改为在**所有**模块组里找宿主节点形状;静态检查加了隐藏规则与 gutter 的断言。
|
|
248
|
+
- 补强:新增三个「按完保存那一屏」用例(替代气泡立即出现 / 保存后重拉 `/state` / 缺替代文本时不得折叠,91 → 94 项),并把 `lib/client.js` 的三处修复逐一还原做过反向复现——每个用例在没有自己那处修复时恰好失败一次;`verify:ui` 加断言「被折叠的用户行旁边必须有替代气泡」,静态检查加 4 条(映射、刷新、在途排队、折叠不变量),`verify:live` 加 2 个下发标记。
|
|
249
|
+
- 加固:`verify:ui` 读宿主 `/state` 的那一步自带 8 秒兜底——大会话(数千事件)会让宿主答上十几秒,此前这会把整轮冒烟拖成 `evaluate timed out`;现在降级为「分母未知」并继续跑。往返断言的分母也统一成别处一直在用的 `turns - 回退数`:替换载体在宿主侧算可编辑,但平台不为它渲染行,本就没有行内铅笔可言。
|
|
250
|
+
- 补强:两支笔的落位各配了真宿主 DOM 用例——原「铅笔在字外留白」的用例改写为「铅笔并进那条活下来的条的末尾、气泡里不再留笔」,无条用例补上「留白里仍有笔」,回复 entry 用例补 `.dshet-reply-action` class 断言;静态检查加 4 条(`injectRowAction(row, editTarget, …)`、气泡笔的摘除、`order:-1` 规则与按钮上的 class、气泡按内容收窄),`verify:live` 加 2 个下发标记,`verify:ui` 加 2 条现场断言(行末尾的笔、回复笔按布局算 x 是否最左)与气泡「零支笔」的改写断言——**已在真机重载后跑通**;三处新写法也各做过反向复现(还原后各有一个用例失败,其中 `applyDom` 守卫那处失败在「笔并进条末尾」用例上,它同时钉住"刚放好又被拆走"这条回归)。当前 `npm run check` 105 项 + 契约 + 静态全绿。
|
|
251
|
+
- 新增(跨插件契约):按兄弟插件 `dsh-delete-turn` 回传的核对结果,把它声明依赖的形状钉进真实校验器并公开——①prompt 编辑保持**单节点窗口**(只遮蔽目标那一格,不回退到末尾)②替换事件与目标**同事件类型**③`sourceEventSeqs` **全窗口**覆盖④**每一个**替换都带语义标记(`source.kind = plugin:dsh-edit-turn` + `source.editedBy`),包括不含文本的空 `developer/message` 载体⑤回复编辑仍是**多节点回退 + 追加修正**,不往 `assistant/message` 挂 `sourceEventSeqs`(官方校验会拒)。契约测试新增 7 条(6 节 +1、新 8b 节 +5、9 节 +1,共 73 项);`GET /state` 新增 `revisions[]`(`{ replacementSeq, startSeq, endSeq, shadowed }`,与 apply 响应同名)并扩了 host 用例;README 增「跨插件契约」一节,明确改写后那条 `user/message` 的 `source.kind` 是 `plugin:dsh-edit-turn` 而不是 `user`(**有意为之**:它是插件写入的文本、平台不该再回答;按 `source.kind === 'user'` 识别"人类提问"的消费者请改用 `revisions`/`editedBy`,别放宽这个判定)。
|
|
252
|
+
- **修复:编辑器的保存按钮「点不动」**。宿主拥有行,流式回复时它会不断重建行——一个按钮如果在 mousedown 与 mouseup 之间被换掉,click 事件根本不会触发,而从外部看就是「点了没反应」,且状态里看不出任何痕迹。现在编辑器的取消/保存、以及行内铅笔都改走 **pointerdown**(按下即生效),键盘仍走 click;同一个按钮只接线一次并在同一次按压后**吞掉随之而来的 click**,避免重复执行。两条新用例分别钉住「按下即开、后续 click 不重置草稿」与「按下即前进到确认步」;静态检查加 3 条。另:回复编辑的按钮文案由「保存替换」改为「保存」(中英一致)。
|
|
253
|
+
- **修复:编辑器不像个输入框**。文本框固定 4 行高,两行文字浮在半个空盒子里,右下角还挂着拖拽手柄(`resize` 默认值),上面还压着标题行和一段说明——平台自己的输入框(以及它的排队消息编辑态)不长这样。现在整个面板就是**平台输入框的形状**:一个 16px 圆角容器,输入区透明无边框(`display:block`、`resize:none`,`fitEditorHeight` 按 `scrollHeight` 随内容长高,上下限 `min-height:47px`/`max-height:46vh`),聚焦高亮画在容器上(`:focus-within`),取消/主按钮是右下角的胶囊(`.dshet-footer` 右对齐);标题行与常驻说明删除(说明留在确认步、按钮提示与本文档)。键盘对齐输入框:**Enter 保存、Shift+Enter 换行、Esc 取消/退回**,并且**输入法组合中的 Enter 不算保存**(`event.isComposing`)。用例新增/改写为「Enter 生效、Shift+Enter 换行、组合中的 Enter 不生效」「内容长高」「Esc 退回再关闭」,静态检查加 2 条(容器形状、输入法组合),`verify:live` 加 1 个下发标记。
|
|
254
|
+
- **修复:编辑器打开着的时候,别的输入框打不了字**。宿主随时会重绘消息行,我们的编辑框随之重建,而重建代码**无条件**执行 `area.focus()`——于是每次重绘都把光标从用户正在打字的地方(底部输入框、给会话改名、任何输入框)抢回编辑框:用户看到的正是"下面输入框点进去打不了字,字都跑你框里去了"。现在编辑器只在**刚打开**时聚焦;重建时只有用户本来就在本框内打字才把焦点**还**给它,并按记录的 `selectionStart/End` 还原光标位置,其余情况一律不碰焦点(`focus`/`blur` 维护 `editorFocus`)。两条用例(重建不抢焦点;重建归还焦点并还原光标)与 3 条静态检查钉住;真机复现:模拟宿主重绘后,焦点留在底部输入框,随后输入的字进入底部输入框,编辑框保持不动。后续又修了两处同源问题:**焦点意图改由「点击编辑器之外」释放**(`blur` 不行——宿主重建或保存期间的临时禁用也会 blur,那会导致保存失败后焦点回不来、重建中丢光标),并让**再点一次铅笔不再重置草稿**(此前会静默丢掉已输入的内容)。两处各配用例(保存被拒后焦点回到新框、按外面才释放意图、重点铅笔保留草稿)。
|
|
255
|
+
- **修复:拼音打不了中文**。键处理只挡了 `event.isComposing`,而**选词的 Enter 在很多引擎里是 `isComposing=false` + `keyCode=229`**(平台自己的输入框两条都挡,这是从它的源码里核对的)——于是每次按回车选词都被当成"保存",框直接保存并关闭。现在按平台的方式两条一起挡,并且**组合期间不做高度重排**(`compositionend` 才排),避免输入框在候选窗下移动。用例补 `keyCode 229` 形态,静态检查改为钉这两条;真机(合成事件)验证:组合 "nihao" 停留在框内、229 形态的 Enter **0 次 /apply**、随后 "你好" 正常进入输入框。
|
|
256
|
+
- 新增:`GET /dsh-edit-turn/debug` 现在也记录 **apply** 请求(成功记编辑类型/目标 seq/替换 seq,失败记错误码)。「点了没反应」从此可以一句话定位:请求记录里没有 apply,就是浏览器这半边的点击丢了;有 apply 且失败,就是宿主侧拒绝了它。静态检查加 1 条。
|
|
257
|
+
- 修复(工具自身):`verify:live` 的「本插件切片」原先取 id 首次/末次出现位置的 ±4000 字窗口。插件重新启用后,同一模块组里别的插件在注释里提到本插件、组尾的 source-map 提示又列出 `dsh-edit-turn/client.js.map`,窗口于是膨胀到几乎吞下整个组,把隔壁插件(`dsh-delete-turn`)的 `t(\`error.${...}\`)` 当成"残留的朴素查错"误报。现在改为**锚定模块注册本身**(`id: 'dsh-edit-turn'` 往前找 `__ModuleLoader__.load({`,往后截到下一个模块),并新增一条「模块以该 id 注册」的正向断言。切片从此只含本模块,正反两类断言都不再受邻居影响。
|
|
258
|
+
- 补强:`verify:live` 加 2 个下发标记与 1 条模块注册断言;真机重载后 `verify:ui` 全部通过(新增「回复笔最左」「行末尾的笔」在三个视图确认),`verify:live` 全部通过。
|
|
259
|
+
|
|
157
260
|
**0.2.1** —— 修 0.1.7 上的两处界面问题,并补上让问题可自证的诊断手段。
|
|
158
261
|
|
|
159
262
|
- 修复:模型回复的编辑入口改注册进官方插槽 `conversation.chat.assistant-actions`,与平台自己的 复制 / 赞 / 踩 / 分支 / 用量 同排(此前是 DOM 注入,位置不一致、且会随工具调用行卸载而闪烁)。
|
|
@@ -199,20 +302,22 @@ This plugin adds it:
|
|
|
199
302
|
|
|
200
303
|
- hover any message **you** sent and an edit action appears on the row;
|
|
201
304
|
- clicking it opens an in-place editor below that message, pre-filled with the original text;
|
|
202
|
-
-
|
|
203
|
-
- the
|
|
305
|
+
- saving **replaces that one message in place**: only the old wording leaves the model context, the revised text takes its place, and every later turn is answered against the new version;
|
|
306
|
+
- **the reply under it and the whole conversation after it stay exactly as they were** - nothing is re-run and nothing is cleared;
|
|
307
|
+
- model replies are editable too: replacing one rolls back that answer and everything built on it (a changed answer invalidates what followed) and appends your text as a fresh reply;
|
|
308
|
+
- turn `rerun` on if you do want the edited turn answered again right away.
|
|
204
309
|
|
|
205
310
|
### Features
|
|
206
311
|
|
|
207
312
|
- **Official seam, log untouched.** A rollback appends one replacement event carrying `surfaceOp: { op: 'replace', startSeq, endSeq }`. Every original event stays in the session file; it simply stops entering `deriveMessages()`. This is the same contract `/compact` uses.
|
|
208
|
-
- **
|
|
209
|
-
- **
|
|
210
|
-
- **
|
|
313
|
+
- **The revision is the carrier.** A prompt edit lands a `user/message` replacement whose content **is** your revised text: it updates the model context and shows in the transcript in place of the old wording. The platform does not answer it as a new input (that was tried: *appending* a user message makes the platform reply, and the next save added another copy). Reply edits stay silent too: their carrier is an empty `developer/message` - the format admits only system-prompt sources on `system/message`, and an empty plugin-owned system node is exactly what used to make sessions unreadable (see below).
|
|
314
|
+
- **The smallest window that is correct.** A prompt edit shadows exactly one node (`shadowed = [target.seq]`); a reply edit shadows the reply through the end of the surface - a changed answer invalidates everything built on it - which also keeps an assistant message (carrying its own tool_use blocks) and the tool/result it produced together. A dangling call/result pair is impossible.
|
|
315
|
+
- **A rewritten message stays editable.** It still shows where the original stood, and hovering it offers the pencil again.
|
|
316
|
+
- **No model call by default.** Saving only writes the context. `rerun: true` restores the old behaviour and goes through the one official prompt admission path, `ctx.sessionController.prompt()`.
|
|
211
317
|
- **The model's replies are editable too.** A reply cannot be swapped in place: the format refuses `sourceEventSeqs` on an `assistant/message` (verified against the real validator). The answer is rolled back together with everything after it, and the corrected text is **appended** as a fresh reply - the model goes on treating it as its own. `source.editedBy` records honestly that the plugin wrote those words.
|
|
212
|
-
- **One click applies** - no second confirmation. The editor is already an explicit action the user opened
|
|
318
|
+
- **One click applies** - no second confirmation by default. The editor is already an explicit action the user opened; with `confirm: true` the confirmation step states what this save will discard, while the input box itself stays clean.
|
|
213
319
|
- **Bilingual UI** that follows the current DSH locale.
|
|
214
320
|
- **Skin-friendly** - the editor paints its own opaque surface (`--dshet-panel`) instead of borrowing the theme's surface colours. A skin exists to make surfaces translucent so its artwork shows through, and it only compensates for *its own* elements; a plugin's class names are not on that list, so a panel that borrows those variables can end up fully transparent with text sitting straight on the art. The dark branch uses the official `body[data-ds-dark-theme]` hook (the same one `dsh-client-ui-theme` and several official UI packages use) and blends in with `backdrop-filter`.
|
|
215
|
-
- **Configurable carrier**: if some DSH release treats empty system nodes differently, one config line restores the marker-text carrier.
|
|
216
321
|
- **Loopback-only host routes** with `Host` and `Origin` validation.
|
|
217
322
|
|
|
218
323
|
### Install
|
|
@@ -232,14 +337,22 @@ Restart the DSH process that serves that profile to pick it up.
|
|
|
232
337
|
### Usage
|
|
233
338
|
|
|
234
339
|
1. Hover the **user message** you want to change and click the pencil action.
|
|
235
|
-
2. An editor opens below it with the original text pre-filled. Click "Save
|
|
236
|
-
3. The editor
|
|
340
|
+
2. An editor opens below it with the original text pre-filled. Click "**Save**" - **one click applies**: the old wording leaves the model context and the revised text takes its place. **No model call.** (`rerun: true` shows "Save and re-run" instead.)
|
|
341
|
+
3. The editor closes and the message shows your text in place; the reply under it and everything after it stay exactly where they were.
|
|
342
|
+
|
|
343
|
+
The editor is **shaped like the platform's own input box**: one rounded
|
|
344
|
+
container with the text at the top and Cancel / Save pills at its bottom right,
|
|
345
|
+
focus shown on the container itself. It **grows with its text** (no half-empty
|
|
346
|
+
slab for a two-line edit), with no title bar, no standing description and no
|
|
347
|
+
resize grip, and the keyboard matches the composer: **Enter saves, Shift+Enter
|
|
348
|
+
breaks the line, Escape cancels** (stepping back out of the confirmation first;
|
|
349
|
+
an Enter that commits IME composition is never taken as save).
|
|
237
350
|
|
|
238
351
|
If the editor warns that the message carries attachments, the rewrite keeps the
|
|
239
352
|
text only and drops them.
|
|
240
353
|
|
|
241
354
|
**Editing what the model said.** Hover any model reply and the same edit action
|
|
242
|
-
appears. Click "Save
|
|
355
|
+
appears. Click "Save" - the reply is replaced with your text,
|
|
243
356
|
everything after it is removed, and the model goes on treating your words as its
|
|
244
357
|
own, so the conversation can continue from there.
|
|
245
358
|
|
|
@@ -252,28 +365,36 @@ longer valid once you have changed what the model said.
|
|
|
252
365
|
```yaml
|
|
253
366
|
- id: dsh-edit-turn
|
|
254
367
|
config:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
confirm: true # default: require a confirmation step
|
|
368
|
+
confirm: false # default: apply in one click; true restores the two-step flow
|
|
369
|
+
rerun: false # default: saving only writes the context; true re-runs the turn
|
|
258
370
|
```
|
|
259
371
|
|
|
260
372
|
| Field | Default | Meaning |
|
|
261
373
|
|---|---|---|
|
|
262
|
-
| `
|
|
263
|
-
| `
|
|
264
|
-
| `confirm` | `false` | Whether the client asks for a second confirmation before applying. Off by default - one click applies, because the editor is already an explicit action the user opened and the panel states what saving discards. Set it to `true` to restore the two-step flow. |
|
|
374
|
+
| `confirm` | `false` | Whether the client asks for a second confirmation before applying. Off by default - one click applies, because the editor is already an explicit action the user opened; with `true` the confirmation step states what this save will discard. |
|
|
375
|
+
| `rerun` | `false` | Whether the edited turn is re-run right after saving. Off by default: the revision enters the context and the model reads it on your next message. `true` re-runs through the official `sessionController.prompt()`. |
|
|
265
376
|
|
|
266
377
|
### How it works
|
|
267
378
|
|
|
268
|
-
One edit is
|
|
379
|
+
One edit is **one official replacement event**. Read the event stream
|
|
380
|
+
(`sessionQuery.readSession`, falling back to the live session's
|
|
381
|
+
`snapshotEvents()`), fold the official surface order, locate the target message
|
|
382
|
+
node, then:
|
|
383
|
+
|
|
384
|
+
- a prompt edit: the window is **that one node** (`[target.seq]`) and the carrier
|
|
385
|
+
is the revised text itself;
|
|
386
|
+
- a reply edit: the window is `surface[index .. end]` (a changed answer
|
|
387
|
+
invalidates everything built on it); a fresh turn is opened first
|
|
388
|
+
(`turn/start` + `step/start`) because the read path only admits step messages
|
|
389
|
+
inside an open turn and step; the carrier is the empty `developer/message`,
|
|
390
|
+
the corrected text is appended as an assistant message inside that turn, and
|
|
391
|
+
`step/end` + `turn/end` close it behind the correction.
|
|
269
392
|
|
|
270
|
-
|
|
271
|
-
falling back to the live session's `snapshotEvents()`), fold the official
|
|
272
|
-
surface order, locate the target message node, and take the window
|
|
273
|
-
`surface[index .. end]`. Then append:
|
|
393
|
+
Then append:
|
|
274
394
|
|
|
275
395
|
```js
|
|
276
|
-
|
|
396
|
+
// the reply-edit shape: the prompt edit carries the revised user message instead
|
|
397
|
+
session.append('developer/message', { turn, step, message: { role: 'developer', content: [] } }, {
|
|
277
398
|
surfaceOp: { op: 'replace', startSeq, endSeq },
|
|
278
399
|
sourceEventSeqs: shadowed, // complete coverage: the validator requires every shadowed node
|
|
279
400
|
})
|
|
@@ -282,7 +403,7 @@ session.append('system/message', { turn, step, message: { role: 'system', conten
|
|
|
282
403
|
The append then waits for the official durability checkpoint (`sessions.flush`)
|
|
283
404
|
so a reload or a DSH restart still sees the rollback.
|
|
284
405
|
|
|
285
|
-
**Step two
|
|
406
|
+
**Step two (optional): the re-run.** Off by default; `rerun: true` turns it on.
|
|
286
407
|
|
|
287
408
|
```js
|
|
288
409
|
await ctx.sessionController.prompt({ requestId, sessionId, mode: 'queue', content: [{ type: 'text', text }] })
|
|
@@ -301,22 +422,37 @@ for exactly this.
|
|
|
301
422
|
|
|
302
423
|
| Kind | Name | Purpose |
|
|
303
424
|
|---|---|---|
|
|
304
|
-
| Route | `GET /dsh-edit-turn/state?sessionId=` | Editable turns, the hidden-row ledger, the surface, busy state |
|
|
305
|
-
| Route | `POST /dsh-edit-turn/apply` | `{ sessionId, seq \| messageId \| turn, text }` →
|
|
425
|
+
| Route | `GET /dsh-edit-turn/state?sessionId=` | Editable turns, the hidden-row ledger (`hidden` + `revisions`), the surface, busy state |
|
|
426
|
+
| Route | `POST /dsh-edit-turn/apply` | `{ sessionId, seq \| messageId \| turn, text }` → write the revised text (`rerun: true` re-runs too); the response carries `applied` / `reran` / `shadowed` / `replacementSeq` |
|
|
306
427
|
| Tool | `edit_turn_targets` | Read-only: list the session's editable turns and their text |
|
|
307
428
|
| Client | `conversation.input.overlay` | Per-session controller: edit entry, in-place editor, hidden-row ledger |
|
|
308
429
|
|
|
430
|
+
### Cross-plugin contract (for sibling plugins)
|
|
431
|
+
|
|
432
|
+
The transcript only builds rows for **append surface events** (ui-chat's user and assistant definitions both match on `isAppendSurfaceEvent`), so after an in-place edit **that row stays anchored to the original seq / messageId** — nothing in the row tells another plugin "I am now at seq N", while the live node in the model context is the replacement event. Any entry that validates by surface (say `dsh-delete-turn`'s delete action) finds the original seq dead and has to follow the replacement chain to the live node to stay useful. External consumers can rely on:
|
|
433
|
+
|
|
434
|
+
1. **A prompt edit keeps a single-node window**: `planRollback` for `mode === 'prompt'` shadows exactly the message itself (`shadowed = [target.seq]`) and never rolls back to the tail;
|
|
435
|
+
2. **The replacement event has the type of the target**: a `user/message` edit lands a `user/message` (a reply cannot be swapped in place — see 5);
|
|
436
|
+
3. **`sourceEventSeqs` always lists the whole window**: the official validator demands complete coverage; one missing node and the append is refused;
|
|
437
|
+
4. **Every replacement carries the semantic marker**: `source.kind === 'plugin:dsh-edit-turn'` and `source.editedBy === 'dsh-edit-turn'` — including the empty text-free `developer/message` carrier, so siblings can recognise rewrites by `editedBy` alone instead of inferring from the window shape;
|
|
438
|
+
5. **A reply edit is a multi-node rollback plus an appended correction**, not an in-place swap: the old reply row keeping no entry is **by design** (the new row carries it). Do not add `sourceEventSeqs` to an `assistant/message` to keep the old row's entry — the official validator refuses it outright;
|
|
439
|
+
6. **The mapping is published**: `revisions[]` on `GET /dsh-edit-turn/state` gives `{ replacementSeq, startSeq, endSeq, shadowed }` (the field names the `POST /apply` response already used), and `hidden[]` lists `{ seq, turn, replacement }` per shadowed row. No sibling has to re-derive the ledger from the event stream.
|
|
440
|
+
|
|
441
|
+
**The `source.kind` behaviour, intentionally:** after a prompt edit the landed `user/message` has `source.kind === 'plugin:dsh-edit-turn'`, **not `user`**. Any consumer that detects human prompts by `source.kind === 'user'` (for example, partitioning reply-deletion windows by human turns) will classify the revision as ordinary content. That is deliberate: the text was written by a plugin, it is not a fresh human turn, and the platform must not answer it. Consumers that need to find it should use `revisions`/`editedBy` above rather than loosening the `source.kind === 'user'` test.
|
|
442
|
+
|
|
309
443
|
### Verification status
|
|
310
444
|
|
|
311
|
-
Verified against DSH `0.1.6-alpha.2`, entirely **without model calls**:
|
|
445
|
+
Verified against DSH `0.1.6-alpha.2` and `0.1.7-rc.1`, entirely **without model calls**:
|
|
312
446
|
|
|
313
447
|
| Check | Command | Result |
|
|
314
448
|
|---|---|---|
|
|
315
449
|
| Is the plugin actually mounted in a running instance? (read-only route-guard probe, no token needed) | `npm run probe:loaded [port]` | pass: `/state` answers 400 and `/apply` answers 405 - status codes only this plugin produces |
|
|
316
|
-
|
|
|
317
|
-
|
|
|
450
|
+
| What the browser half asked for and got back (read-only diagnostics, failures included) | `GET /dsh-edit-turn/debug` | pass: a real browser was confirmed fetching replies, and an old v3 session was reproduced as session-not-found |
|
|
451
|
+
| Official append AND READ contract against the real validator, in process | `npm run verify:contract` | 80 checks pass (every write sequence is reloaded through the reader afterwards): the replacement is accepted, the derived history really shrinks, the log stays append-only, a tool result leaves with its call, the empty developer carrier adds no model message, **two consecutive rollbacks are both accepted**, **the whole reply-editing mechanism is accepted**, **the invariants siblings depend on (single-node window, same event type, full `sourceEventSeqs` coverage, an `editedBy` marker on every replacement, a rewritten prompt whose `source.kind` is not `user`, a correction that carries no `sourceEventSeqs`)** |
|
|
452
|
+
| Pure logic, host integration and browser-half DOM behaviour (real HTTP, real validator, stubbed services, DOM stub) | `npm test` | 105 tests pass |
|
|
318
453
|
| Browser-half static checks (registration, i18n completeness, styles, skin legibility, three-way version sync, wire contract) | `npm run verify:client` | all pass |
|
|
319
454
|
| Live client artifact (is the running DSH serving the current code?) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | all pass, **including anchors on the host's node shapes** |
|
|
455
|
+
| **Real browser rendering smoke** (CDP drives an already-open tab, read-only) | `npm run verify:ui` | all pass: the served bytes are the edited bytes, no empty placeholder left by a retired entry, the reply pencil sits in the platform action bar, a rolled-back row keeps its action bar, **every collapsed user message still shows the text that replaced it**, **that bubble sits above the time and the copy, in the order the platform draws a message**, **the rewriting pencil sits at the end of the surviving bar (confirmed live in the original session, the other session, and back again)**, **the reply pencil leads its bar, ahead of the copy (confirmed live)**, no `.dshet-floating` covers the revision text, and the entries survive a session round trip |
|
|
320
456
|
| Real profile: install, patch composition, boot, tool contract, route guards | `npm run verify:profile` | all pass |
|
|
321
457
|
| **Real browser, end to end** (agent-browser drives Chrome at a running instance) | manual | done: an edit action appears on model reply rows, the editor opens pre-filled with the real reply, and cancelling leaves zero `apply` requests. See `docs/reply-editor-in-browser.png` |
|
|
322
458
|
|
|
@@ -327,6 +463,28 @@ plugin's own markers are inside it. That proves "a browser reload gets the
|
|
|
327
463
|
current code", which is stronger than "the source looks right" - and after a
|
|
328
464
|
front-end edit it is the only automatic way to confirm the change took effect.
|
|
329
465
|
|
|
466
|
+
`verify:ui` drives a tab that is **already open** (Chrome started with
|
|
467
|
+
`--remote-debugging-port`, 9333 by default): it reloads with the cache bypassed,
|
|
468
|
+
asserts the bytes the browser just got are the edited bytes, then reads the DOM
|
|
469
|
+
and the console - every assistant cell rendered something (no empty placeholder
|
|
470
|
+
from a retired entry), the reply pencil sits in the platform's own action bar, a
|
|
471
|
+
rolled-back row lost its message but kept time / copy / delete, every collapsed
|
|
472
|
+
user message still has this plugin's replacement bubble - the live signature of
|
|
473
|
+
the "message vanished after saving" defect, since a row may collapse but never
|
|
474
|
+
with nowhere to show the replaced text - and that bubble sits **above** the time
|
|
475
|
+
and the copy, in the order the platform draws a message in (planted after the
|
|
476
|
+
row it would have left the bar on top of the message), **that the pencil
|
|
477
|
+
rewriting a prompt sits at the end of the bar that survived, behind the clock
|
|
478
|
+
and the copy, with no pencil left inside the bubble** (the bubble's own gutter
|
|
479
|
+
is only a fallback for a row with no bar at all), **and that the reply pencil
|
|
480
|
+
leads its strip, ahead of the copy button the host draws before the slot this
|
|
481
|
+
entry lands in** (that cell is `display:contents`, so DOM order can only ever be
|
|
482
|
+
second; the check reads the laid-out x positions, not the tree), no
|
|
483
|
+
`.dshet-floating` covers the revision text, and the entries survive switching
|
|
484
|
+
away and back. It is **read-only**: it never opens the editor, never clicks save,
|
|
485
|
+
never posts to `/apply`. (The two pencil-placement assertions have since been
|
|
486
|
+
run against a live reload and pass.)
|
|
487
|
+
|
|
330
488
|
`verify:profile` boots a sandbox instance on its own port with its own
|
|
331
489
|
`DSH_HOME`, and terminates only the process it started, **by pid**. It never
|
|
332
490
|
touches the DSH you are using:
|
|
@@ -335,10 +493,12 @@ touches the DSH you are using:
|
|
|
335
493
|
DSH_BIN=/path/to/dsh/lib/bin.js PORT=4123 DSH_HOME=/tmp/dsh-edit-turn-home bash tools/verify-dsh-plugin.sh
|
|
336
494
|
```
|
|
337
495
|
|
|
338
|
-
**The one link that is NOT verified, stated plainly:**
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
496
|
+
**The one link that is NOT verified, stated plainly:** `verify:ui` covers *how it
|
|
497
|
+
renders* (read-only). **Actually pressing save** posts `/apply` and rolls the
|
|
498
|
+
session back - that step stays manual (last row of the table above, plus
|
|
499
|
+
`docs/reply-editor-in-browser.png`); the smoke tool never touches it. And there
|
|
500
|
+
is no React or Playwright on this machine, so `verify:ui` talks raw CDP instead
|
|
501
|
+
of a framework. The click path
|
|
342
502
|
itself is now covered: `test/client.dom.test.js` runs the real `OverlayEntry`
|
|
343
503
|
against a readable DOM implementation and asserts "click the action -> the editor
|
|
344
504
|
appears pre-filled -> click save -> the confirmation step appears -> click
|
|
@@ -346,6 +506,159 @@ confirm -> the right request is posted -> the editor closes", plus every failure
|
|
|
346
506
|
branch's message. That test is meaningful: reverting the re-render marker to its
|
|
347
507
|
old form makes 6 of its cases fail.
|
|
348
508
|
|
|
509
|
+
**The screen right after pressing save is covered too**, because it went wrong
|
|
510
|
+
for real once: the save succeeded, the old row collapsed, the replacement bubble
|
|
511
|
+
was never built, and the message simply left the interface (the server-side
|
|
512
|
+
rollback had landed all along; only the view was wrong). Three cases pin one
|
|
513
|
+
cause each - (1) the replacement bubble must be on screen the moment the save
|
|
514
|
+
response arrives, even if the `/state` refresh that follows never returns,
|
|
515
|
+
(2) a save must ask the host for the state it just changed, (3) a row with a
|
|
516
|
+
recorded replacement but no replacement text yet may not collapse. Reverting the
|
|
517
|
+
three fixes in `lib/client.js` one at a time makes exactly those three cases
|
|
518
|
+
fail, one each; restore them and all pass (94 at the time, 97 now).
|
|
519
|
+
|
|
520
|
+
**"the bubble sits above the time and the copy" is covered too**: the standing-in
|
|
521
|
+
bubble was planted *after* the collapsed row, so the timestamp and the copy
|
|
522
|
+
button that the row had left stood above the rewritten text and read as if they
|
|
523
|
+
belonged to the next line. The bubble now goes inside the row, ahead of the node
|
|
524
|
+
carrying the action bar (`placeRevisionBubble`, idempotent - it only moves when
|
|
525
|
+
the position is wrong), and `collapseRowContent`'s exemption list gained the
|
|
526
|
+
bubble itself, since a bubble inside the row would otherwise be emptied away with
|
|
527
|
+
it; a row with no bar to keep is displayed away entirely, and then the bubble is
|
|
528
|
+
parked *before* the row so it stays on screen. Two cases pin the two positions
|
|
529
|
+
(94 -> 95 then; 97 now); putting the planting back the old way fails exactly four cases at
|
|
530
|
+
once. `verify:ui` gained the live assertion "the bubble sits above the action
|
|
531
|
+
bar", the static checks two more, and `verify:live` one more served marker.
|
|
532
|
+
|
|
533
|
+
**"Where each of the two pencils sits" is covered too**, changed to the spec you
|
|
534
|
+
gave: the prompt pencil belongs at the **right end** of the action bar (behind
|
|
535
|
+
the clock, the copy, and any button a sibling plugin appends after us), and the
|
|
536
|
+
reply pencil at the **left end** (ahead of the
|
|
537
|
+
copy button). Neither is reachable by moving an insertion point; each was stuck
|
|
538
|
+
against a host constraint: the collapsed row parked its pencil in the revision
|
|
539
|
+
bubble's own gutter (visible only while hovering the rewritten text, and nowhere
|
|
540
|
+
near the copy), and the reply pencil is rendered through a host slot, so in DOM
|
|
541
|
+
order it can only sit after the copy button the platform draws. Now (1)
|
|
542
|
+
`renderRevision` hands the rewriting pencil to the bar that survived
|
|
543
|
+
(`injectRowAction(row, editTarget, ...)`) and takes it out of the bubble at the
|
|
544
|
+
same time (`removeRowAction(bubble)`) - the bubble's gutter is a fallback only,
|
|
545
|
+
for a row with no bar left at all; and `applyDom`, while a row is visible, tears
|
|
546
|
+
the row's pencil down only when **nothing of ours is standing in for it** -
|
|
547
|
+
otherwise it would remove what that call has just placed. (2) the reply pencil
|
|
548
|
+
gained `.dshet-reply-action` plus flex `order:-1` to get to the left end - that
|
|
549
|
+
cell is `display:contents`, so DOM order can only ever be second, and only an
|
|
550
|
+
order property can move it. The row pencil uses the same tool at the other end:
|
|
551
|
+
`dsh-delete-turn` appends its bin into the same bar on its own pass (live DOM:
|
|
552
|
+
`time -> copy -> pencil -> bin`), and neither plugin re-positions a node already
|
|
553
|
+
in the bar, so no insertion order can hold the right end - `.dshet-action-host`
|
|
554
|
+
carries flex `order:9` (every platform item in that `display:flex` bar is
|
|
555
|
+
`order:0`; the gutter and floating placements are not flex items and ignore it). The colour is matched to the platform's too: `.dshet-action` now
|
|
556
|
+
takes `var(--dsw-alias-label-tertiary)` (hover: `--dsw-alias-label-primary`,
|
|
557
|
+
with the default theme's value as the fallback) instead of our own blue-grey
|
|
558
|
+
ink, which read as a different, "active" icon beside the neutral grey ones -
|
|
559
|
+
the pencil and the copy button now measure the same computed colour
|
|
560
|
+
(`rgb(129, 133, 140)`).
|
|
561
|
+
The bubble also stopped stretching into a
|
|
562
|
+
full-width pill (moved inside the row, `align-self` no longer applies, so it
|
|
563
|
+
sets `width:fit-content`). The rewritten test asserts the pencil joins the end
|
|
564
|
+
of the surviving bar with none left in the bubble, the barless case asserts the
|
|
565
|
+
gutter fallback still holds a pencil, the reply-entry test asserts the class;
|
|
566
|
+
the static checks gained seven (including "the bubble is sized to its text, not
|
|
567
|
+
to the row", the pill defect, "the row pencil sorts to the right end of its
|
|
568
|
+
bar", and the two platform-colour checks), `verify:live` two served markers, and `verify:ui`
|
|
569
|
+
two live assertions plus the "zero pencils in the bubble" rewrite - **those two
|
|
570
|
+
live assertions have since been run against a real reload and pass** (the pencil
|
|
571
|
+
at the end of the bar was confirmed in the original session, in the other
|
|
572
|
+
session, and after switching back; the reply pencil was confirmed leftmost; and
|
|
573
|
+
the "end of the bar" check now measures the laid-out right edge instead of
|
|
574
|
+
counting children, because with flex order the DOM position no longer says where
|
|
575
|
+
a button is drawn - the live bar reads `time -> copy -> bin -> pencil`).
|
|
576
|
+
Each of the
|
|
577
|
+
three new placements was also checked in reverse: restoring the old "hand the
|
|
578
|
+
pencil to the bar", dropping the `.dshet-reply-action` class, or dropping
|
|
579
|
+
`applyDom`'s placement guard each makes exactly one case fail (the guard's
|
|
580
|
+
revert fails the "pencil joins the end of the bar" case, which pins the
|
|
581
|
+
"placed then torn down" regression too). One tool bug surfaced while re-running
|
|
582
|
+
`verify:live` with the plugin re-enabled: the "own slice" was a fixed window
|
|
583
|
+
around the first and last mention of the plugin id, and once another plugin's
|
|
584
|
+
comment and the group's source-map hint mentioned the id, that window swelled to
|
|
585
|
+
swallow nearly the whole group - reporting the neighbour's `t(\`error.${...}\`)` as
|
|
586
|
+
a stale lookup of ours. The slice is now anchored on the module registration
|
|
587
|
+
itself (`id: 'dsh-edit-turn'` back to `__ModuleLoader__.load({`, cut at the next
|
|
588
|
+
module), with a positive assertion that the module registers under that id.
|
|
589
|
+
`npm run check` is green: 105 tests, contract, static; `verify:live` and
|
|
590
|
+
`verify:ui` both pass against the running instance.
|
|
591
|
+
|
|
592
|
+
**A reply edit used to brick the session, and the contract suite now proves it
|
|
593
|
+
cannot.** The old write was illegal twice over: the silent carrier was an empty
|
|
594
|
+
plugin-owned `system/message` (the format admits only system-prompt sources
|
|
595
|
+
there), and the correction was appended with the turn/step of an already closed
|
|
596
|
+
turn. Appends are lenient and accepted both; readers are not, so DSH answered
|
|
597
|
+
`session-not-found` for the whole session from then on (`SessionFormatError:
|
|
598
|
+
system/message does not match an open turn and step`) - the edit entries
|
|
599
|
+
disappeared and the history would not open. Reply edits now open a turn of their
|
|
600
|
+
own first, use an empty `developer/message` as the carrier, and close the turn
|
|
601
|
+
behind the correction; the runtime projects `lastTurn` from every `turn/start` it
|
|
602
|
+
observes, so its next turn still gets the number the format expects. The contract
|
|
603
|
+
suite gained the layer that was missing: after every write sequence it RELOADS
|
|
604
|
+
the log through the same loader the server uses (plus message projections), and
|
|
605
|
+
it simulates the next real turn after an edit. And for sessions already broken,
|
|
606
|
+
`tools/repair-session.mjs` validates each log through that reader and truncates
|
|
607
|
+
at the first bad event with a backup - four sessions on this machine were
|
|
608
|
+
repaired that way, all losing only this plugin's corrupt reply-edit groups.
|
|
609
|
+
|
|
610
|
+
**"Pressed save and nothing happened" is covered too**, and it was a real
|
|
611
|
+
report. The host owns the row and rebuilds it freely - while a turn streams it
|
|
612
|
+
re-renders constantly - so a button replaced between mousedown and mouseup never
|
|
613
|
+
fires a click: from outside the button simply "does nothing", and no state
|
|
614
|
+
anywhere shows why. The editor's cancel/save and the row pencils now act on
|
|
615
|
+
**pointerdown** (the press counts immediately), the click path stays for the
|
|
616
|
+
keyboard, and the button swallows the click that follows its own press so the
|
|
617
|
+
action cannot run twice; the pencil keeps its guard across passes by being wired
|
|
618
|
+
once and reading the current target off its entry. Two new cases pin "the press
|
|
619
|
+
opens the editor and the swallowed click does not reset the draft" and "the
|
|
620
|
+
press advances to the confirmation step", and three static checks pin the
|
|
621
|
+
wiring. The host's request ring now records **apply** attempts as well, so the
|
|
622
|
+
next report is answerable in one look: no `apply` entry means the click never
|
|
623
|
+
reached the host, an `apply` with `ok:false` means the host refused it and the
|
|
624
|
+
`code` says why. **The focus bookkeeping was rebuilt around an explicit intent.** The first fix
|
|
625
|
+
still used `blur` as "the user left" - but the host replacing the row, or the
|
|
626
|
+
save temporarily disabling the box, blurs without the user going anywhere, and
|
|
627
|
+
the caret was dropped for good: after a refused save the box came back
|
|
628
|
+
unfocused, and a host rebuild mid-typing lost the caret. The intent is now
|
|
629
|
+
cleared by exactly one signal - a press **outside** the editor (a document-level
|
|
630
|
+
pointerdown) - so rebuilds, pending states and re-renders never move it, and a
|
|
631
|
+
refused save hands the focus back. Pressing the pencil again for the same
|
|
632
|
+
message also re-focuses the open box instead of resetting the draft, which had
|
|
633
|
+
silently thrown away whatever the user had typed. Three cases pin the refused
|
|
634
|
+
save, the outside press and the double pencil.
|
|
635
|
+
|
|
636
|
+
**An open editor used to disable every other input on the page.** The host
|
|
637
|
+
re-renders the row at will, the box is rebuilt with it, and the rebuild called
|
|
638
|
+
`focus()` unconditionally - so every re-render pulled the caret out of whatever
|
|
639
|
+
the user was typing in (the composer, a rename field, anything) and sent their
|
|
640
|
+
keystrokes into the editor instead. The box now takes focus only when it is
|
|
641
|
+
just opened; a rebuild hands the focus **back** only if the user was typing in
|
|
642
|
+
it, restoring the caret position, and otherwise leaves focus alone. Two cases
|
|
643
|
+
pin it (a rebuild does not steal; a rebuild mid-typing returns focus at the
|
|
644
|
+
caret) plus three static checks, and the live reproduction now keeps the
|
|
645
|
+
composer focused with the typed text landing in the composer.
|
|
646
|
+
|
|
647
|
+
The reply editor's button label also lost its redundant second
|
|
648
|
+
word: "Save replacement" is now just "Save"; the textarea itself became a normal
|
|
649
|
+
input box: one rounded container in the platform composer's shape, the text
|
|
650
|
+
transparent and borderless inside it, Cancel / Save pills at its bottom right,
|
|
651
|
+
focus drawn on the container. It grows with its text instead of sitting at a
|
|
652
|
+
fixed four rows with an empty half, the manual resize grip, title bar and
|
|
653
|
+
standing description are gone, Enter saves while Shift+Enter breaks the line,
|
|
654
|
+
Escape cancels, and IME input always wins: the Enter that commits a 拼音/かな
|
|
655
|
+
candidate (arriving either as `isComposing` or as the legacy `keyCode 229` -
|
|
656
|
+
the platform's own inputs guard both, and the missing half is what made every
|
|
657
|
+
candidate pick save the box) never counts as save, and the field is not
|
|
658
|
+
relaid out mid-composition. Cases pin the auto-fit, the keyboard path and the IME guard; two more static
|
|
659
|
+
checks pin the container shape and the IME guard; `verify:live` carries the
|
|
660
|
+
marker.
|
|
661
|
+
|
|
349
662
|
**Development prerequisite:** `npm test` and both `verify:*` commands need
|
|
350
663
|
`@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-tools` to resolve. The plugin
|
|
351
664
|
itself does not depend on them (the host half imports only `schemastery` and
|
|
@@ -359,13 +672,56 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
|
|
|
359
672
|
|
|
360
673
|
- **Human prompts and model replies** are both editable; injected context rows and the system prompt offer no edit entry.
|
|
361
674
|
- **Editing a reply replaces it with plain text**: the tool calls and reasoning inside it are removed (the editor warns first), because their results are no longer valid.
|
|
362
|
-
- **Editing a user message
|
|
363
|
-
- **
|
|
675
|
+
- **Editing a user message replaces that one message and leaves the rest of the conversation untouched - no re-run unless `rerun: true`**; editing a model reply only replaces the text and never asks the model again.
|
|
676
|
+
- **A prompt edit touches only that message.** The reply under it and the whole conversation after it are kept - no re-run unless `rerun: true`. A **reply edit** removes that answer and everything built on it, because a changed answer invalidates what followed. Keeping the original as a branch needs `sessionController.fork({ sessionId, atSeq })`, which is not implemented.
|
|
364
677
|
- **Text only.** A message carrying image or file attachments keeps its text and drops them (the editor warns first).
|
|
365
678
|
- **The session must be open in DSH**, otherwise the route answers `409 session-not-active`.
|
|
366
679
|
- **Running work is refused**: an unclosed turn or an in-flight compaction answers `409 busy`.
|
|
367
680
|
- **The rollback is durable; the hiding is not.** Once written, the rollback permanently changes the model context. Hiding those rows in the transcript is this plugin's browser half. Uninstall the plugin and the old rows reappear - while the rollback in the model context still stands, because the replacement is an official event that outlives the plugin.
|
|
368
681
|
- **The system prompt is never touched**: the window can never include surface node 0, and that node is never editable.
|
|
682
|
+
- **Old sessions (v3-format logs) cannot be read under DSH 0.1.7**: the session reader answers `session-not-found` for them, so the plugin does not work in one at all (the visible symptom is no edit entry). Newly written sessions are v4 and work normally.
|
|
683
|
+
|
|
684
|
+
### Troubleshooting: the edit entry disappeared entirely
|
|
685
|
+
|
|
686
|
+
Separate "the plugin never loaded" from "it loaded but nothing rendered" - the two have nothing in common, so do not guess.
|
|
687
|
+
|
|
688
|
+
1. **Is the plugin loaded?** `npm run probe:loaded [port]` (default 3080) from the plugin directory. `400`/`405` means the host half is running; `404` means it never loaded.
|
|
689
|
+
2. **When it did not load, look at the host's startup output** for:
|
|
690
|
+
|
|
691
|
+
```
|
|
692
|
+
dsh-edit-turn (dsh-edit-turn): failed to import
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
That line is almost always a **dependency resolution failure**, and the usual cause is a `link:` install (the development directory mounted into the profile) whose `node_modules` is a symlink into an npx cache - once npx prunes that cache the symlink dangles, the static `import` throws, and the whole plugin vanishes. Fix it by installing the dependencies for real:
|
|
696
|
+
|
|
697
|
+
```sh
|
|
698
|
+
cd <plugin dir> && rm -f node_modules && npm install --omit=dev --legacy-peer-deps
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
Mind the two kinds of dependency: **peers** provided by the host (`dsh-tools`, `dsh-settings`) are resolved by the DSH loader and need no install; **regular dependencies** (like `schemastery`) must resolve on their own - the loader will not find them for you.
|
|
702
|
+
3. **"The history fails to load / one session is unreadable"** (`session-not-found`,
|
|
703
|
+
with `SessionFormatError` in the console): this can be a log broken by the reply
|
|
704
|
+
edits of 0.2.0-0.2.3. The repository ships a repair tool that scans a sessions
|
|
705
|
+
directory, validates every log through the official loader, and - after a
|
|
706
|
+
byte-for-byte backup - truncates at the first event the reader refuses:
|
|
707
|
+
|
|
708
|
+
```sh
|
|
709
|
+
node tools/repair-session.mjs ~/.dsh/sessions/<project-dir> --dry-run
|
|
710
|
+
node tools/repair-session.mjs ~/.dsh/sessions/<project-dir>
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
The truncated tail is kept as `*.corrupt-*.bak` next to the log; restart DSH
|
|
714
|
+
(or the desktop app) and the session opens again.
|
|
715
|
+
|
|
716
|
+
4. **When it did load but shows no entry**, ask the read-only diagnostic route what the browser half actually requested:
|
|
717
|
+
|
|
718
|
+
```sh
|
|
719
|
+
curl "http://127.0.0.1:<port>/dsh-edit-turn/debug"
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
Entries with `state` and `replies` above zero mean the data reached the client, so the problem is rendering; **no entries at all** means the browser half never ran (check its console).
|
|
723
|
+
|
|
724
|
+
**Saved and nothing happened? Look here first:** is there an `apply` entry? None means the click was lost in the browser half (the cause was the host rebuilding the row between mousedown and mouseup; the buttons now act on pointerdown). An `apply` with `ok:false` means the host refused it - the `code` says why (`busy`/`stale`/...).
|
|
369
725
|
|
|
370
726
|
### Sister plugins
|
|
371
727
|
|