dsh-recall-plugin 2.6.0 → 2.6.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/CHANGELOG.md +15 -1
- package/README.md +1 -0
- package/lib/client.js +85 -20
- package/lib/config.js +7 -1
- package/lib/dump-parse.js +6 -1
- package/lib/errors.js +3 -0
- package/lib/index.js +10 -3
- package/lib/maintenance.js +3 -1
- package/lib/routes-core.js +6 -3
- package/lib/routes-manage.js +38 -10
- package/lib/scripts.posix.js +12 -0
- package/lib/scripts.pwsh.js +8 -1
- package/lib/snapshots.js +24 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
|
4
4
|
|
|
5
|
-
## [
|
|
5
|
+
## [2.6.1] - 2026-10-31
|
|
6
|
+
|
|
7
|
+
### 新增
|
|
8
|
+
|
|
9
|
+
- **「只回退消息」模式(`messageOnly`,默认关闭,issue #31)**:开启后每条用户消息仍登记撤回记录(撤回按钮与对话回退照常),但不再执行文件快照——大仓(gitignore 过滤后仍上千文件 / 上 GB)不必为每条消息写一次全量对象。按消息生效:`index.json` 只给这类条目记 `files: false`,开启前拍的旧快照仍可完整回退文件;确认面板对这类消息隐藏「回退文件与对话」单选,快照管理树标「仅消息」。代价是这类记录没有 tag,索引损坏被隔离后撤回按钮无法从 tag 重建。
|
|
10
|
+
|
|
11
|
+
### 修复
|
|
12
|
+
|
|
13
|
+
- **磁盘枚举失败不再表现成「没有快照」**:此前快照管理读不到磁盘时会静默返回空列表,界面只剩一棵空树,用户以为快照都没了(issue #30 报告的现场就是这样)。现在失败原因进「最近错误」,列表 / 按工作区或会话批量删除 / 全部删除回 `DUMP_FAILED`,卡片显示可重试的错误条;「全部删除」也不再用「已删除 0 条」掩盖枚举失败。
|
|
14
|
+
- **建过仓但从没拍过快照的空仓不再永久占盘**:dump 脚本为每个 store 增发 `STATE` 段(`present` / `absent` / `quarantined` / `foreign`),「立即 gc」据此把「有 store 布局但从未生成索引」的空壳整目录回收;损坏隔离现场(`index.json.corrupt-*`)、旧版脚本输出与非本插件的目录一律保留。
|
|
15
|
+
|
|
16
|
+
### 文档
|
|
17
|
+
|
|
18
|
+
- 新增 [plan-dump-visible-and-empty-reclaim.md](docs/plans/completed/plan-dump-visible-and-empty-reclaim.md):两条遗留的问题、判据表与测试清单;AGENTS 已知坑补 I46,compat-audit 补 I46 详情。
|
|
19
|
+
- 新增 [plan-message-only-mode.md](docs/plans/completed/plan-message-only-mode.md):只回退消息的问题、判据、边界与实施记录;smoke checklist 第十一节与 records 同日批次(M1〜M6 实弹)。
|
|
6
20
|
|
|
7
21
|
## [2.6.0] - 2026-10-30
|
|
8
22
|
|
package/README.md
CHANGED
|
@@ -161,6 +161,7 @@ peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases`
|
|
|
161
161
|
| `baseExcludes` | `.git`、`node_modules/`、`.dsh-recall-snapshots/`、`dsh-recall-snapshots/`、`target/`、`dist/`、`build/`、`out/`、`coverage/`、`.next/`、`.nuxt/`、`.output/`、`.cache/`、`.gradle/`、`*.exe`、`*.dll`、`*.pdb`、`*.so`、`*.dylib`、`*.msi`、`*.zip`、`*.7z`、`*.rar`、`*.tar`、`*.tar.gz`、`*.iso` | 基础排除表(gitignore 语法,优先级低于 exclude.txt);被排除的大目录整棵子树不进快照扫描。其中「目录形态」项(如 `target/`)另有一层含义:**工作区根自身的路径段命中时,该工作区不启用快照**——在构建产物目录里开会话时排除表本来就管不到它自己(模式是相对该 root 的),这类目录也没有回退价值;删掉对应项即恢复 |
|
|
162
162
|
| `refillDraft` | true | 撤回后把被撤回的消息(文本与附件)回填到输入框 |
|
|
163
163
|
| `snapshotEnabled` | true | 快照总开关(关闭只冻结新建,已有快照仍可撤回) |
|
|
164
|
+
| `messageOnly` | false | 只回退消息:每条用户消息仍登记撤回记录(按钮与对话回退照常),但不写文件快照——适合 gitignore 过滤后仍上 GB 的超大工作区。按消息生效:开启前的旧快照仍可完整回退文件;这类条目在快照管理里标「仅消息」,删除后不可从 tag 恢复 |
|
|
164
165
|
| `archiveOriginal` | true | 撤回后归档原会话(关闭后原会话保留在会话列表中) |
|
|
165
166
|
| `editSubmitsImmediately` | true | 「编辑并重发」提交后自动发出改后的消息;关闭则只回填输入框,由你按回车发送 |
|
|
166
167
|
| `locale` | auto | 界面语言:`auto` 跟随系统(`navigator.language` 以 `zh` 开头用中文,否则英文)、`zh` / `en` 显式锁定。见下方「界面语言」 |
|
package/lib/client.js
CHANGED
|
@@ -218,6 +218,10 @@
|
|
|
218
218
|
// 对齐;此前复用 ex-note 左对齐正文样式,空列表区显得是「缺了一行」而非
|
|
219
219
|
// 一个状态
|
|
220
220
|
".dsh-recall-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5;text-align:center;padding:24px 0}",
|
|
221
|
+
// 列表读取失败条(issue #30 遗留项):error 前景 + 换行不裁切,右侧带重试 chip。
|
|
222
|
+
// 与空态区分开——「没有快照」和「读不到快照」在用户眼里必须是两件事
|
|
223
|
+
".dsh-recall-list-error{display:flex;gap:8px;align-items:flex-start;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:1.5}",
|
|
224
|
+
".dsh-recall-list-error-text{flex:1;min-width:0;word-break:break-word}",
|
|
221
225
|
".dsh-recall-ex-status-success{color:var(--dsw-alias-state-success-primary)}",
|
|
222
226
|
// V6 健康徽章(git 可用性):pill 配色直接复用官方状态行配对——成功用
|
|
223
227
|
// success-tertiary 底、失败用 interactive-bg-hover-danger 底(error 无
|
|
@@ -242,6 +246,8 @@
|
|
|
242
246
|
".dsh-recall-tree-label{flex:1;min-width:0;display:flex;gap:8px;align-items:baseline;font-size:12px;line-height:1.5;overflow:hidden}",
|
|
243
247
|
".dsh-recall-tree-name{flex:none;font-weight:600;color:var(--dsw-alias-label-secondary)}",
|
|
244
248
|
".dsh-recall-tree-title{min-width:0;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
|
|
249
|
+
// issue #31:仅消息条目的静态标记——与 ex-chip 同丸形,但非按钮、无 hover
|
|
250
|
+
".dsh-recall-tree-tag{flex:none;border-radius:999px;corner-shape:round;padding:0 8px;font-size:11px;line-height:1.6;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-module-platform);white-space:nowrap}",
|
|
245
251
|
// tabular-nums:树 meta 列(「3 会话 / 28 快照」「v2/3」)多行纵向扫描时
|
|
246
252
|
// 数字等宽,个位对齐不跳动
|
|
247
253
|
".dsh-recall-tree-meta{flex:none;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-variant-numeric:tabular-nums}",
|
|
@@ -425,6 +431,7 @@
|
|
|
425
431
|
"tree.family.title": "\u7248\u672C\u5BB6\u65CF\uFF1A{chain}",
|
|
426
432
|
"tree.switch": "\u5207\u6362",
|
|
427
433
|
"tree.switch.title": "\u5207\u6362\u5230\u8BE5\u7248\u672C\u4F1A\u8BDD",
|
|
434
|
+
"tree.messageOnly": "\u4EC5\u6D88\u606F",
|
|
428
435
|
"tree.delete.snapshot.title": "\u5220\u9664\u8BE5\u5FEB\u7167\uFF08tag \u4E0E\u7D22\u5F15\u6761\u76EE\uFF09",
|
|
429
436
|
"tree.delete.snapshot.confirm": "\u786E\u8BA4\u5220\u9664\u8BE5\u5FEB\u7167\uFF1F\u6B64\u64CD\u4F5C\u4E0D\u53EF\u6062\u590D\u3002",
|
|
430
437
|
"tree.delete.session.title": "\u5220\u9664\u8BE5\u4F1A\u8BDD\u5168\u90E8\u5FEB\u7167",
|
|
@@ -438,6 +445,8 @@
|
|
|
438
445
|
"recall.truncated.files": "\u2026\u4EC5\u663E\u793A\u524D {shown} \u6761\uFF0C\u5171 {total} \u4E2A\u6587\u4EF6\u5C06\u53D8\u66F4",
|
|
439
446
|
"recall.truncated.chat": "\u2026\u4EC5\u663E\u793A\u524D {shown} \u6761\uFF0C\u5171 {total} \u5904\u5DEE\u5F02",
|
|
440
447
|
"recall.confirm.sessionOnly": "\u9879\u76EE\u6587\u4EF6\u4FDD\u6301\u5F53\u524D\u72B6\u6001\uFF0C\u4E0D\u4F1A\u88AB\u56DE\u9000\u6216\u5220\u9664\uFF1B\u5BF9\u8BDD\u56DE\u9000\u5230\u8BE5\u6D88\u606F\u4E4B\u524D\u3002",
|
|
448
|
+
"recall.confirm.messageOnly": "\u8FD9\u6761\u6D88\u606F\u53EA\u6709\u5BF9\u8BDD\u56DE\u9000\uFF1A\u4E0D\u4F1A\u6539\u52A8\u9879\u76EE\u6587\u4EF6\uFF0C\u5BF9\u8BDD\u56DE\u9000\u5230\u8BE5\u6D88\u606F\u4E4B\u524D\u3002",
|
|
449
|
+
"recall.confirm.noEffect": "\u8BE5\u6D88\u606F\u6CA1\u6709\u6587\u4EF6\u5FEB\u7167\uFF0C\u4E14\u662F\u4F1A\u8BDD\u7B2C\u4E00\u6761\u6D88\u606F\uFF0C\u4E4B\u524D\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u5BF9\u8BDD\u3002",
|
|
441
450
|
"recall.confirm.rollbackAt": "\u5C06\u9879\u76EE\u6062\u590D\u5230 {time} \u53D1\u9001\u8BE5\u6D88\u606F\u65F6\u7684\u72B6\u6001\u3002",
|
|
442
451
|
"recall.confirm.rollbackNoTime": "\u5C06\u9879\u76EE\u6062\u590D\u5230\u53D1\u9001\u8BE5\u6D88\u606F\u65F6\u7684\u72B6\u6001\u3002",
|
|
443
452
|
"recall.confirm.files": "\u5171 {total} \u4E2A\u6587\u4EF6\u5C06\u53D8\u66F4{summary}\u3002\u6B64\u64CD\u4F5C\u4F1A\u8986\u76D6\u5F53\u524D\u6587\u4EF6\u5185\u5BB9\uFF1B\u56DE\u9000\u524D\u4F1A\u81EA\u52A8\u4FDD\u5B58\u4E00\u4EFD\u5F53\u524D\u72B6\u6001\u7684\u5B89\u5168\u5FEB\u7167\uFF08\u4E0D\u542B\u5728\u4E0B\u65B9\u6E05\u5355\u5185\uFF09\u3002",
|
|
@@ -511,6 +520,7 @@
|
|
|
511
520
|
"notice.buildRoot": "\u5F53\u524D\u5DE5\u4F5C\u533A\u4F4D\u4E8E\u6784\u5EFA\u4EA7\u7269\u76EE\u5F55\uFF08\u8DEF\u5F84\u6BB5 {seg}\uFF09\uFF0C\u5DF2\u8DF3\u8FC7\u9879\u76EE\u5FEB\u7167\uFF1B\u5982\u9700\u5728\u6B64\u76EE\u5F55\u4F7F\u7528\u64A4\u56DE\uFF0C\u8BF7\u5728\u63D2\u4EF6\u8BBE\u7F6E\u91CC\u4ECE\u300C\u57FA\u7840\u6392\u9664\u8868\u300D\u79FB\u9664\u8BE5\u9879\u3002",
|
|
512
521
|
// ---- 错误码文案(host 静态 message 的本地化副本;动态细节码不在表内)----
|
|
513
522
|
"err.STALE": "\u9884\u89C8\u540E\u9879\u76EE\u6587\u4EF6\u53D1\u751F\u4E86\u53D8\u5316\uFF0C\u8BF7\u91CD\u65B0\u9884\u89C8\u786E\u8BA4",
|
|
523
|
+
"err.DUMP_FAILED": "\u8BFB\u53D6\u78C1\u76D8\u5FEB\u7167\u5931\u8D25\uFF0C\u5217\u8868\u4E0E\u6279\u91CF\u5220\u9664\u6682\u4E0D\u53EF\u7528\uFF1B\u8BF7\u67E5\u770B\u300C\u6700\u8FD1\u9519\u8BEF\u300D\u540E\u91CD\u8BD5",
|
|
514
524
|
"err.AGENT_BUSY": "Agent \u6B63\u5728\u8FD0\u884C\u4E2D\uFF0C\u8BF7\u5148\u505C\u6B62\u540E\u518D\u64A4\u56DE",
|
|
515
525
|
"err.NO_SNAPSHOT": "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167",
|
|
516
526
|
"err.NO_STORE": "\u5FEB\u7167\u5B58\u50A8\u4E0D\u53EF\u7528",
|
|
@@ -553,6 +563,7 @@
|
|
|
553
563
|
"manage.search.placeholder": "\u641C\u7D22\u5DE5\u4F5C\u533A / \u4F1A\u8BDD\u6807\u9898 / \u6D88\u606F\u5185\u5BB9 / ID",
|
|
554
564
|
"manage.search.aria": "\u641C\u7D22\u5FEB\u7167",
|
|
555
565
|
"manage.empty": "\u5728\u4EFB\u610F\u5DE5\u4F5C\u533A\u53D1\u9001\u4E00\u6761\u6D88\u606F\u540E\uFF0C\u8FD9\u91CC\u4F1A\u51FA\u73B0\u5FEB\u7167\u3002",
|
|
566
|
+
"manage.loadFailed": "\u8BFB\u53D6\u78C1\u76D8\u5FEB\u7167\u5931\u8D25",
|
|
556
567
|
"manage.emptyFiltered": "\u65E0\u5339\u914D\u5FEB\u7167",
|
|
557
568
|
"manage.errors.title": "\u6700\u8FD1\u9519\u8BEF ({n})",
|
|
558
569
|
"manage.errors.collapse": "\u6536\u8D77",
|
|
@@ -571,6 +582,8 @@
|
|
|
571
582
|
"config.group.ui": "\u754C\u9762",
|
|
572
583
|
"config.snapshotEnabled.label": "\u542F\u7528\u5FEB\u7167",
|
|
573
584
|
"config.snapshotEnabled.hint": "\u5173\u95ED\u540E\u4E0D\u518D\u65B0\u5EFA\u5FEB\u7167\uFF1B\u5DF2\u6709\u5FEB\u7167\u4ECD\u53EF\u64A4\u56DE",
|
|
585
|
+
"config.messageOnly.label": "\u53EA\u56DE\u9000\u6D88\u606F",
|
|
586
|
+
"config.messageOnly.hint": "\u6BCF\u6761\u6D88\u606F\u53EA\u767B\u8BB0\u64A4\u56DE\u8BB0\u5F55\uFF0C\u4E0D\u5199\u6587\u4EF6\u5FEB\u7167\uFF08\u8D85\u5927\u5DE5\u4F5C\u533A\u7701\u65F6\u7701\u76D8\uFF09\uFF1B\u5DF2\u6709\u5FEB\u7167\u4ECD\u53EF\u56DE\u9000\u6587\u4EF6",
|
|
574
587
|
"config.refillDraft.label": "\u56DE\u586B\u8F93\u5165\u6846",
|
|
575
588
|
"config.refillDraft.hint": "\u64A4\u56DE\u540E\u628A\u6D88\u606F\u6587\u672C\u56DE\u586B\u8F93\u5165\u6846\uFF0C\u4FBF\u4E8E\u6539\u5B8C\u91CD\u53D1",
|
|
576
589
|
"config.archiveOriginal.label": "\u5F52\u6863\u539F\u4F1A\u8BDD",
|
|
@@ -683,6 +696,7 @@
|
|
|
683
696
|
"tree.family.title": "Version family: {chain}",
|
|
684
697
|
"tree.switch": "Switch",
|
|
685
698
|
"tree.switch.title": "Switch to this version session",
|
|
699
|
+
"tree.messageOnly": "Messages only",
|
|
686
700
|
"tree.delete.snapshot.title": "Delete this snapshot (tag and index entry)",
|
|
687
701
|
"tree.delete.snapshot.confirm": "Delete this snapshot? This cannot be undone.",
|
|
688
702
|
"tree.delete.session.title": "Delete all snapshots of this session",
|
|
@@ -696,6 +710,8 @@
|
|
|
696
710
|
"recall.truncated.files": "\u2026showing the first {shown} of {total} files to change",
|
|
697
711
|
"recall.truncated.chat": "\u2026showing the first {shown} of {total} differences",
|
|
698
712
|
"recall.confirm.sessionOnly": "Project files stay as they are \u2014 nothing is reverted or deleted; only the chat rewinds to before this message.",
|
|
713
|
+
"recall.confirm.messageOnly": "This message only rewinds the chat: project files are untouched, and the chat goes back to before this message.",
|
|
714
|
+
"recall.confirm.noEffect": "This message has no file snapshot, and as the first message of the session there is no earlier chat to rewind to.",
|
|
699
715
|
"recall.confirm.rollbackAt": "Restore the project to its state at {time}, when this message was sent.",
|
|
700
716
|
"recall.confirm.rollbackNoTime": "Restore the project to its state when this message was sent.",
|
|
701
717
|
// 句首空格是有意的:本句与 recall.confirm.rollbackAt 拼接成一段(前句以句点
|
|
@@ -769,6 +785,7 @@
|
|
|
769
785
|
"notice.buildRoot": 'This workspace is a build-artifact directory (path segment {seg}), so project snapshots are skipped; to use recall here, remove that entry from "Base excludes" in the plugin settings.',
|
|
770
786
|
// ---- 错误码文案 ----
|
|
771
787
|
"err.STALE": "Project files changed after the preview; preview again to confirm",
|
|
788
|
+
"err.DUMP_FAILED": 'Could not read snapshots from disk; the list and bulk delete are unavailable. Check "Recent errors" and retry',
|
|
772
789
|
"err.AGENT_BUSY": "The agent is running; stop it before recalling",
|
|
773
790
|
"err.NO_SNAPSHOT": "No project snapshot is available for this message",
|
|
774
791
|
"err.NO_STORE": "Snapshot storage is unavailable",
|
|
@@ -811,6 +828,7 @@
|
|
|
811
828
|
"manage.search.placeholder": "Search workspace / session title / message / ID",
|
|
812
829
|
"manage.search.aria": "Search snapshots",
|
|
813
830
|
"manage.empty": "Send a message in any workspace and its snapshot will appear here.",
|
|
831
|
+
"manage.loadFailed": "Could not read snapshots from disk",
|
|
814
832
|
"manage.emptyFiltered": "No matching snapshots",
|
|
815
833
|
"manage.errors.title": "Recent errors ({n})",
|
|
816
834
|
"manage.errors.collapse": "Collapse",
|
|
@@ -829,6 +847,8 @@
|
|
|
829
847
|
"config.group.ui": "Interface",
|
|
830
848
|
"config.snapshotEnabled.label": "Enable snapshots",
|
|
831
849
|
"config.snapshotEnabled.hint": "When off, no new snapshots are created; existing ones can still be recalled",
|
|
850
|
+
"config.messageOnly.label": "Messages only",
|
|
851
|
+
"config.messageOnly.hint": "Register recall records without writing file snapshots (saves time and disk in huge workspaces); existing snapshots can still roll back files",
|
|
832
852
|
"config.refillDraft.label": "Refill input box",
|
|
833
853
|
"config.refillDraft.hint": "Put the message text back into the input box after a recall, so you can edit and resend",
|
|
834
854
|
"config.archiveOriginal.label": "Archive original session",
|
|
@@ -1890,7 +1910,7 @@
|
|
|
1890
1910
|
writeIds(rootId, next);
|
|
1891
1911
|
}
|
|
1892
1912
|
|
|
1893
|
-
// src/client/
|
|
1913
|
+
// src/client/message-blocks.ts
|
|
1894
1914
|
function attachmentRefsFromBlocks(blocks) {
|
|
1895
1915
|
const refs = [];
|
|
1896
1916
|
for (const block of blocks) {
|
|
@@ -1903,6 +1923,18 @@
|
|
|
1903
1923
|
}
|
|
1904
1924
|
return refs;
|
|
1905
1925
|
}
|
|
1926
|
+
function projectMessageBlocks(blocks) {
|
|
1927
|
+
return {
|
|
1928
|
+
text: blocks.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join(""),
|
|
1929
|
+
// 官方契约:images 传 image 块数组({attachment}),官方内部取
|
|
1930
|
+
// image.attachment.attachmentId —— 不是裸 attachment 对象
|
|
1931
|
+
images: blocks.filter((b) => b && b.type === "image" && b.attachment).map((b) => ({ attachment: b.attachment })),
|
|
1932
|
+
// file 块(文档/表格等非图片附件):官方渲染成文件卡片(图标 + 文件名 +
|
|
1933
|
+
// 扩展名/大小)——自绘路径必须复刻,否则它落进 rest 的 JSON 兜底
|
|
1934
|
+
files: blocks.filter((b) => b && b.type === "file"),
|
|
1935
|
+
rest: blocks.filter((b) => !b || !(b.type === "text" && typeof b.text === "string") && !(b.type === "image" && b.attachment) && b.type !== "file")
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1906
1938
|
function fileExtOf(name) {
|
|
1907
1939
|
const dot = name.lastIndexOf(".");
|
|
1908
1940
|
const raw = dot > 0 && dot < name.length - 1 ? name.slice(dot + 1) : "";
|
|
@@ -1923,6 +1955,8 @@
|
|
|
1923
1955
|
const name = ref && typeof ref === "object" && typeof ref.name === "string" && ref.name !== "" ? ref.name : t("file.unnamed");
|
|
1924
1956
|
return { name, ext: fileExtOf(name), size: fileCardSizeText(ref && typeof ref === "object" ? ref.bytes : void 0) };
|
|
1925
1957
|
}
|
|
1958
|
+
|
|
1959
|
+
// src/client/diff-kinds.ts
|
|
1926
1960
|
var KIND_INFO = {
|
|
1927
1961
|
modified: { labelKey: "kind.modified", cls: "modified" },
|
|
1928
1962
|
restored: { labelKey: "kind.restored", cls: "restored" },
|
|
@@ -1938,6 +1972,8 @@
|
|
|
1938
1972
|
}
|
|
1939
1973
|
return parts.join(" \xB7 ");
|
|
1940
1974
|
}
|
|
1975
|
+
|
|
1976
|
+
// src/client/recall-node.ts
|
|
1941
1977
|
function buildRecallNode(React, util, ctx, sessionsSvc, workspacesSvc, uiWorkspaceSvc) {
|
|
1942
1978
|
const { api, ensureInit, showThrottledToast, writeClipboard, clockText: clockText2, pluginConfig, messageFor, t } = util;
|
|
1943
1979
|
const { EditIcon, EditPanel } = buildEditFlow(React, t);
|
|
@@ -2007,7 +2043,9 @@
|
|
|
2007
2043
|
);
|
|
2008
2044
|
});
|
|
2009
2045
|
const canRevertChat = typeof recall.cutSeq === "number";
|
|
2010
|
-
const
|
|
2046
|
+
const noFiles = recall.files === false;
|
|
2047
|
+
const sessionOnly = noFiles || canRevertChat && scope === "session-only";
|
|
2048
|
+
const noEffect = noFiles && !canRevertChat;
|
|
2011
2049
|
if (recall.truncated) {
|
|
2012
2050
|
rows.push(React.createElement(
|
|
2013
2051
|
"div",
|
|
@@ -2027,11 +2065,11 @@
|
|
|
2027
2065
|
React.createElement("div", { className: "dsh-recall-panel-title" }, t("recall.confirm.title")),
|
|
2028
2066
|
sessionOnly ? (
|
|
2029
2067
|
// session-only:零文件改动即无不可逆操作缺口——安全快照预告随之隐藏
|
|
2030
|
-
|
|
2068
|
+
//(不打安全快照),主说明换成模式语义;仅消息条目另给专属说明
|
|
2031
2069
|
React.createElement(
|
|
2032
2070
|
"div",
|
|
2033
2071
|
{ className: "dsh-recall-panel-note" },
|
|
2034
|
-
t("recall.confirm.sessionOnly")
|
|
2072
|
+
t(noFiles ? "recall.confirm.messageOnly" : "recall.confirm.sessionOnly")
|
|
2035
2073
|
)
|
|
2036
2074
|
) : React.createElement(
|
|
2037
2075
|
"div",
|
|
@@ -2053,9 +2091,10 @@
|
|
|
2053
2091
|
)
|
|
2054
2092
|
) : null,
|
|
2055
2093
|
changes.length > 0 ? React.createElement("div", { className: "dsh-recall-list" }, ...rows) : null,
|
|
2056
|
-
canRevertChat ? (
|
|
2094
|
+
canRevertChat && !noFiles ? (
|
|
2057
2095
|
// 模式二选一(原生 radio,键盘方向键可达):临场选择不设全局配置项;
|
|
2058
|
-
// 默认 both
|
|
2096
|
+
// 默认 both 与现状一致,面板每次打开都复位到默认起点。仅消息条目
|
|
2097
|
+
// 没有可选项(没有文件树),整组隐藏
|
|
2059
2098
|
React.createElement(
|
|
2060
2099
|
"div",
|
|
2061
2100
|
{ className: "dsh-recall-scope", role: "radiogroup", "aria-label": t("recall.scope.aria") },
|
|
@@ -2073,22 +2112,28 @@
|
|
|
2073
2112
|
)
|
|
2074
2113
|
)
|
|
2075
2114
|
) : null,
|
|
2115
|
+
noEffect ? (
|
|
2116
|
+
// 仅消息 + 首条消息:文件无快照、对话无更早切点,提交只会空转
|
|
2117
|
+
React.createElement("div", { className: "dsh-recall-panel-note", key: "no-effect" }, t("recall.confirm.noEffect"))
|
|
2118
|
+
) : null,
|
|
2076
2119
|
React.createElement(
|
|
2077
2120
|
"div",
|
|
2078
2121
|
{ className: "dsh-recall-panel-actions" },
|
|
2079
2122
|
React.createElement("button", { type: "button", className: "dsh-recall-btn", onClick: closePanel }, t("common.cancel")),
|
|
2080
|
-
React.createElement("button", { type: "button", className: "dsh-recall-btn dsh-recall-btn-danger", onClick: executeRecall }, t(sessionOnly ? "recall.confirm.submit.sessionOnly" : "recall.confirm.submit.both"))
|
|
2123
|
+
React.createElement("button", { type: "button", className: "dsh-recall-btn dsh-recall-btn-danger", disabled: noEffect, onClick: executeRecall }, t(sessionOnly ? "recall.confirm.submit.sessionOnly" : "recall.confirm.submit.both"))
|
|
2081
2124
|
)
|
|
2082
2125
|
);
|
|
2083
2126
|
}
|
|
2084
2127
|
if (recall.stage === "executing") {
|
|
2128
|
+
const active = recall.scope || scope;
|
|
2085
2129
|
return React.createElement(
|
|
2086
2130
|
"div",
|
|
2087
2131
|
{ className: "dsh-recall-panel" },
|
|
2088
|
-
React.createElement("div", { className: "dsh-recall-panel-title" }, t(
|
|
2132
|
+
React.createElement("div", { className: "dsh-recall-panel-title" }, t(active === "session-only" ? "recall.executing.sessionOnly" : "recall.executing.both"))
|
|
2089
2133
|
);
|
|
2090
2134
|
}
|
|
2091
2135
|
if (recall.stage === "done") {
|
|
2136
|
+
const active = recall.scope || scope;
|
|
2092
2137
|
return React.createElement(
|
|
2093
2138
|
"div",
|
|
2094
2139
|
{ className: "dsh-recall-panel" },
|
|
@@ -2096,7 +2141,7 @@
|
|
|
2096
2141
|
React.createElement(
|
|
2097
2142
|
"div",
|
|
2098
2143
|
{ className: "dsh-recall-panel-note" },
|
|
2099
|
-
|
|
2144
|
+
active === "session-only" ? (
|
|
2100
2145
|
// session-only 文案矩阵:文件侧零改动是确定事实,失败时也只描述对话侧
|
|
2101
2146
|
recall.chatReverted ? t("recall.done.sessionOnly.ok") : t("recall.done.sessionOnly.fail", { error: recall.chatError || t("common.unknownReason") })
|
|
2102
2147
|
) : recall.chatReverted ? t("recall.done.both.ok") : t("recall.done.both.partial") + (recall.chatError ? t("recall.done.both.failChat", { error: recall.chatError }) : "")
|
|
@@ -2145,10 +2190,7 @@
|
|
|
2145
2190
|
const data = node && node.data ? node.data : {};
|
|
2146
2191
|
const messageId = node ? String(node.id || node.key || "") : "";
|
|
2147
2192
|
const blocks = Array.isArray(data.content) ? data.content : [];
|
|
2148
|
-
const
|
|
2149
|
-
const imageBlocks = blocks.filter((b) => b && b.type === "image" && b.attachment).map((b) => ({ attachment: b.attachment }));
|
|
2150
|
-
const fileBlocks = blocks.filter((b) => b && b.type === "file");
|
|
2151
|
-
const rest = blocks.filter((b) => !b || !(b.type === "text" && typeof b.text === "string") && !(b.type === "image" && b.attachment) && b.type !== "file");
|
|
2193
|
+
const { text: text2, images: imageBlocks, files: fileBlocks, rest } = projectMessageBlocks(blocks);
|
|
2152
2194
|
const attachmentRefs = attachmentRefsFromBlocks(blocks);
|
|
2153
2195
|
activeSession.id = String(sessionId || "");
|
|
2154
2196
|
const [copied, setCopied] = React.useState(false);
|
|
@@ -2475,7 +2517,9 @@
|
|
|
2475
2517
|
truncated: Boolean(res.truncated),
|
|
2476
2518
|
treeId: res.treeId || null,
|
|
2477
2519
|
time: res.time || null,
|
|
2478
|
-
cutSeq: typeof res.cutSeq === "number" ? res.cutSeq : null
|
|
2520
|
+
cutSeq: typeof res.cutSeq === "number" ? res.cutSeq : null,
|
|
2521
|
+
// issue #31:仅消息条目没有文件树(老 Host 无该字段 → 按 true)
|
|
2522
|
+
files: res.files !== false
|
|
2479
2523
|
});
|
|
2480
2524
|
}).catch((error) => {
|
|
2481
2525
|
setRecall({ stage: "error", message: String(error) });
|
|
@@ -2486,7 +2530,7 @@
|
|
|
2486
2530
|
const isEdit = editText !== null;
|
|
2487
2531
|
if (!isEdit && recall.stage !== "confirm") return;
|
|
2488
2532
|
const changes = recall.stage === "confirm" ? recall.changes || [] : [];
|
|
2489
|
-
const activeScope = overrides && overrides.scope === "session-only" ? "session-only" : overrides && overrides.scope === "both" ? "both" : scope;
|
|
2533
|
+
const activeScope = overrides && overrides.scope === "session-only" ? "session-only" : overrides && overrides.scope === "both" ? "both" : recall.stage === "confirm" && recall.files === false ? "session-only" : scope;
|
|
2490
2534
|
const previewCut = overrides && overrides.cutSeq !== void 0 ? overrides.cutSeq : recall.stage === "confirm" && typeof recall.cutSeq === "number" ? recall.cutSeq : null;
|
|
2491
2535
|
const setStage = (stage) => {
|
|
2492
2536
|
if (!isEdit) {
|
|
@@ -2505,7 +2549,7 @@
|
|
|
2505
2549
|
const baseFiles = wantAttachments ? preloadAttachmentFiles(sessionId, refsForFiles) : null;
|
|
2506
2550
|
const inlineFiles = overrides && overrides.editFiles ? overrides.editFiles : null;
|
|
2507
2551
|
const attachmentFiles = baseFiles !== null && inlineFiles !== null ? Promise.all([baseFiles, inlineFiles]).then(([base, extra]) => base.concat(extra)) : baseFiles !== null ? baseFiles : inlineFiles;
|
|
2508
|
-
setStage({ stage: "executing", changes });
|
|
2552
|
+
setStage({ stage: "executing", changes, scope: activeScope });
|
|
2509
2553
|
api("execute", { messageId, sessionId, previewTotal, previewTreeId: previewTreeId || void 0, scope: activeScope, previewAt: Date.now() }).then(async (res) => {
|
|
2510
2554
|
if (!res || !res.ok) {
|
|
2511
2555
|
if (res && res.code === "STALE") {
|
|
@@ -2526,7 +2570,8 @@
|
|
|
2526
2570
|
truncated: Boolean(res2.truncated),
|
|
2527
2571
|
treeId: res2.treeId || null,
|
|
2528
2572
|
time: res2.time || null,
|
|
2529
|
-
cutSeq: typeof res2.cutSeq === "number" ? res2.cutSeq : null
|
|
2573
|
+
cutSeq: typeof res2.cutSeq === "number" ? res2.cutSeq : null,
|
|
2574
|
+
files: res2.files !== false
|
|
2530
2575
|
});
|
|
2531
2576
|
}).catch((error) => {
|
|
2532
2577
|
setStage({ stage: "error", message: String(error) });
|
|
@@ -2592,7 +2637,7 @@
|
|
|
2592
2637
|
fillDraft(fillTarget, text2, attachmentFiles);
|
|
2593
2638
|
if (fileBlocks.length > 0) showThrottledToast(t("toast.fileAttachRefill"));
|
|
2594
2639
|
}
|
|
2595
|
-
setRecall({ stage: "done", count: typeof res.count === "number" ? res.count : changes.length, chatReverted, chatError });
|
|
2640
|
+
setRecall({ stage: "done", count: typeof res.count === "number" ? res.count : changes.length, chatReverted, chatError, scope: activeScope });
|
|
2596
2641
|
}
|
|
2597
2642
|
if (chatError === "") {
|
|
2598
2643
|
api("notify", buildRecallNotify(notifyBase, {
|
|
@@ -2776,6 +2821,8 @@
|
|
|
2776
2821
|
baseExcludes: Array.isArray(v.baseExcludes) ? v.baseExcludes.join("\n") : "",
|
|
2777
2822
|
refillDraft: v.refillDraft !== false,
|
|
2778
2823
|
snapshotEnabled: v.snapshotEnabled !== false,
|
|
2824
|
+
// issue #31:老 Host 无该字段 → 按默认 false(现状)处理
|
|
2825
|
+
messageOnly: v.messageOnly === true,
|
|
2779
2826
|
archiveOriginal: v.archiveOriginal !== false,
|
|
2780
2827
|
// issue #26:老 Host 的 values 无该字段时按默认 true(与 util 同口径)
|
|
2781
2828
|
editSubmitsImmediately: v.editSubmitsImmediately !== false,
|
|
@@ -2803,7 +2850,7 @@
|
|
|
2803
2850
|
function save() {
|
|
2804
2851
|
if (state.busy || !draft || !baseline) return;
|
|
2805
2852
|
const patch = {};
|
|
2806
|
-
for (const key of ["gcSnaps", "gcHours", "maxFileBytes", "maxWorkspaceBytes", "scanBudgetMs", "snapshotBudgetMs", "maxSnapshotsPerWorkspace", "baseExcludes", "refillDraft", "snapshotEnabled", "archiveOriginal", "editSubmitsImmediately", "retentionDays", "locale"]) {
|
|
2853
|
+
for (const key of ["gcSnaps", "gcHours", "maxFileBytes", "maxWorkspaceBytes", "scanBudgetMs", "snapshotBudgetMs", "maxSnapshotsPerWorkspace", "baseExcludes", "refillDraft", "snapshotEnabled", "messageOnly", "archiveOriginal", "editSubmitsImmediately", "retentionDays", "locale"]) {
|
|
2807
2854
|
const value = draft[key];
|
|
2808
2855
|
if (value !== void 0 && value !== baseline[key]) patch[key] = value;
|
|
2809
2856
|
}
|
|
@@ -2870,6 +2917,7 @@
|
|
|
2870
2917
|
}
|
|
2871
2918
|
if (patch.refillDraft !== void 0) clean.refillDraft = Boolean(patch.refillDraft);
|
|
2872
2919
|
if (patch.snapshotEnabled !== void 0) clean.snapshotEnabled = Boolean(patch.snapshotEnabled);
|
|
2920
|
+
if (patch.messageOnly !== void 0) clean.messageOnly = Boolean(patch.messageOnly);
|
|
2873
2921
|
if (patch.archiveOriginal !== void 0) clean.archiveOriginal = Boolean(patch.archiveOriginal);
|
|
2874
2922
|
if (patch.editSubmitsImmediately !== void 0) clean.editSubmitsImmediately = Boolean(patch.editSubmitsImmediately);
|
|
2875
2923
|
if (patch.retentionDays !== void 0) {
|
|
@@ -3027,6 +3075,8 @@
|
|
|
3027
3075
|
// 不重复加标题。
|
|
3028
3076
|
React.createElement("div", { className: "dsh-recall-cfg-group" }, t("config.group.snapshot")),
|
|
3029
3077
|
boolRow("snapshotEnabled", t("config.snapshotEnabled.label"), t("config.snapshotEnabled.hint")),
|
|
3078
|
+
// issue #31:只回退消息——紧挨总开关,语义上是「快照写什么」的下一层
|
|
3079
|
+
boolRow("messageOnly", t("config.messageOnly.label"), t("config.messageOnly.hint")),
|
|
3030
3080
|
boolRow("refillDraft", t("config.refillDraft.label"), t("config.refillDraft.hint")),
|
|
3031
3081
|
boolRow("archiveOriginal", t("config.archiveOriginal.label"), t("config.archiveOriginal.hint")),
|
|
3032
3082
|
boolRow("editSubmitsImmediately", t("config.editSubmitsImmediately.label"), t("config.editSubmitsImmediately.hint")),
|
|
@@ -3287,7 +3337,7 @@
|
|
|
3287
3337
|
return result;
|
|
3288
3338
|
}
|
|
3289
3339
|
function buildSnapshotManager(React, util, sessionsSvc, workspacesSvc, uiWorkspaceSvc) {
|
|
3290
|
-
const { api, clockText: clockText2, sizeText: sizeText2, buildTree: buildTree2, t } = util;
|
|
3340
|
+
const { api, clockText: clockText2, sizeText: sizeText2, buildTree: buildTree2, messageFor, t } = util;
|
|
3291
3341
|
function DeleteButton(props) {
|
|
3292
3342
|
return React.createElement("button", {
|
|
3293
3343
|
type: "button",
|
|
@@ -3326,6 +3376,7 @@
|
|
|
3326
3376
|
}
|
|
3327
3377
|
function ManageCard() {
|
|
3328
3378
|
const [items, setItems] = React.useState(null);
|
|
3379
|
+
const [listError, setListError] = React.useState(null);
|
|
3329
3380
|
const [usage, setUsage] = React.useState(null);
|
|
3330
3381
|
const [errors, setErrors] = React.useState(null);
|
|
3331
3382
|
const [state, setState] = React.useState({ busy: false, message: "", error: false });
|
|
@@ -3369,6 +3420,7 @@
|
|
|
3369
3420
|
const useLimit = overLimit || limit;
|
|
3370
3421
|
api("manage", { op: "list", limit: useLimit }).then((res) => {
|
|
3371
3422
|
if (res && res.ok) {
|
|
3423
|
+
setListError(null);
|
|
3372
3424
|
setItems(res.items || []);
|
|
3373
3425
|
setTotal(typeof res.total === "number" ? res.total : (res.items || []).length);
|
|
3374
3426
|
fetchTitles(res.items || []);
|
|
@@ -3384,6 +3436,10 @@
|
|
|
3384
3436
|
}).catch(() => {
|
|
3385
3437
|
});
|
|
3386
3438
|
}
|
|
3439
|
+
} else if (res) {
|
|
3440
|
+
setListError(messageFor(res, t("manage.loadFailed")));
|
|
3441
|
+
setItems([]);
|
|
3442
|
+
setTotal(0);
|
|
3387
3443
|
}
|
|
3388
3444
|
api("manage", { op: "lineage" }).then((res2) => {
|
|
3389
3445
|
if (res2 && res2.ok && Array.isArray(res2.lineage)) setLineage(res2.lineage);
|
|
@@ -3519,6 +3575,8 @@
|
|
|
3519
3575
|
"span",
|
|
3520
3576
|
{ className: "dsh-recall-tree-label" },
|
|
3521
3577
|
React.createElement("span", { className: "dsh-recall-tree-title" }, label),
|
|
3578
|
+
// issue #31:仅消息条目没有文件树,标出来——否则用户会以为这条能回退文件
|
|
3579
|
+
it.files === false ? React.createElement("span", { className: "dsh-recall-tree-tag", key: "message-only" }, t("tree.messageOnly")) : null,
|
|
3522
3580
|
// 删除紧贴本条消息摘要(而非行尾):按行扫读时动作落在「看的那一行」,
|
|
3523
3581
|
// 不必横跳到右侧同列按钮再回读行号
|
|
3524
3582
|
React.createElement(DeleteButton, {
|
|
@@ -3696,6 +3754,13 @@
|
|
|
3696
3754
|
onChange: (e) => setQuery(e.target.value)
|
|
3697
3755
|
})
|
|
3698
3756
|
),
|
|
3757
|
+
// 读取失败提示:role=alert 让读屏立即播报;「重试」复用 refresh,不新造入口
|
|
3758
|
+
listError ? React.createElement(
|
|
3759
|
+
"div",
|
|
3760
|
+
{ key: "list-error", className: "dsh-recall-list-error", role: "alert" },
|
|
3761
|
+
React.createElement("span", { className: "dsh-recall-list-error-text" }, listError),
|
|
3762
|
+
React.createElement("button", { type: "button", className: "dsh-recall-ex-chip", onClick: () => refresh() }, t("common.retry"))
|
|
3763
|
+
) : null,
|
|
3699
3764
|
// V3 加载骨架:items===null 表示首查未回——用 5 条 pulse 灰条占位替代
|
|
3700
3765
|
// 打开快照管理时的一段空白;aria-hidden 纯装饰不打扰读屏
|
|
3701
3766
|
items === null ? React.createElement(
|
package/lib/config.js
CHANGED
|
@@ -47,6 +47,10 @@ const Config = Schema.object({
|
|
|
47
47
|
baseExcludes: withVolatile(Schema.array(Schema.string()).default(BASE_EXCLUDES).description("\u57FA\u7840\u6392\u9664\u8868\uFF08gitignore \u8BED\u6CD5\uFF0C\u4F18\u5148\u7EA7\u4F4E\u4E8E exclude.txt\uFF09")),
|
|
48
48
|
refillDraft: withVolatile(Schema.boolean().default(true).description("\u64A4\u56DE\u540E\u628A\u88AB\u64A4\u56DE\u7684\u6D88\u606F\uFF08\u6587\u672C\u4E0E\u9644\u4EF6\uFF09\u56DE\u586B\u5230\u8F93\u5165\u6846")),
|
|
49
49
|
snapshotEnabled: withVolatile(Schema.boolean().default(true).description("\u542F\u7528\u6D88\u606F\u5FEB\u7167\uFF08\u5173\u95ED\u540E\u4E0D\u518D\u65B0\u5EFA\uFF0C\u5DF2\u6709\u5FEB\u7167\u4ECD\u53EF\u64A4\u56DE\uFF09")),
|
|
50
|
+
// issue #31:超大工作区(gitignore 过滤后仍上 GB)每条消息写一次全量对象
|
|
51
|
+
// 成本过高。开启后仍登记撤回记录(按钮与对话回退照常),但跳过文件快照;
|
|
52
|
+
// 按条落 files:false,老条目仍可完整回退文件
|
|
53
|
+
messageOnly: withVolatile(Schema.boolean().default(false).description("\u53EA\u56DE\u9000\u6D88\u606F\uFF1A\u6BCF\u6761\u6D88\u606F\u53EA\u767B\u8BB0\u64A4\u56DE\u8BB0\u5F55\uFF0C\u4E0D\u5199\u6587\u4EF6\u5FEB\u7167\uFF08\u9002\u5408\u8D85\u5927\u5DE5\u4F5C\u533A\uFF09")),
|
|
50
54
|
archiveOriginal: withVolatile(Schema.boolean().default(true).description("\u64A4\u56DE\u540E\u5F52\u6863\u539F\u4F1A\u8BDD\uFF08\u5173\u95ED\u540E\u539F\u4F1A\u8BDD\u4FDD\u7559\u5728\u5217\u8868\u4E2D\uFF09")),
|
|
51
55
|
// issue #26:编辑提交后自动发出改后的文本。关闭则退化为「只回填输入框」,
|
|
52
56
|
// 给「只想改稿、不立刻发」的用户留一条与撤回回填一致的出口
|
|
@@ -137,6 +141,7 @@ const DEFAULTS = {
|
|
|
137
141
|
baseExcludes: BASE_EXCLUDES,
|
|
138
142
|
refillDraft: true,
|
|
139
143
|
snapshotEnabled: true,
|
|
144
|
+
messageOnly: false,
|
|
140
145
|
archiveOriginal: true,
|
|
141
146
|
editSubmitsImmediately: true,
|
|
142
147
|
retentionDays: 0,
|
|
@@ -160,12 +165,13 @@ function createConfig(raw) {
|
|
|
160
165
|
const baseExcludes = Array.isArray(cfg.baseExcludes) && cfg.baseExcludes.length ? cfg.baseExcludes.filter((p) => typeof p === "string" && p.trim()) : BASE_EXCLUDES;
|
|
161
166
|
const refillDraft = typeof cfg.refillDraft === "boolean" ? cfg.refillDraft : true;
|
|
162
167
|
const snapshotEnabled = typeof cfg.snapshotEnabled === "boolean" ? cfg.snapshotEnabled : true;
|
|
168
|
+
const messageOnly = cfg.messageOnly === true;
|
|
163
169
|
const archiveOriginal = typeof cfg.archiveOriginal === "boolean" ? cfg.archiveOriginal : true;
|
|
164
170
|
const editSubmitsImmediately = cfg.editSubmitsImmediately !== false;
|
|
165
171
|
const rawDays = typeof cfg.retentionDays === "number" ? cfg.retentionDays : parseInt(String(cfg.retentionDays == null ? "" : cfg.retentionDays), 10);
|
|
166
172
|
const retentionDays = Number.isFinite(rawDays) ? Math.max(0, rawDays) : 0;
|
|
167
173
|
const locale = cfg.locale === "zh" || cfg.locale === "en" ? cfg.locale : "auto";
|
|
168
|
-
return { gcSnaps, gcHours, maxFileBytes, maxWorkspaceBytes, scanBudgetMs, snapshotBudgetMs, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, editSubmitsImmediately, retentionDays, locale };
|
|
174
|
+
return { gcSnaps, gcHours, maxFileBytes, maxWorkspaceBytes, scanBudgetMs, snapshotBudgetMs, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, messageOnly, archiveOriginal, editSubmitsImmediately, retentionDays, locale };
|
|
169
175
|
}
|
|
170
176
|
export {
|
|
171
177
|
Config,
|
package/lib/dump-parse.js
CHANGED
|
@@ -31,7 +31,7 @@ function parseStoresDump(text) {
|
|
|
31
31
|
for (const line of String(text).split(/\r?\n/)) {
|
|
32
32
|
if (line.indexOf("==DIR ") === 0) {
|
|
33
33
|
flush();
|
|
34
|
-
cur = { dir: line.slice(6).trim(), root: null, entries: null, lineage: null };
|
|
34
|
+
cur = { dir: line.slice(6).trim(), root: null, entries: null, lineage: null, indexState: "unknown" };
|
|
35
35
|
inIndex = false;
|
|
36
36
|
inLineage = false;
|
|
37
37
|
indexLines = [];
|
|
@@ -39,6 +39,11 @@ function parseStoresDump(text) {
|
|
|
39
39
|
continue;
|
|
40
40
|
}
|
|
41
41
|
if (!cur) continue;
|
|
42
|
+
if (line.indexOf("STATE ") === 0) {
|
|
43
|
+
const v = line.slice(6).trim();
|
|
44
|
+
if (v === "present" || v === "absent" || v === "quarantined" || v === "foreign") cur.indexState = v;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
42
47
|
if (line.indexOf("ROOT ") === 0) {
|
|
43
48
|
const v = line.slice(5).trim();
|
|
44
49
|
cur.root = v || null;
|
package/lib/errors.js
CHANGED
|
@@ -16,6 +16,7 @@ const RECALL_PARTIAL_DELETE = "PARTIAL_DELETE";
|
|
|
16
16
|
const RECALL_UNKNOWN_OP = "UNKNOWN_OP";
|
|
17
17
|
const RECALL_FORMAT_BLOCKED = "FORMAT_BLOCKED";
|
|
18
18
|
const RECALL_UNKNOWN_ENDPOINT = "UNKNOWN_ENDPOINT";
|
|
19
|
+
const RECALL_DUMP_FAILED = "DUMP_FAILED";
|
|
19
20
|
const RECALL_INDEX_CORRUPT = "INDEX_CORRUPT";
|
|
20
21
|
const ALL_CODES = Object.freeze([
|
|
21
22
|
RECALL_STALE,
|
|
@@ -36,6 +37,7 @@ const ALL_CODES = Object.freeze([
|
|
|
36
37
|
RECALL_UNKNOWN_OP,
|
|
37
38
|
RECALL_FORMAT_BLOCKED,
|
|
38
39
|
RECALL_UNKNOWN_ENDPOINT,
|
|
40
|
+
RECALL_DUMP_FAILED,
|
|
39
41
|
RECALL_INDEX_CORRUPT
|
|
40
42
|
]);
|
|
41
43
|
export {
|
|
@@ -43,6 +45,7 @@ export {
|
|
|
43
45
|
RECALL_AGENT_BUSY,
|
|
44
46
|
RECALL_BAD_TYPE,
|
|
45
47
|
RECALL_BODY_TOO_LARGE,
|
|
48
|
+
RECALL_DUMP_FAILED,
|
|
46
49
|
RECALL_EMPTY_PATCH,
|
|
47
50
|
RECALL_ERROR,
|
|
48
51
|
RECALL_FORMAT_BLOCKED,
|
package/lib/index.js
CHANGED
|
@@ -154,13 +154,20 @@ function apply(ctx, config) {
|
|
|
154
154
|
const text = rt.scripts.stripBom(await rt.runShell(rt.scripts.storesDumpScript(container || "", extras), { timeoutMs: 12e4, stdoutMaxBytes: 8388608 }));
|
|
155
155
|
return parseStoresDump(text);
|
|
156
156
|
} catch (error) {
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
const detail = String(error && error.stack || error);
|
|
158
|
+
console.error("recall stores dump failed:", detail);
|
|
159
|
+
rt.recordError("recall stores dump failed: " + detail);
|
|
160
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
159
161
|
}
|
|
160
162
|
}
|
|
161
163
|
async function locateSnapshotOnDisk(id) {
|
|
162
164
|
if (!id) return null;
|
|
163
|
-
|
|
165
|
+
let dump;
|
|
166
|
+
try {
|
|
167
|
+
dump = await dumpStores();
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
164
171
|
const hints = /* @__PURE__ */ new Map();
|
|
165
172
|
for (const [root, st] of state.stores.entries()) {
|
|
166
173
|
if (st && st.dir) hints.set(st.dir, root);
|
package/lib/maintenance.js
CHANGED
|
@@ -205,7 +205,9 @@ function createMaintenance(ctx, rt, snaps, config, deps = {}) {
|
|
|
205
205
|
let removed = 0;
|
|
206
206
|
for (const [dir, info] of disk) {
|
|
207
207
|
if (!dir || !info) continue;
|
|
208
|
-
|
|
208
|
+
const emptyIndex = Array.isArray(info.entries) && info.entries.length === 0;
|
|
209
|
+
const neverCaptured = info.entries === null && info.indexState === "absent";
|
|
210
|
+
if (!emptyIndex && !neverCaptured) continue;
|
|
209
211
|
if (memorySnapshotCount(dir, info.root) > 0) continue;
|
|
210
212
|
const store = rt.storeFromDir(dir, false);
|
|
211
213
|
try {
|
package/lib/routes-core.js
CHANGED
|
@@ -38,6 +38,8 @@ function createRoutesCore(deps) {
|
|
|
38
38
|
time: snap ? snap.time : null,
|
|
39
39
|
id,
|
|
40
40
|
...feedback,
|
|
41
|
+
// issue #31:按钮照常出现,但界面要知道这条没有文件树(隐藏范围单选)
|
|
42
|
+
files: snap ? snap.files !== false : void 0,
|
|
41
43
|
// A4:段名与中文 notice 并存下发(client 优先段名本地取词,见 init 同处注释)
|
|
42
44
|
notice: artifactSeg ? buildRootNotice(artifactSeg) : void 0,
|
|
43
45
|
artifactSeg: artifactSeg || void 0
|
|
@@ -52,14 +54,15 @@ function createRoutesCore(deps) {
|
|
|
52
54
|
if (result === null) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
|
|
53
55
|
const snap2 = state.snapshots.get(id);
|
|
54
56
|
const cutSeq = await snaps.resolveCutSeq(sessionId, id);
|
|
55
|
-
return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, treeId: result.treeId || null, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq };
|
|
57
|
+
return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, treeId: result.treeId || null, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq, files: snap2 ? snap2.files !== false : void 0 };
|
|
56
58
|
},
|
|
57
59
|
"execute": async (args) => {
|
|
58
60
|
const id = args && args.messageId ? String(args.messageId) : "";
|
|
59
61
|
const sessionId = args && args.sessionId ? String(args.sessionId) : null;
|
|
60
|
-
const
|
|
62
|
+
const entry = state.snapshots.get(id);
|
|
63
|
+
const scope = args && args.scope === "session-only" || entry !== void 0 && entry.files === false ? "session-only" : "both";
|
|
61
64
|
if (scope === "session-only") {
|
|
62
|
-
const snap =
|
|
65
|
+
const snap = entry;
|
|
63
66
|
if (!snap) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
|
|
64
67
|
if (agentBusy(sessionId, snap.root)) return { ok: false, code: E.RECALL_AGENT_BUSY, message: "Agent \u6B63\u5728\u8FD0\u884C\u4E2D\uFF0C\u8BF7\u5148\u505C\u6B62\u540E\u518D\u64A4\u56DE" };
|
|
65
68
|
const cutSeq2 = await snaps.resolveCutSeq(sessionId, id);
|
package/lib/routes-manage.js
CHANGED
|
@@ -29,6 +29,9 @@ function createRoutesManage(deps) {
|
|
|
29
29
|
E
|
|
30
30
|
} = deps;
|
|
31
31
|
const { sessionTitles, messageTexts, liveTitleFast, liveMessageTextFast } = sessionInfo;
|
|
32
|
+
function dumpFailed() {
|
|
33
|
+
return { ok: false, code: E.RECALL_DUMP_FAILED, message: "\u8BFB\u53D6\u78C1\u76D8\u5FEB\u7167\u5931\u8D25\uFF0C\u5217\u8868\u4E0E\u6279\u91CF\u5220\u9664\u4E0D\u53EF\u7528\uFF1B\u8BF7\u67E5\u770B\u300C\u6700\u8FD1\u9519\u8BEF\u300D\u4E2D\u7684\u539F\u56E0\u540E\u91CD\u8BD5" };
|
|
34
|
+
}
|
|
32
35
|
async function buildListItems() {
|
|
33
36
|
const allItems = [];
|
|
34
37
|
const dump = await dumpStores();
|
|
@@ -37,7 +40,7 @@ function createRoutesManage(deps) {
|
|
|
37
40
|
if (st && st.dir) hints.set(st.dir, root);
|
|
38
41
|
}
|
|
39
42
|
const byId = /* @__PURE__ */ new Map();
|
|
40
|
-
function push(id, time, root, sessionId) {
|
|
43
|
+
function push(id, time, root, sessionId, files) {
|
|
41
44
|
if (!id || typeof id !== "string") return;
|
|
42
45
|
if (isSafetySnapshotId(id)) return;
|
|
43
46
|
const old = byId.get(id);
|
|
@@ -52,6 +55,7 @@ function createRoutesManage(deps) {
|
|
|
52
55
|
};
|
|
53
56
|
const liveText = liveMessageTextFast(sessionId || "", id);
|
|
54
57
|
if (liveText) rec.messageText = liveText;
|
|
58
|
+
if (files === false) rec.files = false;
|
|
55
59
|
byId.set(id, rec);
|
|
56
60
|
allItems.push(rec);
|
|
57
61
|
return;
|
|
@@ -69,16 +73,17 @@ function createRoutesManage(deps) {
|
|
|
69
73
|
if (t) old.messageText = t;
|
|
70
74
|
}
|
|
71
75
|
if (!old.time && time) old.time = time;
|
|
76
|
+
if (files === false) old.files = false;
|
|
72
77
|
}
|
|
73
78
|
for (const [dir, info] of dump) {
|
|
74
79
|
const baseRoot = info.root || hints.get(dir) || null;
|
|
75
80
|
for (const e of info.entries || []) {
|
|
76
81
|
if (!e || typeof e.id !== "string") continue;
|
|
77
|
-
push(e.id, e.time, baseRoot || typeof e.root === "string" && e.root || null, e.sessionId);
|
|
82
|
+
push(e.id, e.time, baseRoot || typeof e.root === "string" && e.root || null, e.sessionId, e.files);
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
85
|
for (const [id, s] of state.snapshots.entries()) {
|
|
81
|
-
push(id, s.time, s.root, s.sessionId);
|
|
86
|
+
push(id, s.time, s.root, s.sessionId, s.files);
|
|
82
87
|
}
|
|
83
88
|
allItems.sort((a, b) => (b.time || 0) - (a.time || 0));
|
|
84
89
|
return allItems;
|
|
@@ -289,6 +294,8 @@ function createRoutesManage(deps) {
|
|
|
289
294
|
baseExcludes: cfg.baseExcludes.slice(),
|
|
290
295
|
refillDraft: cfg.refillDraft,
|
|
291
296
|
snapshotEnabled: cfg.snapshotEnabled,
|
|
297
|
+
// issue #31:只回退消息(不写文件快照)
|
|
298
|
+
messageOnly: cfg.messageOnly,
|
|
292
299
|
archiveOriginal: cfg.archiveOriginal,
|
|
293
300
|
editSubmitsImmediately: cfg.editSubmitsImmediately,
|
|
294
301
|
retentionDays: cfg.retentionDays,
|
|
@@ -328,6 +335,7 @@ function createRoutesManage(deps) {
|
|
|
328
335
|
}
|
|
329
336
|
if (patch.refillDraft !== void 0) clean.refillDraft = Boolean(patch.refillDraft);
|
|
330
337
|
if (patch.snapshotEnabled !== void 0) clean.snapshotEnabled = Boolean(patch.snapshotEnabled);
|
|
338
|
+
if (patch.messageOnly !== void 0) clean.messageOnly = Boolean(patch.messageOnly);
|
|
331
339
|
if (patch.archiveOriginal !== void 0) clean.archiveOriginal = Boolean(patch.archiveOriginal);
|
|
332
340
|
if (patch.editSubmitsImmediately !== void 0) clean.editSubmitsImmediately = Boolean(patch.editSubmitsImmediately);
|
|
333
341
|
if (patch.retentionDays !== void 0) {
|
|
@@ -377,7 +385,12 @@ function createRoutesManage(deps) {
|
|
|
377
385
|
if (stale) refreshListCacheInBackground();
|
|
378
386
|
return { ok: true, items: listCache.items.slice(0, safeLimit), total: listCache.items.length, stale };
|
|
379
387
|
}
|
|
380
|
-
|
|
388
|
+
let allItems;
|
|
389
|
+
try {
|
|
390
|
+
allItems = await buildListItems();
|
|
391
|
+
} catch (error) {
|
|
392
|
+
return dumpFailed();
|
|
393
|
+
}
|
|
381
394
|
listCache.at = Date.now();
|
|
382
395
|
listCache.items = allItems;
|
|
383
396
|
listCache.stale = false;
|
|
@@ -495,15 +508,25 @@ function createRoutesManage(deps) {
|
|
|
495
508
|
const id = args && args.messageId ? String(args.messageId) : "";
|
|
496
509
|
if (scope === "workspace") {
|
|
497
510
|
if (!root) return { ok: false, code: E.RECALL_NO_ROOT, message: "\u7F3A\u5C11\u5DE5\u4F5C\u533A\u8DEF\u5F84" };
|
|
498
|
-
|
|
511
|
+
let deleted;
|
|
512
|
+
try {
|
|
513
|
+
deleted = await deleteSnapshotsByFilter((rec) => rec.root === root, sessionId);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
return dumpFailed();
|
|
516
|
+
}
|
|
499
517
|
return { ok: true, deleted };
|
|
500
518
|
}
|
|
501
519
|
if (scope === "session") {
|
|
502
520
|
if (!targetSessionId) return { ok: false, code: E.RECALL_NO_SESSION, message: "\u7F3A\u5C11\u4F1A\u8BDD ID" };
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
521
|
+
let deleted;
|
|
522
|
+
try {
|
|
523
|
+
deleted = await deleteSnapshotsByFilter(
|
|
524
|
+
(rec) => rec.sessionId === targetSessionId && (!root || rec.root === root),
|
|
525
|
+
sessionId
|
|
526
|
+
);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
return dumpFailed();
|
|
529
|
+
}
|
|
507
530
|
return { ok: true, deleted };
|
|
508
531
|
}
|
|
509
532
|
let snap = state.snapshots.get(id) || null;
|
|
@@ -547,7 +570,12 @@ function createRoutesManage(deps) {
|
|
|
547
570
|
return { ok: true };
|
|
548
571
|
}
|
|
549
572
|
if (op === "deleteAll") {
|
|
550
|
-
|
|
573
|
+
let result;
|
|
574
|
+
try {
|
|
575
|
+
result = await deleteAllSnapshots();
|
|
576
|
+
} catch (error) {
|
|
577
|
+
return dumpFailed();
|
|
578
|
+
}
|
|
551
579
|
if (result.failed > 0) {
|
|
552
580
|
return {
|
|
553
581
|
ok: false,
|
package/lib/scripts.posix.js
CHANGED
|
@@ -535,6 +535,18 @@ function storesDumpScript(container, extraDirs) {
|
|
|
535
535
|
'for d in "${dirs[@]}"; do',
|
|
536
536
|
' [ -d "$d" ] || continue',
|
|
537
537
|
' echo "==DIR $d"',
|
|
538
|
+
// STATE 供空仓回收判定(present/absent/quarantined/foreign):判据留在
|
|
539
|
+
// 脚本侧是因为只有这里能看文件系统——absent 表示「从没拍过快照的真仓」
|
|
540
|
+
// (git/ 或 root.txt 在),foreign 表示不是我们的目录,两者不能混。
|
|
541
|
+
' if [ -f "$d/index.json" ]; then',
|
|
542
|
+
" echo 'STATE present'",
|
|
543
|
+
' elif ls "$d"/index.json.corrupt-* >/dev/null 2>&1; then',
|
|
544
|
+
" echo 'STATE quarantined'",
|
|
545
|
+
' elif [ -d "$d/git" ] || [ -f "$d/root.txt" ]; then',
|
|
546
|
+
" echo 'STATE absent'",
|
|
547
|
+
" else",
|
|
548
|
+
" echo 'STATE foreign'",
|
|
549
|
+
" fi",
|
|
538
550
|
' if [ -f "$d/root.txt" ]; then',
|
|
539
551
|
` printf "ROOT %s\\n" "$(cat "$d/root.txt" 2>/dev/null | tr -d '\\r\\n')"`,
|
|
540
552
|
" else",
|
package/lib/scripts.pwsh.js
CHANGED
|
@@ -675,6 +675,14 @@ function storesDumpScript(container, extraDirs) {
|
|
|
675
675
|
" if (-not $d) { continue }",
|
|
676
676
|
" if (-not (Test-Path -LiteralPath $d -PathType Container)) { continue }",
|
|
677
677
|
' Write-Output ("==DIR " + $d)',
|
|
678
|
+
// STATE 供空仓回收判定(present/absent/quarantined/foreign):判据留在脚本侧
|
|
679
|
+
// 是因为只有这里能看文件系统——absent 是「从没拍过快照的真仓」,foreign 是
|
|
680
|
+
// 别人的目录,两者必须分开(否则会删掉不是我们的东西)。
|
|
681
|
+
" $ix = Join-Path $d 'index.json'",
|
|
682
|
+
" if (Test-Path -LiteralPath $ix -PathType Leaf) { Write-Output 'STATE present' }",
|
|
683
|
+
" elseif (@(Get-ChildItem -LiteralPath $d -Filter 'index.json.corrupt-*' -ErrorAction SilentlyContinue).Count -gt 0) { Write-Output 'STATE quarantined' }",
|
|
684
|
+
" elseif ((Test-Path -LiteralPath (Join-Path $d 'git') -PathType Container) -or (Test-Path -LiteralPath (Join-Path $d 'root.txt') -PathType Leaf)) { Write-Output 'STATE absent' }",
|
|
685
|
+
" else { Write-Output 'STATE foreign' }",
|
|
678
686
|
" $rt = Join-Path $d 'root.txt'",
|
|
679
687
|
" if (Test-Path -LiteralPath $rt -PathType Leaf) {",
|
|
680
688
|
" $rv = (Get-Content -LiteralPath $rt -Raw -Encoding UTF8 -ErrorAction SilentlyContinue)",
|
|
@@ -686,7 +694,6 @@ function storesDumpScript(container, extraDirs) {
|
|
|
686
694
|
" Write-Output 'ROOT '",
|
|
687
695
|
" }",
|
|
688
696
|
" Write-Output 'INDEXBEGIN'",
|
|
689
|
-
" $ix = Join-Path $d 'index.json'",
|
|
690
697
|
" if (Test-Path -LiteralPath $ix -PathType Leaf) {",
|
|
691
698
|
" $j = Get-Content -LiteralPath $ix -Raw -Encoding UTF8 -ErrorAction SilentlyContinue",
|
|
692
699
|
" if ($j) { Write-Output $j.TrimEnd() }",
|
package/lib/snapshots.js
CHANGED
|
@@ -269,6 +269,7 @@ function createSnapshots(ctx, rt, config) {
|
|
|
269
269
|
await stampStoreFormat(store, verdict);
|
|
270
270
|
const entries = Array.from(state.snapshots.entries()).filter(([, s]) => s.root === root).map(([id, s]) => {
|
|
271
271
|
const rec = { id, time: s.time, root: s.root, sessionId: s.sessionId };
|
|
272
|
+
if (s.files === false) rec.files = false;
|
|
272
273
|
const fb = state.snapFeedback.get(id);
|
|
273
274
|
if (fb && (fb.failed || Array.isArray(fb.skipped) && fb.skipped.length)) rec.feedback = fb;
|
|
274
275
|
return rec;
|
|
@@ -320,11 +321,13 @@ function createSnapshots(ctx, rt, config) {
|
|
|
320
321
|
invalid++;
|
|
321
322
|
continue;
|
|
322
323
|
}
|
|
323
|
-
|
|
324
|
+
const info = {
|
|
324
325
|
root,
|
|
325
326
|
time: typeof entry.time === "number" ? entry.time : Date.now(),
|
|
326
327
|
sessionId: entry.sessionId || sessionId || ""
|
|
327
|
-
}
|
|
328
|
+
};
|
|
329
|
+
if (entry.files === false) info.files = false;
|
|
330
|
+
state.snapshots.set(entry.id, info);
|
|
328
331
|
const fb = entry.feedback;
|
|
329
332
|
if (fb && typeof fb === "object") {
|
|
330
333
|
const rec = {};
|
|
@@ -422,9 +425,27 @@ function createSnapshots(ctx, rt, config) {
|
|
|
422
425
|
rt.recordError("recall rebuildOrphans failed: " + String(error));
|
|
423
426
|
}
|
|
424
427
|
}
|
|
428
|
+
async function captureMessageOnly(root, sessionId, messageId, time) {
|
|
429
|
+
let store = await rt.resolveStore(root);
|
|
430
|
+
store = await rt.tryUpgradeToHome(root);
|
|
431
|
+
if (!await guardStoreFormat(store)) return;
|
|
432
|
+
const g = await rt.ensureGit(root, store);
|
|
433
|
+
if (!g.ok) {
|
|
434
|
+
setFeedback(messageId, { failed: true, ...buildFeedbackError(g.error || "\u672A\u77E5\u539F\u56E0") });
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
await loadIndex(root, sessionId);
|
|
438
|
+
state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId, files: false });
|
|
439
|
+
await saveIndex(root, sessionId);
|
|
440
|
+
setFeedback(messageId, { skipped: [] });
|
|
441
|
+
}
|
|
425
442
|
async function captureSnapshot(sessionId, messageId, time) {
|
|
426
443
|
const root = await rt.resolveRoot(sessionId);
|
|
427
444
|
if (!root) return;
|
|
445
|
+
if (config.messageOnly) {
|
|
446
|
+
await captureMessageOnly(root, sessionId, messageId, time);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
428
449
|
const fused = snapFailures.get(root);
|
|
429
450
|
if (fused && Date.now() < fused.skipUntil) return;
|
|
430
451
|
const limited = snapLimits.get(root);
|
|
@@ -519,6 +540,7 @@ function createSnapshots(ctx, rt, config) {
|
|
|
519
540
|
async function diffFor(messageId) {
|
|
520
541
|
const snap = state.snapshots.get(String(messageId));
|
|
521
542
|
if (!snap) return null;
|
|
543
|
+
if (snap.files === false) return { changes: [], total: 0, truncated: false, treeId: null };
|
|
522
544
|
const store = state.stores.get(snap.root);
|
|
523
545
|
if (!store) return null;
|
|
524
546
|
const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe || "", "snap-" + messageId, BASE(), MAX_CHANGES), { timeoutMs: 6e5, stdoutMaxBytes: 8388608 }));
|
package/package.json
CHANGED