dsh-turn-undo 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/client.js +470 -0
- package/cordis.patch.yml +9 -0
- package/index.js +1083 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 吕才灵
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# dsh-turn-undo
|
|
2
|
+
|
|
3
|
+
> DSH 插件:在任意用户消息处点击“撤销”,让对话恢复到发送这条消息之前的状态——回滚 AI 造成的文件变更,并 fork 出新会话继续编辑。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- ✅ 每条用户消息的操作栏注入一个“撤销”按钮(↺)。
|
|
8
|
+
- ✅ 弹窗显示本轮会恢复/删除/修改的文件,并给出警告。
|
|
9
|
+
- ✅ 确认后:
|
|
10
|
+
- 恢复工作区文件到**发送该消息之前**的快照状态。
|
|
11
|
+
- 通过 DSH 原生的 `sessionController.fork` 从该消息之前 fork 出新会话。
|
|
12
|
+
- 旧会话保留,标题前方自动加上 `(已撤销)`。
|
|
13
|
+
- 新会话输入框自动填入被撤销的这条用户消息,方便你修改后重发。
|
|
14
|
+
- ✅ 不依赖 git,适用于任何目录。
|
|
15
|
+
- ✅ 快照去重(sha256 内容寻址),未变化文件零成本存储。
|
|
16
|
+
- ✅ 自动清理:TTL + 每会话数量上限。
|
|
17
|
+
|
|
18
|
+
## 安装
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
dsh plugin --profile <name> add dsh-turn-undo
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 使用
|
|
25
|
+
|
|
26
|
+
1. 在对话中,每条用户消息的操作栏会出现撤销按钮。
|
|
27
|
+
2. 点击按钮,弹窗列出本轮影响的文件。
|
|
28
|
+
3. 点击“确认恢复并继续”:
|
|
29
|
+
- 文件恢复。
|
|
30
|
+
- 旧会话被标记为 `(已撤销)`。
|
|
31
|
+
- 新会话打开,输入框里已有原消息内容。
|
|
32
|
+
4. 在新会话里修改问题后重新发送即可。
|
|
33
|
+
|
|
34
|
+
## 架构
|
|
35
|
+
|
|
36
|
+
### 服务端(`index.js`)
|
|
37
|
+
|
|
38
|
+
- **快照触发**:监听 `session/event` 全局事件。
|
|
39
|
+
- `turn/start`:生成 `turn - 0.5` 小数快照,记录用户发消息之前的工作区状态。
|
|
40
|
+
- `turn/end`:生成整数 `turn` 快照,记录 AI 完成工作后的状态。
|
|
41
|
+
- **存储**:
|
|
42
|
+
- `$DSH_HOME/turn-undo/objects/<sha256>`(内容去重)
|
|
43
|
+
- `$DSH_HOME/turn-undo/snapshots/<sessionId>/<turn>.json`(manifest:path → hash)
|
|
44
|
+
- **恢复**:从 `turn - 0.5` 快照回写文件,并删除快照里不存在的文件。
|
|
45
|
+
- **fork**:
|
|
46
|
+
- 调用 DSH 官方 web 同款的 `ctx.sessionController.fork({ sessionId, atSeq })`。
|
|
47
|
+
- 对“第一条用户消息”前面没有 completed turn 时,fall back 到 `ctx.sessionController.create({ cwd, agentPreset })` 创建空白会话。
|
|
48
|
+
- 旧会话通过 `ctx.sessionController.rename` 加上 `(已撤销)` 前缀。
|
|
49
|
+
- **HTTP API**:
|
|
50
|
+
- `GET /api/turn-undo?sessionId=...&messageSeq=...&promptText=...`:预览本轮影响。
|
|
51
|
+
- `POST /api/turn-undo`(body `{ sessionId, messageSeq, promptText }`):执行恢复 + fork。
|
|
52
|
+
|
|
53
|
+
### 客户端(`client.js`)
|
|
54
|
+
|
|
55
|
+
- `MutationObserver` 自动检测新增的用户消息。
|
|
56
|
+
- 在用户消息的 IconActions 区域通过 portal 注入撤销按钮。
|
|
57
|
+
- 弹窗组件:`RestoreButton` + `RestoreModal`。
|
|
58
|
+
- 恢复完成后先打开新会话,再轮询设置输入框草稿(`conversation.input.shell(...).setDraft(...)`)。
|
|
59
|
+
|
|
60
|
+
## 配置
|
|
61
|
+
|
|
62
|
+
在 `cordis.patch.yml` 中配置:
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
- insert:
|
|
66
|
+
- id: turn-undo
|
|
67
|
+
name: dsh-turn-undo
|
|
68
|
+
config:
|
|
69
|
+
snapshotTtlDays: 7 # 快照保留天数
|
|
70
|
+
maxSnapshotsPerSession: 50 # 每会话最多保留快照数
|
|
71
|
+
maxFileBytes: 10485760 # 单个文件超过 10MB 不纳入快照
|
|
72
|
+
maxFilesPerSnapshot: 10000 # 单次快照最多文件数
|
|
73
|
+
maxSnapshotBytes: 524288000 # 单次快照总大小上限 500MB
|
|
74
|
+
snapshotDelayMs: 250 # turn/end 后等待落盘的延迟
|
|
75
|
+
excludes: # 覆盖默认忽略目录/文件
|
|
76
|
+
- node_modules/
|
|
77
|
+
- .git/
|
|
78
|
+
- dist/
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
默认 excludes 还会过滤:`.venv/`、`venv/`、`__pycache__/`、`target/`、`build/`、`.next/`、`.turbo/`、`.gradle/`、`.idea/`、`.vscode/`、`coverage/`、`.DS_Store`、`*.log`。注意:这些只是扫描时的过滤,restore 的删除阶段也遵循同样规则。
|
|
82
|
+
|
|
83
|
+
## 开发
|
|
84
|
+
|
|
85
|
+
运行集成测试:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
node test-integration.mjs
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
语法检查:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
node -e "import('./index.js')"
|
|
95
|
+
node -c client.js
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
在 DSH 里热加载/测试:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
cd /Users/lyu/Documents/Source/deepseek-harness
|
|
102
|
+
pnpm dsh web
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
> 客户端(`client.js`)修改后,若 `pnpm run dev:web` 没在开,需要重新构建客户端 bundle 并刷新页面。
|
|
106
|
+
|
|
107
|
+
## 已知限制
|
|
108
|
+
|
|
109
|
+
- 只能恢复 AI 通过 DSH 工具产生的文件变更;无法捕获:
|
|
110
|
+
- 用户通过 VS Code 等外部编辑器直接修改的文件。
|
|
111
|
+
- 用户在终端里直接执行的 shell 命令。
|
|
112
|
+
- MCP 工具中不经过 DSH 工具管线的操作。
|
|
113
|
+
- 撤销依赖本插件在运行期间生成的快照;对安装插件之前的旧会话无效。
|
|
114
|
+
- 第一条用户消息的撤销会创建空白新会话(因为没有 completed turn 可 fork)。
|
|
115
|
+
- 超过容量上限的单次快照会被跳过,并打印警告。
|
|
116
|
+
|
|
117
|
+
## 许可
|
|
118
|
+
|
|
119
|
+
MIT
|
package/client.js
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
// Browser half of the dsh-turn-undo plugin.
|
|
2
|
+
//
|
|
3
|
+
// Injects a "restore to before this message" button into the IconActions row of
|
|
4
|
+
// EVERY USER MESSAGE ONLY (never AI replies), using React.createPortal.
|
|
5
|
+
//
|
|
6
|
+
// Injection logic mirrors dsh-turn-rewind's collectPortalTargets():
|
|
7
|
+
// 1. Locate message rows with [data-chat-flow-kind="user"][data-chat-anchor-key]
|
|
8
|
+
// or [data-chat-flow-kind="steering"][data-chat-anchor-key] — only user
|
|
9
|
+
// messages carry these flow kinds (AI replies use "assistant-step",
|
|
10
|
+
// "tool-call", etc. and are never selected).
|
|
11
|
+
// 2. Inside the row, find the message actions container (the element
|
|
12
|
+
// holding the copy/branch buttons). We locate it by taking the parent of
|
|
13
|
+
// the row's first <button> (dimension-independent of hashed CSS classes).
|
|
14
|
+
// 3. That parent element becomes the portal target for the undo button.
|
|
15
|
+
//
|
|
16
|
+
// Communication:
|
|
17
|
+
// GET /api/turn-undo?sessionId=...&turn=... -> preview
|
|
18
|
+
// POST /api/turn-undo -> restore
|
|
19
|
+
//
|
|
20
|
+
// See DESIGN.md for full architecture.
|
|
21
|
+
|
|
22
|
+
window.__ModuleLoader__.load({
|
|
23
|
+
id: 'dsh-turn-undo',
|
|
24
|
+
factory: function (require) {
|
|
25
|
+
var module = { exports: {} }
|
|
26
|
+
var exports = module.exports
|
|
27
|
+
|
|
28
|
+
var API_PATH = '/api/turn-undo'
|
|
29
|
+
var USER_ROW_SELECTOR = '[data-chat-flow-kind="user"][data-chat-anchor-key], [data-chat-flow-kind="steering"][data-chat-anchor-key]'
|
|
30
|
+
|
|
31
|
+
// 定位用户/steering 消息的操作行容器。
|
|
32
|
+
// 运行时用户行内没有 data-actions-reveal(该属性只在 turn-tail 上);
|
|
33
|
+
// 直接用稳定结构:取行内第一个 <button>(copy 等)的 parentElement 作为操作行。
|
|
34
|
+
function findIconActions(row) {
|
|
35
|
+
if (!row || row.nodeType !== 1) return null
|
|
36
|
+
var kind = row.getAttribute('data-chat-flow-kind')
|
|
37
|
+
if (kind !== 'user' && kind !== 'steering') return null
|
|
38
|
+
var firstButton = row.querySelector('button')
|
|
39
|
+
if (!firstButton) return null
|
|
40
|
+
var actions = firstButton.parentElement
|
|
41
|
+
if (!actions || actions.nodeType !== 1) return null
|
|
42
|
+
if (actions.querySelectorAll(':scope > button').length < 1) return null
|
|
43
|
+
return actions
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function truncate(text, max) {
|
|
47
|
+
return text.length > max ? text.substring(0, max) + '…' : text
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function kindLabel(kind) {
|
|
51
|
+
switch (kind) {
|
|
52
|
+
case 'created': return '已创建'
|
|
53
|
+
case 'modified': return '已修改'
|
|
54
|
+
case 'deleted': return '已删除'
|
|
55
|
+
default: return '已变更'
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function apply(ctx) {
|
|
60
|
+
var react, reactDom
|
|
61
|
+
try {
|
|
62
|
+
react = require('react')
|
|
63
|
+
reactDom = require('react-dom')
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error('[turn-undo] client skipped: cannot load react', error)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
var h = react.createElement
|
|
69
|
+
var useState = react.useState
|
|
70
|
+
var useEffect = react.useEffect
|
|
71
|
+
var useLayoutEffect = react.useLayoutEffect
|
|
72
|
+
|
|
73
|
+
var STYLE_ID = 'dsh-turn-undo'
|
|
74
|
+
if (!document.getElementById('dsh-turn-undo-styles')) {
|
|
75
|
+
var styleEl = document.createElement('style')
|
|
76
|
+
styleEl.id = 'dsh-turn-undo-styles'
|
|
77
|
+
styleEl.textContent = [
|
|
78
|
+
'.dtu-container{display:inline-flex;align-items:center}',
|
|
79
|
+
'.dtu-trigger{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}',
|
|
80
|
+
'.dtu-trigger:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
|
|
81
|
+
'.dtu-trigger:disabled{cursor:not-allowed;opacity:.5}',
|
|
82
|
+
'.dtu-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:10000}',
|
|
83
|
+
'.dtu-dialog{box-sizing:border-box;width:min(560px,100%);max-height:calc(100dvh - 48px);overflow:auto;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.2)}',
|
|
84
|
+
'.dtu-body{display:flex;flex-direction:column;gap:14px;width:100%;min-width:0;max-width:100%;box-sizing:border-box;padding:18px}',
|
|
85
|
+
'.dtu-header{display:flex;justify-content:space-between;align-items:center;gap:12px}',
|
|
86
|
+
'.dtu-title{margin:0;font-size:18px;font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
87
|
+
'.dtu-close{width:28px;height:28px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:20px;cursor:pointer}.dtu-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
|
|
88
|
+
'.dtu-section-label{font-size:14px;font-weight:600;color:var(--dsw-alias-label-secondary);margin:0 0 8px}',
|
|
89
|
+
'.dtu-msgbox{font-size:14px;color:var(--dsw-alias-label-primary);padding:8px 12px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;line-height:1.5}',
|
|
90
|
+
'.dtu-status{margin:0;overflow-wrap:anywhere;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}',
|
|
91
|
+
'.dtu-files{min-width:0;max-width:100%;box-sizing:border-box;max-height:220px;overflow:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:10px}',
|
|
92
|
+
'.dtu-file{display:flex;justify-content:space-between;gap:16px;min-width:0;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:12px}.dtu-file:last-child{border-bottom:0}',
|
|
93
|
+
'.dtu-file code{min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--dsw-alias-label-secondary)}',
|
|
94
|
+
'.dtu-kind{flex:none;color:var(--dsw-alias-label-tertiary)}',
|
|
95
|
+
'.dtu-warning,.dtu-error{box-sizing:border-box;max-width:100%;margin:0;padding:10px 12px;overflow-wrap:anywhere;word-break:break-word;border-radius:10px;font-size:12px;line-height:18px}',
|
|
96
|
+
'.dtu-warning{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-primary)}',
|
|
97
|
+
'.dtu-error{border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 30%,transparent);color:var(--dsw-alias-state-error-primary)}',
|
|
98
|
+
'.dtu-footer{display:flex;justify-content:flex-end;gap:12px}',
|
|
99
|
+
'.dtu-btn{padding:8px 16px;border:0;border-radius:6px;font-size:14px;cursor:pointer}',
|
|
100
|
+
'.dtu-btn-cancel{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}.dtu-btn-cancel:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
101
|
+
'.dtu-btn-primary{background:var(--dsw-alias-state-business-primary);color:#fff;font-weight:500;min-width:120px}.dtu-btn-primary:hover{opacity:.9}.dtu-btn-primary:disabled{opacity:.5;cursor:not-allowed}',
|
|
102
|
+
].join('')
|
|
103
|
+
document.head.appendChild(styleEl)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function openRestoredSession(sessionId, draftText) {
|
|
107
|
+
try {
|
|
108
|
+
ctx.sessions.open(sessionId)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.warn('[turn-undo] openRestoredSession open failed:', error.message)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (!draftText) return
|
|
114
|
+
function trySetDraft(remaining) {
|
|
115
|
+
if (remaining <= 0) return
|
|
116
|
+
try {
|
|
117
|
+
var scope = ctx.sessions.scope(sessionId)
|
|
118
|
+
if (scope !== undefined) {
|
|
119
|
+
ctx.conversation.input.for(scope).setDraft(draftText)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (remaining <= 1) {
|
|
124
|
+
console.warn('[turn-undo] openRestoredSession setDraft failed:', error.message)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
setTimeout(function () { trySetDraft(remaining - 1) }, 100)
|
|
128
|
+
}
|
|
129
|
+
trySetDraft(20)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
ctx.inject(['slots', 'sessions', 'conversation'], function (scope) {
|
|
133
|
+
scope.effect(function () {
|
|
134
|
+
return scope.slots.inject('conversation.session.header.actions', function () {
|
|
135
|
+
return scope.slots.register({
|
|
136
|
+
name: 'conversation.session.header.actions',
|
|
137
|
+
id: 'turn-undo-portals',
|
|
138
|
+
order: 100,
|
|
139
|
+
inject: function () {
|
|
140
|
+
var sessionsSvc = scope.sessions
|
|
141
|
+
var conversationSvc = scope.conversation
|
|
142
|
+
return {
|
|
143
|
+
openRestoredSession: function (newSessionId, draftText) {
|
|
144
|
+
// Open first: the input shell only exists after the session binding is materialized.
|
|
145
|
+
if (sessionsSvc && sessionsSvc.open) {
|
|
146
|
+
sessionsSvc.open(newSessionId)
|
|
147
|
+
}
|
|
148
|
+
if (!conversationSvc || !conversationSvc.input || !draftText) return
|
|
149
|
+
function trySetDraft(remaining) {
|
|
150
|
+
if (remaining <= 0) return
|
|
151
|
+
try {
|
|
152
|
+
var shell = conversationSvc.input.shell(newSessionId)
|
|
153
|
+
if (shell && typeof shell.setDraft === 'function') {
|
|
154
|
+
shell.setDraft(draftText)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
} catch (e) {
|
|
158
|
+
if (remaining <= 1) {
|
|
159
|
+
console.warn('[turn-undo] Failed to set draft in new session:', e.message)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
setTimeout(function () { trySetDraft(remaining - 1) }, 100)
|
|
163
|
+
}
|
|
164
|
+
trySetDraft(20)
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
}, RestoreMessagePortals)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
function RestoreMessagePortals(props) {
|
|
174
|
+
var sessionId = props.sessionId
|
|
175
|
+
var openRestoredSessionProp = props.openRestoredSession
|
|
176
|
+
var useChat = props.useChat
|
|
177
|
+
var nodes = useChat(function (snapshot) {
|
|
178
|
+
return snapshot.nodes ? snapshot.nodes.values() : []
|
|
179
|
+
})
|
|
180
|
+
var targetsState = useState([])
|
|
181
|
+
var targets = targetsState[0]
|
|
182
|
+
var setTargets = targetsState[1]
|
|
183
|
+
|
|
184
|
+
useLayoutEffect(function () {
|
|
185
|
+
var active = true
|
|
186
|
+
var queued = false
|
|
187
|
+
var refresh = function () {
|
|
188
|
+
if (!active) return
|
|
189
|
+
var next = collectPortalTargets(nodes)
|
|
190
|
+
setTargets(function (current) {
|
|
191
|
+
return samePortalTargets(current, next) ? current : next
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
var queueRefresh = function () {
|
|
195
|
+
if (queued || !active) return
|
|
196
|
+
queued = true
|
|
197
|
+
queueMicrotask(function () {
|
|
198
|
+
queued = false
|
|
199
|
+
refresh()
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
refresh()
|
|
203
|
+
var observer = new MutationObserver(queueRefresh)
|
|
204
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
205
|
+
return function () {
|
|
206
|
+
active = false
|
|
207
|
+
observer.disconnect()
|
|
208
|
+
}
|
|
209
|
+
}, [nodes])
|
|
210
|
+
|
|
211
|
+
var portals = []
|
|
212
|
+
for (var i = 0; i < targets.length; i++) {
|
|
213
|
+
var target = targets[i]
|
|
214
|
+
portals.push(reactDom.createPortal(
|
|
215
|
+
h(RestoreMessageAction, {
|
|
216
|
+
matched: target.matched,
|
|
217
|
+
sessionId: sessionId,
|
|
218
|
+
openRestoredSession: openRestoredSessionProp,
|
|
219
|
+
}),
|
|
220
|
+
target.container,
|
|
221
|
+
sessionId + ':' + String(target.matched.messageSeq),
|
|
222
|
+
))
|
|
223
|
+
}
|
|
224
|
+
return portals
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function RestoreMessageAction(props) {
|
|
228
|
+
var matched = props.matched
|
|
229
|
+
var sessionId = props.sessionId
|
|
230
|
+
var openRestoredSession = props.openRestoredSession
|
|
231
|
+
var messageSeq = matched.messageSeq
|
|
232
|
+
var messageText = matched.promptText
|
|
233
|
+
var openState = useState(false)
|
|
234
|
+
var open = openState[0]
|
|
235
|
+
var setOpen = openState[1]
|
|
236
|
+
var previewState = useState(null)
|
|
237
|
+
var preview = previewState[0]
|
|
238
|
+
var setPreview = previewState[1]
|
|
239
|
+
var loadingState = useState(true)
|
|
240
|
+
var loading = loadingState[0]
|
|
241
|
+
var setLoading = loadingState[1]
|
|
242
|
+
var errorState = useState(null)
|
|
243
|
+
var error = errorState[0]
|
|
244
|
+
var setError = errorState[1]
|
|
245
|
+
var applyingState = useState(false)
|
|
246
|
+
var applying = applyingState[0]
|
|
247
|
+
var setApplying = applyingState[1]
|
|
248
|
+
var doneState = useState(false)
|
|
249
|
+
var done = doneState[0]
|
|
250
|
+
var setDone = doneState[1]
|
|
251
|
+
|
|
252
|
+
useEffect(function () {
|
|
253
|
+
var cancelled = false
|
|
254
|
+
setLoading(true)
|
|
255
|
+
setError(null)
|
|
256
|
+
fetch(API_PATH + '?sessionId=' + encodeURIComponent(sessionId) + '&messageSeq=' + messageSeq + '&promptText=' + encodeURIComponent(messageText), {
|
|
257
|
+
method: 'GET', headers: { 'Accept': 'application/json' }, cache: 'no-store',
|
|
258
|
+
})
|
|
259
|
+
.then(function (res) { return res.json() })
|
|
260
|
+
.then(function (data) { if (!cancelled) setPreview(data) })
|
|
261
|
+
.catch(function (err) { if (!cancelled) setError(err.message || '请求失败') })
|
|
262
|
+
.finally(function () { if (!cancelled) setLoading(false) })
|
|
263
|
+
return function () { cancelled = true }
|
|
264
|
+
}, [])
|
|
265
|
+
|
|
266
|
+
function show() {
|
|
267
|
+
setOpen(true)
|
|
268
|
+
setPreview(null)
|
|
269
|
+
setDone(false)
|
|
270
|
+
setLoading(true)
|
|
271
|
+
// NOTE: cancelled 只存在于 mount effect 闭包,show() 引用它会抛
|
|
272
|
+
// ReferenceError → setLoading(false) 永不执行 → 弹框一直"正在检查…",
|
|
273
|
+
// 且未捕获错误可能导致 React 卸下 portal(按钮消失)。必须无条件收尾。
|
|
274
|
+
fetch(API_PATH + '?sessionId=' + encodeURIComponent(sessionId) + '&messageSeq=' + messageSeq + '&promptText=' + encodeURIComponent(messageText), {
|
|
275
|
+
method: 'GET', headers: { 'Accept': 'application/json' }, cache: 'no-store',
|
|
276
|
+
})
|
|
277
|
+
.then(function (res) { return res.json() })
|
|
278
|
+
.then(function (data) { setPreview(data) })
|
|
279
|
+
.catch(function (err) { setError(err.message || '请求失败') })
|
|
280
|
+
.finally(function () { setLoading(false) })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function close() { if (applying) return; setOpen(false) }
|
|
284
|
+
|
|
285
|
+
function canApply() {
|
|
286
|
+
return preview !== null && !preview.error && !loading && !applying && !done
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function applyRestore() {
|
|
290
|
+
if (!canApply() || applying) return
|
|
291
|
+
setApplying(true)
|
|
292
|
+
setError(null)
|
|
293
|
+
fetch(API_PATH, {
|
|
294
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
295
|
+
body: JSON.stringify({ sessionId: sessionId, messageSeq: messageSeq, promptText: messageText }),
|
|
296
|
+
})
|
|
297
|
+
.then(function (res) { return res.json() })
|
|
298
|
+
.then(function (data) {
|
|
299
|
+
if (data.error && data.ok === false) { setError(data.error); setApplying(false); return }
|
|
300
|
+
setDone(true)
|
|
301
|
+
setApplying(false)
|
|
302
|
+
setOpen(false)
|
|
303
|
+
if (data.newSessionId && openRestoredSession) {
|
|
304
|
+
openRestoredSession(data.newSessionId, messageText)
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
.catch(function (err) { setError(err.message || '请求失败'); setApplying(false) })
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
var changes = (preview && Array.isArray(preview.changes)) ? preview.changes : []
|
|
311
|
+
var noSnapshot = (preview && preview.noSnapshot) === true
|
|
312
|
+
var previewError = (preview && preview.error) ? preview.error : null
|
|
313
|
+
|
|
314
|
+
return h('div', { className: 'dtu-container' },
|
|
315
|
+
h('button', {
|
|
316
|
+
type: 'button', className: 'dtu-trigger',
|
|
317
|
+
title: '恢复到发送这条消息之前', 'aria-label': '恢复到发送这条消息之前',
|
|
318
|
+
onClick: show, disabled: applying,
|
|
319
|
+
},
|
|
320
|
+
h('svg', {
|
|
321
|
+
width: '16', height: '16', viewBox: '0 0 16 16',
|
|
322
|
+
fill: 'none', 'aria-hidden': 'true', style: { display: 'block' },
|
|
323
|
+
},
|
|
324
|
+
h('path', {
|
|
325
|
+
d: 'M6.35 3.25 2.75 7l3.6 3.75M3.1 7h5.15a4.25 4.25 0 0 1 4.25 4.25v1.25',
|
|
326
|
+
stroke: 'currentColor', strokeWidth: '1.45',
|
|
327
|
+
strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
328
|
+
}),
|
|
329
|
+
),
|
|
330
|
+
),
|
|
331
|
+
open ? h(RestoreDialog, {
|
|
332
|
+
sessionId: sessionId, messageText: messageText, turn: messageSeq,
|
|
333
|
+
onClose: close, preview: preview, loading: loading, error: error,
|
|
334
|
+
applying: applying, done: done, changes: changes,
|
|
335
|
+
previewError: previewError, noSnapshot: noSnapshot, canApply: canApply, applyRestore: applyRestore,
|
|
336
|
+
}) : null,
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function RestoreDialog(props) {
|
|
341
|
+
var sessionId = props.sessionId
|
|
342
|
+
var messageText = props.messageText
|
|
343
|
+
var turn = props.turn
|
|
344
|
+
var onClose = props.onClose
|
|
345
|
+
var preview = props.preview
|
|
346
|
+
var loading = props.loading
|
|
347
|
+
var error = props.error
|
|
348
|
+
var applying = props.applying
|
|
349
|
+
var done = props.done
|
|
350
|
+
var changes = props.changes
|
|
351
|
+
var previewError = props.previewError
|
|
352
|
+
var noSnapshot = props.noSnapshot
|
|
353
|
+
var canApply = props.canApply
|
|
354
|
+
var applyRestore = props.applyRestore
|
|
355
|
+
|
|
356
|
+
return reactDom.createPortal(
|
|
357
|
+
h('div', { className: 'dtu-overlay', onClick: onClose },
|
|
358
|
+
h('div', { className: 'dtu-dialog', onClick: function (e) { e.stopPropagation() } },
|
|
359
|
+
h('div', { className: 'dtu-body' },
|
|
360
|
+
h('div', { className: 'dtu-header' },
|
|
361
|
+
h('h3', { className: 'dtu-title' }, '恢复到发送这条消息之前'),
|
|
362
|
+
h('button', { onClick: onClose, className: 'dtu-close', 'aria-label': '关闭' },
|
|
363
|
+
h('span', {}, '×'),
|
|
364
|
+
),
|
|
365
|
+
),
|
|
366
|
+
messageText ? h('div', { className: 'dtu-section' },
|
|
367
|
+
h('div', { className: 'dtu-section-label' }, '消息'),
|
|
368
|
+
h('div', { className: 'dtu-msgbox' }, truncate(messageText, 200)),
|
|
369
|
+
) : null,
|
|
370
|
+
loading ? h('p', { className: 'dtu-status' }, '正在检查可以恢复的项目文件…') : null,
|
|
371
|
+
previewError ? h('p', { className: 'dtu-error' }, previewError) : null,
|
|
372
|
+
(!loading && noSnapshot)
|
|
373
|
+
? h('p', { className: 'dtu-status' }, '该时点没有可用快照,将仅创建新会话。') : null,
|
|
374
|
+
(!loading && !previewError && !noSnapshot && changes.length === 0)
|
|
375
|
+
? h('p', { className: 'dtu-status' }, '这条消息之前没有需要恢复的文件。') : null,
|
|
376
|
+
(!loading && !previewError && changes.length > 0)
|
|
377
|
+
? h('div', { className: 'dtu-section' },
|
|
378
|
+
h('div', { className: 'dtu-section-label' }, '将影响的文件 (' + changes.length + ' 个)'),
|
|
379
|
+
h('div', { className: 'dtu-files' },
|
|
380
|
+
changes.map(function (change, idx) {
|
|
381
|
+
return h('div', { key: idx, className: 'dtu-file' },
|
|
382
|
+
h('code', {}, change.path),
|
|
383
|
+
h('span', { className: 'dtu-kind' }, kindLabel(change.kind)),
|
|
384
|
+
)
|
|
385
|
+
}),
|
|
386
|
+
),
|
|
387
|
+
) : null,
|
|
388
|
+
(!loading && !previewError && changes.length > 0)
|
|
389
|
+
? h('div', { className: 'dtu-warning' },
|
|
390
|
+
'警告:恢复会把文件覆盖到这条消息发送前的状态。'
|
|
391
|
+
) : null,
|
|
392
|
+
(error && !previewError) ? h('p', { className: 'dtu-error' }, error) : null,
|
|
393
|
+
h('div', { className: 'dtu-footer' },
|
|
394
|
+
h('button', { onClick: onClose, className: 'dtu-btn dtu-btn-cancel', disabled: applying }, '取消'),
|
|
395
|
+
h('button', {
|
|
396
|
+
onClick: applyRestore, className: 'dtu-btn dtu-btn-primary',
|
|
397
|
+
disabled: !canApply(),
|
|
398
|
+
}, done ? '已完成' : (applying ? '正在恢复…' : '确认恢复并继续')),
|
|
399
|
+
),
|
|
400
|
+
),
|
|
401
|
+
),
|
|
402
|
+
),
|
|
403
|
+
document.body,
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function selectUndoMessage(node) {
|
|
408
|
+
if (node.kind !== 'user' && node.kind !== 'steering') return null
|
|
409
|
+
if (typeof node.seq !== 'number' || node.seq < 0) return null
|
|
410
|
+
var promptText = ''
|
|
411
|
+
var content = node.content || []
|
|
412
|
+
for (var i = 0; i < content.length; i++) {
|
|
413
|
+
if (content[i].type === 'text' && typeof content[i].text === 'string') {
|
|
414
|
+
promptText += content[i].text + '\n'
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!promptText.trim()) return null
|
|
418
|
+
return { messageSeq: node.seq, promptText: promptText.trim().substring(0, 200) }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function selectUndoMessageTarget(value) {
|
|
422
|
+
var node = ('key' in value && 'data' in value) ? value.data : value
|
|
423
|
+
var matched = selectUndoMessage(node)
|
|
424
|
+
if (matched === null) return null
|
|
425
|
+
return {
|
|
426
|
+
matched: matched,
|
|
427
|
+
rowKey: ('key' in value && 'data' in value) ? value.key : ('node:' + String(node.seq)),
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function collectPortalTargets(nodes) {
|
|
432
|
+
var nodeArr = (nodes && typeof nodes.length === 'number') ? nodes : Array.from(nodes || [])
|
|
433
|
+
var rows = new Map()
|
|
434
|
+
var elements = document.querySelectorAll(USER_ROW_SELECTOR)
|
|
435
|
+
for (var i = 0; i < elements.length; i++) {
|
|
436
|
+
var element = elements[i]
|
|
437
|
+
var key = element.getAttribute('data-chat-anchor-key')
|
|
438
|
+
if (key) rows.set(key, element)
|
|
439
|
+
}
|
|
440
|
+
var targets = []
|
|
441
|
+
for (var i = 0; i < nodeArr.length; i++) {
|
|
442
|
+
var node = nodeArr[i]
|
|
443
|
+
var target = selectUndoMessageTarget(node)
|
|
444
|
+
if (target === null) continue
|
|
445
|
+
var row = rows.get(target.rowKey)
|
|
446
|
+
if (!row) continue
|
|
447
|
+
// 用与 findIconActions 相同的稳定策略定位操作行容器。
|
|
448
|
+
var actions = findIconActions(row)
|
|
449
|
+
if (!actions) continue
|
|
450
|
+
targets.push({ container: actions, matched: target.matched })
|
|
451
|
+
}
|
|
452
|
+
return targets
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function samePortalTargets(left, right) {
|
|
456
|
+
return left.length === right.length && left.every(function (target, index) {
|
|
457
|
+
var other = right[index]
|
|
458
|
+
return other !== undefined
|
|
459
|
+
&& target.container === other.container
|
|
460
|
+
&& target.matched.messageSeq === other.matched.messageSeq
|
|
461
|
+
&& target.matched.promptText === other.matched.promptText
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
exports.apply = apply
|
|
467
|
+
exports.inject = ['slots', 'sessions', 'conversation']
|
|
468
|
+
return module.exports
|
|
469
|
+
},
|
|
470
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# The dsh-turn-undo bundle patch
|
|
2
|
+
# Inserted over whatever layers came before it.
|
|
3
|
+
# A patch replaces the targeted row's whole config, so a profile overriding
|
|
4
|
+
# this row must restate every key it means to keep.
|
|
5
|
+
|
|
6
|
+
- insert:
|
|
7
|
+
- id: turn-undo
|
|
8
|
+
name: dsh-turn-undo
|
|
9
|
+
config: {}
|