dsh-recall-plugin 1.0.0 → 1.0.2
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 +99 -93
- package/lib/client.js +44 -2
- package/lib/index.js +40 -34
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,93 +1,99 @@
|
|
|
1
|
-
# dsh-recall-plugin
|
|
2
|
-
|
|
3
|
-
> 撤回一条消息,项目文件也一起回去。
|
|
4
|
-
|
|
5
|
-

|
|
19
|
-
|
|
20
|
-
---
|
|
21
|
-
| 确认面板 · 变更文件清单| |
|
|
22
|
-
| --- | --- |
|
|
23
|
-
|  |  |
|
|
24
|
-
|
|
25
|
-
## 功能亮点
|
|
26
|
-
|
|
27
|
-
- **文件 + 对话,整段回退**:撤回的不只是聊天记录,agent 改过的文件也一并回到原样。
|
|
28
|
-
- **不碰你项目自己的 git**:快照存在独立的影子 git 仓库里,你的分支、暂存区、未提交改动统统不受影响;`.git`、`node_modules` 自动排除。
|
|
29
|
-
-
|
|
30
|
-
- **可以反复后悔**:快照全量保留、永不修剪。撤回一次后还能再撤到更早;撤回时被覆盖的文件也一直找得回来。
|
|
31
|
-
- **先看清单再动手**:点撤回先弹出将变更的文件清单(修改 / 恢复 / 删除),确认后才执行,不会稀里糊涂覆盖。
|
|
32
|
-
- **磁盘友好**:快照走 git delta 压缩,是增量不是整目录拷贝;超过 100MB 的大文件自动跳过。
|
|
33
|
-
|
|
34
|
-
##
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
-
|
|
75
|
-
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
1
|
+
# dsh-recall-plugin
|
|
2
|
+
|
|
3
|
+
> 撤回一条消息,项目文件也一起回去。
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
**在任意一条你发过的消息下方**,**点「↶ 撤回」**,**工作区文件和对话历史一起回到那条消息发出之前的状态**。
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## 界面预览
|
|
16
|
+
- 撤回按钮位置
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
| 确认面板 · 变更文件清单| |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
|  |  |
|
|
24
|
+
|
|
25
|
+
## 功能亮点
|
|
26
|
+
|
|
27
|
+
- **文件 + 对话,整段回退**:撤回的不只是聊天记录,agent 改过的文件也一并回到原样。
|
|
28
|
+
- **不碰你项目自己的 git**:快照存在独立的影子 git 仓库里,你的分支、暂存区、未提交改动统统不受影响;`.git`、`node_modules` 自动排除。
|
|
29
|
+
- **项目目录保持干净**:快照始终存在 `$DSH_HOME` 下,不会往项目里塞任何东西;与会话的沙箱权限无关(workspace-write / read-only 会话照常快照与回退),仅当 home 本身不可写(如指到只读盘)才降级到项目内 `.dsh-recall-snapshots`(降级时页面会提示),home 恢复后自动迁走、清理干净。
|
|
30
|
+
- **可以反复后悔**:快照全量保留、永不修剪。撤回一次后还能再撤到更早;撤回时被覆盖的文件也一直找得回来。
|
|
31
|
+
- **先看清单再动手**:点撤回先弹出将变更的文件清单(修改 / 恢复 / 删除),确认后才执行,不会稀里糊涂覆盖。
|
|
32
|
+
- **磁盘友好**:快照走 git delta 压缩,是增量不是整目录拷贝;超过 100MB 的大文件自动跳过。
|
|
33
|
+
|
|
34
|
+
## 已知限制
|
|
35
|
+
|
|
36
|
+
- 快照在**消息发送时**创建,插件启用前的历史消息没有快照,不显示撤回按钮。
|
|
37
|
+
- 会话第一条用户消息无法回退对话(仅文件回退),因为 fork 需要更早的 turn 边界。
|
|
38
|
+
- 仅支持 Windows(脚本用 PowerShell + git CLI);Linux/macOS 下插件不报错,但快照不工作、不显示撤回按钮。
|
|
39
|
+
- 工作区内嵌套的其他 git 仓库(子目录自带 `.git`)不进快照,其内容不参与回退。
|
|
40
|
+
|
|
41
|
+
## 安装
|
|
42
|
+
|
|
43
|
+
前置:Windows + git CLI(未装 git 时撤回按钮不出现,页面顶部会提示安装 git,不影响 DSH 运行);PowerShell 5.1 / 7 均可;DSH 0.1.0-rc.x(依赖版本见 `peerDependencies`)。
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
- DSH 官方插件命令:安装并自动挂载进 web profile
|
|
47
|
+
```powershell
|
|
48
|
+
dsh plugin --profile web add dsh-recall-plugin
|
|
49
|
+
```
|
|
50
|
+
- 也可从 git 直接安装(纯 JS 无构建,免 prepare/allowBuilds):
|
|
51
|
+
```powershell
|
|
52
|
+
dsh plugin --profile web add github:limbo947/DSH-recall-plugin
|
|
53
|
+
```
|
|
54
|
+
- 重启 DSH 进程(按你的启动方式,任选其一)
|
|
55
|
+
```powershell
|
|
56
|
+
dsh web # 前台直接启动
|
|
57
|
+
pm2 restart <你的dsh进程名> # 若用 pm2 托管
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**验证**:重启后硬刷新页面(Ctrl+Shift+R),悬停任意一条插件启用后发送的用户消息——复制按钮旁出现「↶」即生效。没有按钮?九成是没重启 DSH 进程,或 git CLI 不在 PATH 里。
|
|
61
|
+
|
|
62
|
+
**卸载**:`dsh plugin --profile web remove dsh-recall-plugin`(同时移除依赖与挂载层),快照数据保留在 `$DSH_HOME/dsh-recall-snapshots/`,想彻底清除手动删掉该目录即可。
|
|
63
|
+
|
|
64
|
+
## 使用
|
|
65
|
+
|
|
66
|
+
1. 鼠标悬停任意**插件启用后发送**的用户消息,复制按钮左侧出现「↶ 撤回」。
|
|
67
|
+
2. 点击 → 确认面板展示将变更的文件清单(修改 / 恢复 / 删除)。
|
|
68
|
+
3. 点「确认回退」→ 文件恢复到该消息发送前的状态;视图切到新会话(该消息及之后的对话移除),原会话归档、随时可找回。
|
|
69
|
+
|
|
70
|
+
## 工作原理
|
|
71
|
+
|
|
72
|
+
每条用户消息发送时(agent 动文件之前),工作区被快照进一个独立的影子 git 仓库;撤回时用 `git archive` 恢复文件、通过 DSH 官方 `sessions.fork` 机制把会话切到该消息之前。二进制安全,全程不触碰项目自身的 git 状态。
|
|
73
|
+
|
|
74
|
+
- 快照存储:`$DSH_HOME/dsh-recall-snapshots/<SHA256(项目绝对路径)>/`,内含影子 git 仓库(`git/`,tag 名为 `snap-<消息ID>`)与索引文件 `index.json`(消息 ID → 快照时间 / 会话)。
|
|
75
|
+
- 想直接翻历史快照:
|
|
76
|
+
|
|
77
|
+
```powershell
|
|
78
|
+
git --git-dir="<store>\git\.git" tag -l
|
|
79
|
+
git --git-dir="<store>\git\.git" ls-tree -r --name-only snap-<消息ID>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
## 本地开发(无需发布)
|
|
85
|
+
|
|
86
|
+
```powershell
|
|
87
|
+
# 把包目录放进 web profile 的 node_modules,并登记到 bundles
|
|
88
|
+
$pkg = '<你的仓库克隆路径>\dsh-recall-plugin'
|
|
89
|
+
$profile = "$env:USERPROFILE\.dsh\profiles\web"
|
|
90
|
+
Copy-Item -Recurse -Force $pkg "$profile\node_modules\dsh-recall-plugin"
|
|
91
|
+
# 手动编辑 $profile\package.json:
|
|
92
|
+
# dependencies 加 "dsh-recall-plugin": "1.0.0"
|
|
93
|
+
# dsh.profile.bundles 加 "dsh-recall-plugin"
|
|
94
|
+
# 然后重启 DSH 并硬刷新页面
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
package/lib/client.js
CHANGED
|
@@ -59,7 +59,10 @@ window.__ModuleLoader__.load({
|
|
|
59
59
|
'.dsh-recall-btn{border:none;border-radius:8px;padding:5px 14px;font-size:13px;line-height:20px;cursor:pointer;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}',
|
|
60
60
|
'.dsh-recall-btn:hover{color:var(--dsw-alias-label-primary)}',
|
|
61
61
|
'.dsh-recall-btn-danger{background:var(--dsw-alias-state-error-primary);color:#fff}',
|
|
62
|
-
'.dsh-recall-btn-danger:hover{color:#fff;filter:brightness(1.08)}'
|
|
62
|
+
'.dsh-recall-btn-danger:hover{color:#fff;filter:brightness(1.08)}',
|
|
63
|
+
'.dsh-recall-toast{position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:10000;max-width:min(560px,86vw);box-sizing:border-box;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:10px 16px;font-size:13px;line-height:20px;box-shadow:0 8px 28px rgba(0,0,0,.22);display:flex;align-items:baseline;gap:8px;opacity:0;transition:opacity .25s ease;pointer-events:auto}',
|
|
64
|
+
'.dsh-recall-toast.dsh-recall-toast-in{opacity:1}',
|
|
65
|
+
'.dsh-recall-toast-tag{flex:none;font-weight:600;color:var(--dsw-alias-state-error-primary)}'
|
|
63
66
|
].join('')
|
|
64
67
|
// 静态 bundle 的 ctx 可能不提供 styles 服务,降级为直接注入 <style>
|
|
65
68
|
const stylesSvc = ctx.get('styles')
|
|
@@ -75,6 +78,37 @@ window.__ModuleLoader__.load({
|
|
|
75
78
|
let initedSessionId = null
|
|
76
79
|
let initDone = Promise.resolve()
|
|
77
80
|
|
|
81
|
+
// 降级提示:每个种类每次页面加载只弹一次(Set 去重),避免切会话时
|
|
82
|
+
// 反复打扰。纯 DOM 直插(与剪贴板同样的零依赖思路),7 秒后自动淡出。
|
|
83
|
+
const noticeShown = new Set()
|
|
84
|
+
function showNotice(kind, text) {
|
|
85
|
+
if (noticeShown.has(kind) || typeof document === 'undefined') return
|
|
86
|
+
noticeShown.add(kind)
|
|
87
|
+
try {
|
|
88
|
+
const el = document.createElement('div')
|
|
89
|
+
el.className = 'dsh-recall-toast'
|
|
90
|
+
const tag = document.createElement('span')
|
|
91
|
+
tag.className = 'dsh-recall-toast-tag'
|
|
92
|
+
tag.textContent = '撤回插件'
|
|
93
|
+
const body = document.createElement('span')
|
|
94
|
+
body.textContent = text
|
|
95
|
+
el.appendChild(tag)
|
|
96
|
+
el.appendChild(body)
|
|
97
|
+
el.addEventListener('click', () => dismiss(), { once: true })
|
|
98
|
+
document.body.appendChild(el)
|
|
99
|
+
requestAnimationFrame(() => el.classList.add('dsh-recall-toast-in'))
|
|
100
|
+
const timer = setTimeout(dismiss, 7000)
|
|
101
|
+
let dismissed = false
|
|
102
|
+
function dismiss() {
|
|
103
|
+
if (dismissed) return
|
|
104
|
+
dismissed = true
|
|
105
|
+
clearTimeout(timer)
|
|
106
|
+
el.classList.remove('dsh-recall-toast-in')
|
|
107
|
+
setTimeout(() => el.remove(), 300)
|
|
108
|
+
}
|
|
109
|
+
} catch (e) { /* 提示失败不影响主流程 */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
78
112
|
// 每个会话只向 Host 注册一次(预热其根目录解析缓存)。
|
|
79
113
|
// 返回 init 的 promise:Host 端 init 要跑数条 PowerShell(建仓/loadIndex),
|
|
80
114
|
// snapshot-info 必须等它完成后再查,否则冷启动时索引尚未载入会误判
|
|
@@ -82,7 +116,15 @@ window.__ModuleLoader__.load({
|
|
|
82
116
|
function ensureInit(sessionId) {
|
|
83
117
|
if (!sessionId || initedSessionId === sessionId) return initDone
|
|
84
118
|
initedSessionId = sessionId
|
|
85
|
-
initDone = api('init', { sessionId }).
|
|
119
|
+
initDone = api('init', { sessionId }).then((res) => {
|
|
120
|
+
const notice = res && res.notice
|
|
121
|
+
if (notice && notice.gitMissing) {
|
|
122
|
+
showNotice('git', '未检测到 git CLI,撤回功能不可用(快照引擎依赖 git)。安装 git 并重启 DSH 后即可使用。')
|
|
123
|
+
}
|
|
124
|
+
if (notice && notice.homeFallback) {
|
|
125
|
+
showNotice('home', 'home 目录不可写,快照已降级存储到项目内 .dsh-recall-snapshots 目录。')
|
|
126
|
+
}
|
|
127
|
+
}).catch(() => {})
|
|
86
128
|
return initDone
|
|
87
129
|
}
|
|
88
130
|
|
package/lib/index.js
CHANGED
|
@@ -40,19 +40,22 @@ export function apply(ctx) {
|
|
|
40
40
|
return "'" + String(value).replace(/'/g, "''") + "'"
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
// 所有 shell 调用都以宿主身份(danger-full-access)执行,不借用会话沙箱。
|
|
44
|
+
// 为什么安全:DSH 沙箱约束的是「模型驱动」的文件效果,而本插件的命令全部
|
|
45
|
+
// 是宿主侧固定模板(建仓/快照/索引/回退),命令串里唯一变量是插件自己
|
|
46
|
+
// 推导的路径(会话 cwd、哈希出的 store 路径、消息 ID),模型无法注入任何
|
|
47
|
+
// 内容;快照落盘的也只是会话本就有权读取的工作区文件副本,不扩大能力。
|
|
48
|
+
// 为什么必须如此:若按会话解析策略,workspace-write/read-only 会话写不了
|
|
49
|
+
// home,快照被迫降级进项目目录(污染);read-only 会话连项目都写不了,
|
|
50
|
+
// 回退恢复直接失败。pwsh-sandbox 对 danger-full-access 直接不约束(等价
|
|
51
|
+
// 本地执行器),无沙箱后端的部署则忽略该字段,两种环境都成立。
|
|
52
|
+
async function runShell(command, opts) {
|
|
46
53
|
const sp = ctx.get('sandboxPolicy')
|
|
47
|
-
if (sp) {
|
|
48
|
-
const session = sessionId ? sessions.get(sessionId) : undefined
|
|
49
|
-
policy = sp.resolve(session ? { session } : {})
|
|
50
|
-
}
|
|
51
54
|
const spec = shell.resolve({
|
|
52
55
|
command,
|
|
53
56
|
timeoutMs: (opts && opts.timeoutMs) || 300000,
|
|
54
57
|
stdoutMaxBytes: (opts && opts.stdoutMaxBytes) || 4194304,
|
|
55
|
-
|
|
58
|
+
sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: (sp && sp.workspaceRoot) || process.cwd() }
|
|
56
59
|
})
|
|
57
60
|
const res = await shell.run(spec)
|
|
58
61
|
const out = (res && res.stdout && res.stdout.text) || ''
|
|
@@ -84,18 +87,12 @@ export function apply(ctx) {
|
|
|
84
87
|
return root
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
// 会话查找(按 cwd 匹配),用于索引读写与 git 操作的沙箱策略
|
|
88
|
-
function sessionForRoot(root) {
|
|
89
|
-
const found = sessions.list().find((s) => s && s.header && s.header.cwd === root)
|
|
90
|
-
return found ? found.id : null
|
|
91
|
-
}
|
|
92
|
-
|
|
93
90
|
// 解析 git 可执行文件路径:DSH 进程 PATH 可能不含 git,
|
|
94
91
|
// 求值一次并缓存,脚本里用绝对路径调用,避免每条命令依赖 PATH。
|
|
95
92
|
async function resolveGit() {
|
|
96
93
|
if (state.gitExe !== null) return state.gitExe
|
|
97
94
|
try {
|
|
98
|
-
let path = stripBom(await runShell("$g = (Get-Command git -ErrorAction SilentlyContinue).Source; if (-not $g -and (Test-Path -LiteralPath 'C:\\Program Files\\Git\\cmd\\git.exe')) { $g = 'C:\\Program Files\\Git\\cmd\\git.exe' }; if ($g) { Write-Output $g }", { stdoutMaxBytes: 4096 }
|
|
95
|
+
let path = stripBom(await runShell("$g = (Get-Command git -ErrorAction SilentlyContinue).Source; if (-not $g -and (Test-Path -LiteralPath 'C:\\Program Files\\Git\\cmd\\git.exe')) { $g = 'C:\\Program Files\\Git\\cmd\\git.exe' }; if ($g) { Write-Output $g }", { stdoutMaxBytes: 4096 })).trim()
|
|
99
96
|
state.gitExe = path || ''
|
|
100
97
|
} catch (error) {
|
|
101
98
|
state.gitExe = ''
|
|
@@ -115,7 +112,7 @@ export function apply(ctx) {
|
|
|
115
112
|
"$hex = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($r))) -replace '-','').ToLower()",
|
|
116
113
|
"Write-Output (Join-Path $h ('dsh-recall-snapshots\\' + $hex))"
|
|
117
114
|
].join('\n')
|
|
118
|
-
const text = stripBom(await runShell(dirScript, { stdoutMaxBytes: 4096 }
|
|
115
|
+
const text = stripBom(await runShell(dirScript, { stdoutMaxBytes: 4096 })).trim()
|
|
119
116
|
if (!text) return null
|
|
120
117
|
// PS 的 Join-Path 可能带出连续反斜杠(旧版脚本遗留 "…\\<hex>" 形态);
|
|
121
118
|
// 折叠成单反斜杠。Windows 把中间的重复分隔符视为同一个目录,
|
|
@@ -123,8 +120,9 @@ export function apply(ctx) {
|
|
|
123
120
|
return text.replace(/\\{2,}/g, '\\')
|
|
124
121
|
}
|
|
125
122
|
|
|
126
|
-
// 存储根:优先放 DSH home
|
|
127
|
-
// workspace-write
|
|
123
|
+
// 存储根:优先放 DSH home(保持项目目录干净)。shell 以宿主身份执行,
|
|
124
|
+
// 受限会话(workspace-write/read-only)也能写 home;只有 home 本身不可写
|
|
125
|
+
// (如 DSH_HOME 指向只读/网络盘)才降级到项目内(功能优先于干净)。
|
|
128
126
|
// git init <dir> 会把真实 git-dir 建在 <dir>\.git,所以 store.repo 是
|
|
129
127
|
// 仓库工作目录、store.git 是真实 git-dir——冒烟测试踩过的坑。
|
|
130
128
|
async function resolveStore(root, sessionId) {
|
|
@@ -138,7 +136,7 @@ export function apply(ctx) {
|
|
|
138
136
|
}
|
|
139
137
|
if (homeDir) {
|
|
140
138
|
try {
|
|
141
|
-
await runShell('New-Item -ItemType Directory -Force -Path ' + psq(homeDir) + ' | Out-Null', { stdoutMaxBytes: 4096 }
|
|
139
|
+
await runShell('New-Item -ItemType Directory -Force -Path ' + psq(homeDir) + ' | Out-Null', { stdoutMaxBytes: 4096 })
|
|
142
140
|
const store = { dir: homeDir, repo: homeDir + '\\git', git: homeDir + '\\git\\.git', home: true }
|
|
143
141
|
state.stores.set(root, store)
|
|
144
142
|
return store
|
|
@@ -147,15 +145,15 @@ export function apply(ctx) {
|
|
|
147
145
|
}
|
|
148
146
|
}
|
|
149
147
|
const fallback = root + '\\.dsh-recall-snapshots'
|
|
150
|
-
await runShell('New-Item -ItemType Directory -Force -Path ' + psq(fallback) + ' | Out-Null', { stdoutMaxBytes: 4096 }
|
|
148
|
+
await runShell('New-Item -ItemType Directory -Force -Path ' + psq(fallback) + ' | Out-Null', { stdoutMaxBytes: 4096 })
|
|
151
149
|
const store = { dir: fallback, repo: fallback + '\\git', git: fallback + '\\git\\.git', home: false }
|
|
152
150
|
state.stores.set(root, store)
|
|
153
151
|
return store
|
|
154
152
|
}
|
|
155
153
|
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
154
|
+
// 旧版迁移:宿主身份执行前的版本在受限会话里会把影子仓库降级到项目内,
|
|
155
|
+
// 这里在下一条消息快照前把它整体迁回 home 并删除项目内目录,恢复
|
|
156
|
+
// 「项目目录干净」。失败节流 5 分钟,避免 home 不可写时每条消息白试。
|
|
159
157
|
async function tryUpgradeToHome(root, sessionId) {
|
|
160
158
|
const store = state.stores.get(root)
|
|
161
159
|
if (!store || store.home) return store
|
|
@@ -171,7 +169,7 @@ export function apply(ctx) {
|
|
|
171
169
|
}
|
|
172
170
|
if (!homeDir) return store
|
|
173
171
|
try {
|
|
174
|
-
await runShell('New-Item -ItemType Directory -Force -Path ' + psq(homeDir) + ' | Out-Null', { stdoutMaxBytes: 4096 }
|
|
172
|
+
await runShell('New-Item -ItemType Directory -Force -Path ' + psq(homeDir) + ' | Out-Null', { stdoutMaxBytes: 4096 })
|
|
175
173
|
const migrate = [
|
|
176
174
|
"$ErrorActionPreference = 'Stop'",
|
|
177
175
|
'$src = ' + psq(store.dir),
|
|
@@ -181,7 +179,7 @@ export function apply(ctx) {
|
|
|
181
179
|
'Remove-Item -Recurse -Force -LiteralPath $src -ErrorAction SilentlyContinue',
|
|
182
180
|
"Write-Output 'MIGRATE_OK'"
|
|
183
181
|
].join('\n')
|
|
184
|
-
await runShell(migrate, { timeoutMs: 300000, stdoutMaxBytes: 4096 }
|
|
182
|
+
await runShell(migrate, { timeoutMs: 300000, stdoutMaxBytes: 4096 })
|
|
185
183
|
const upgraded = { dir: homeDir, repo: homeDir + '\\git', git: homeDir + '\\git\\.git', home: true }
|
|
186
184
|
state.stores.set(root, upgraded)
|
|
187
185
|
state.gitReady.delete(store.git)
|
|
@@ -223,7 +221,7 @@ export function apply(ctx) {
|
|
|
223
221
|
"Write-Output 'GIT_OK'"
|
|
224
222
|
].join('\n')
|
|
225
223
|
try {
|
|
226
|
-
await runShell(script, { stdoutMaxBytes: 4096 }
|
|
224
|
+
await runShell(script, { stdoutMaxBytes: 4096 })
|
|
227
225
|
state.gitReady.add(store.git)
|
|
228
226
|
return true
|
|
229
227
|
} catch (error) {
|
|
@@ -402,7 +400,7 @@ export function apply(ctx) {
|
|
|
402
400
|
const json = JSON.stringify(entries)
|
|
403
401
|
try {
|
|
404
402
|
const b64 = Buffer.from(json, 'utf8').toString('base64')
|
|
405
|
-
await runShell("New-Item -ItemType Directory -Force -Path " + psq(store.dir) + " | Out-Null; [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64 + "')) | Set-Content -LiteralPath " + psq(store.dir + '\\index.json') + ' -Encoding utf8 -NoNewline', { stdoutMaxBytes: 4096 }
|
|
403
|
+
await runShell("New-Item -ItemType Directory -Force -Path " + psq(store.dir) + " | Out-Null; [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64 + "')) | Set-Content -LiteralPath " + psq(store.dir + '\\index.json') + ' -Encoding utf8 -NoNewline', { stdoutMaxBytes: 4096 })
|
|
406
404
|
} catch (error) {
|
|
407
405
|
console.error('recall saveIndex failed:', String(error))
|
|
408
406
|
}
|
|
@@ -414,7 +412,7 @@ export function apply(ctx) {
|
|
|
414
412
|
const store = state.stores.get(root)
|
|
415
413
|
if (!store) return
|
|
416
414
|
try {
|
|
417
|
-
const raw = stripBom(await runShell('Get-Content -LiteralPath ' + psq(store.dir + '\\index.json') + ' -Raw -ErrorAction SilentlyContinue', { stdoutMaxBytes: 4194304 }
|
|
415
|
+
const raw = stripBom(await runShell('Get-Content -LiteralPath ' + psq(store.dir + '\\index.json') + ' -Raw -ErrorAction SilentlyContinue', { stdoutMaxBytes: 4194304 })).trim()
|
|
418
416
|
if (!raw) return
|
|
419
417
|
const entries = JSON.parse(raw)
|
|
420
418
|
if (!Array.isArray(entries)) return
|
|
@@ -438,7 +436,7 @@ export function apply(ctx) {
|
|
|
438
436
|
const gitExe = await resolveGit()
|
|
439
437
|
if (!store || !gitExe) return
|
|
440
438
|
try {
|
|
441
|
-
const listing = stripBom(await runShell(listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 }
|
|
439
|
+
const listing = stripBom(await runShell(listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
|
|
442
440
|
if (!listing) return
|
|
443
441
|
for (const name of listing.split(/\r?\n/)) {
|
|
444
442
|
const id = name.trim().replace(/^snap-/, '')
|
|
@@ -456,7 +454,7 @@ export function apply(ctx) {
|
|
|
456
454
|
function cleanupLegacy(root, sessionId) {
|
|
457
455
|
const store = state.stores.get(root)
|
|
458
456
|
if (!store || !store.home) return
|
|
459
|
-
runShell('Remove-Item -Recurse -Force -LiteralPath ' + psq(root + '\\.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }
|
|
457
|
+
runShell('Remove-Item -Recurse -Force -LiteralPath ' + psq(root + '\\.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }).catch(() => {})
|
|
460
458
|
}
|
|
461
459
|
|
|
462
460
|
async function captureSnapshot(sessionId, messageId, time) {
|
|
@@ -468,7 +466,7 @@ export function apply(ctx) {
|
|
|
468
466
|
if (!ok) return
|
|
469
467
|
await loadIndex(root, sessionId)
|
|
470
468
|
try {
|
|
471
|
-
await runShell(snapshotScript(root, store, state.gitExe, messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 }
|
|
469
|
+
await runShell(snapshotScript(root, store, state.gitExe, messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
|
|
472
470
|
state.snapshots.set(String(messageId), { root, time: time || Date.now(), count: 0, sessionId })
|
|
473
471
|
await saveIndex(root, sessionId)
|
|
474
472
|
} catch (error) {
|
|
@@ -481,7 +479,7 @@ export function apply(ctx) {
|
|
|
481
479
|
if (!snap) return null
|
|
482
480
|
const store = state.stores.get(snap.root)
|
|
483
481
|
if (!store) return null
|
|
484
|
-
const text = stripBom(await runShell(diffScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 4194304 }
|
|
482
|
+
const text = stripBom(await runShell(diffScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 4194304 }))
|
|
485
483
|
const trimmed = text.trim()
|
|
486
484
|
if (!trimmed) return []
|
|
487
485
|
const parsed = JSON.parse(trimmed)
|
|
@@ -495,7 +493,7 @@ export function apply(ctx) {
|
|
|
495
493
|
if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
|
|
496
494
|
const store = state.stores.get(snap.root)
|
|
497
495
|
if (!store) return { ok: false, error: '快照存储不可用' }
|
|
498
|
-
const text = stripBom(await runShell(rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 }
|
|
496
|
+
const text = stripBom(await runShell(rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
|
|
499
497
|
const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
|
|
500
498
|
const deleted = m ? parseInt(m[1], 10) : 0
|
|
501
499
|
const restored = m ? parseInt(m[2], 10) : 0
|
|
@@ -572,6 +570,7 @@ export function apply(ctx) {
|
|
|
572
570
|
if (name === 'init') {
|
|
573
571
|
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
574
572
|
const root = await resolveRoot(sessionId)
|
|
573
|
+
let notice = null
|
|
575
574
|
if (root) {
|
|
576
575
|
let store = await resolveStore(root, sessionId)
|
|
577
576
|
store = await tryUpgradeToHome(root, sessionId)
|
|
@@ -579,8 +578,15 @@ export function apply(ctx) {
|
|
|
579
578
|
await loadIndex(root, sessionId)
|
|
580
579
|
await rebuildOrphans(root, sessionId)
|
|
581
580
|
cleanupLegacy(root, sessionId)
|
|
581
|
+
// 降级状态随 init 下发,Client 弹一次性提示(每次页面加载各弹一次):
|
|
582
|
+
// gitMissing=未检测到 git CLI(撤回按钮不出现);homeFallback=home
|
|
583
|
+
// 不可写,快照降级存进项目内 .dsh-recall-snapshots。
|
|
584
|
+
notice = {
|
|
585
|
+
gitMissing: state.gitExe === '',
|
|
586
|
+
homeFallback: store ? !store.home : false
|
|
587
|
+
}
|
|
582
588
|
}
|
|
583
|
-
sendJson(res, 200, { ok: Boolean(root), root: root || null })
|
|
589
|
+
sendJson(res, 200, { ok: Boolean(root), root: root || null, notice })
|
|
584
590
|
return
|
|
585
591
|
}
|
|
586
592
|
if (name === 'snapshot-info') {
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-recall-plugin",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/limbo947/
|
|
8
|
+
"url": "git+https://github.com/limbo947/DSH-recall-plugin.git"
|
|
9
9
|
},
|
|
10
10
|
"publishConfig": {
|
|
11
11
|
"access": "public"
|
|
@@ -49,7 +49,9 @@
|
|
|
49
49
|
"react": "^18.2.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependenciesMeta": {
|
|
52
|
-
"cordis": {
|
|
52
|
+
"cordis": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
53
55
|
},
|
|
54
56
|
"license": "MIT"
|
|
55
57
|
}
|