@kangyishuai/dsh-session-rewind 0.3.0 → 0.4.0
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 +2 -1
- package/README.zh-CN.md +2 -1
- package/lib/client.js +164 -55
- package/lib/service.js +36 -0
- package/lib/state-changes.js +71 -0
- package/locale/en.json +15 -1
- package/locale/zh.json +15 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,11 +16,12 @@ Long conversations drift. A few turns later you wish you had answered a prompt d
|
|
|
16
16
|
|
|
17
17
|
- **Two entries.** Press `Esc` `Esc` to open the rewind dialog, or click the ↶ button in any user message's action row (next to Copy).
|
|
18
18
|
- **Running turns are not a dead end.** Confirming a rewind while a turn is running cancels that turn (keeping the queue), waits for it to go idle, then re-plans against the settled history.
|
|
19
|
+
- **State-change warnings.** If the withdrawn range changed session state that lives outside the transcript — plan mode, goal, todo list, model selection, permission/sandbox presets, agent preset, scheduled tasks, working directory — the dialog lists it and says that rewinding does not roll that state back.
|
|
19
20
|
- **Two scopes, chosen per rewind.** The confirmation dialog offers **Rewind conversation only** (default) and **Rewind conversation and files**. The dialog first shows a dry-run preview: how many messages and roughly how many tokens will be withdrawn, which files will be restored or moved to the trash, conflicts, and skipped files.
|
|
20
21
|
- **Conversation.** The target message and everything after it are withdrawn from the model-visible context and from the transcript. The session id, title and earlier history are untouched. Nothing is deleted from the log — one marker event is appended.
|
|
21
22
|
- **Files (optional).** Files changed by `write` / `edit` / `apply_patch` in the withdrawn turns are restored to their pre-change content. Files created after the target are moved to the plugin's trash instead of deleted. A rescue point of the current files is created before anything is written; failures roll back from it.
|
|
22
23
|
- **Composer.** The target message's text (and images) is put back into the composer, ready to edit and send again. An existing draft is never overwritten. Tick **Send this message again right after rewinding** in the confirmation dialog to retry the turn in one click (conversation-only rewinds).
|
|
23
|
-
- **Rewind panel.** A new header action opens a panel with four tabs: **Withdrawn** (every message each rewind removed, searchable, copy or put back in the composer), **History** (
|
|
24
|
+
- **Rewind panel.** A new header action opens a panel with four tabs: **Withdrawn** (every message each rewind removed, searchable, copy or put back in the composer), **History** (a timeline of each rewind and each rescue-point file restore, newest first: scope, message count, tokens, file results), **Rescue points** (restore files from an earlier rescue), and **Settings** (default scope, page size, confirm-on-list, diagnostics).
|
|
24
25
|
|
|
25
26
|
## Read-only agent tool
|
|
26
27
|
|
package/README.zh-CN.md
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
- **每次回溯二选一**:确认框里提供「**仅回退对话**」(默认)与「**回退对话和文件**」,并先展示 dry-run 预览:将撤回多少条消息、约多少 token、哪些文件会还原/移入回收站、冲突与被跳过的文件。
|
|
19
19
|
- **对话**:目标消息及其后的内容从模型上下文和渲染对话中撤回;会话 id、标题和目标之前的历史不变;日志只追加一条 marker,不删历史。
|
|
20
20
|
- **文件(可选)**:被撤回轮次里由 `write` / `edit` / `apply_patch` 改过的文件还原到改动前内容;目标之后新建的文件移入插件回收站而不是删除。写入前先建救援点,失败从救援点回滚。
|
|
21
|
+
- **状态变更提示**:如果要撤回的区间里改过会话状态(plan 模式、goal、待办、模型选择、权限/沙箱预设、智能体预设、定时任务、工作目录),确认框会列出来并明确说明——回溯只撤回对话,**不会**回滚这些状态。
|
|
21
22
|
- **运行中也能回退**:确认回退时如果这一轮正在跑,会先取消它(保留队列)、等它空闲,再对新历史重新求值。
|
|
22
23
|
- **输入框**:目标消息文本(以及图片)放回输入框,改完可直接重发;已有草稿绝不覆盖。勾选「回退后立即重发」可一键重试这一轮(仅对话模式)。
|
|
23
|
-
-
|
|
24
|
+
- **回溯面板**:会话头部新增一个入口,四个页签:**已撤回**(每次回溯撤回的全部消息,可搜索、复制、放回输入框)、**回溯记录**(时间线:每次回溯与每次救援点恢复,新在上;范围、条数、token、文件结果)、**救援点**(手动恢复文件)、**设置**(默认范围、每页数量、列表确认、诊断)。
|
|
24
25
|
|
|
25
26
|
## 只读 agent 工具
|
|
26
27
|
|
package/lib/client.js
CHANGED
|
@@ -72,6 +72,20 @@ window.__ModuleLoader__.load({
|
|
|
72
72
|
"confirm.resendHint":
|
|
73
73
|
"适合「重试这一轮」:回退后自动把原文发出去,想改内容就在输入框里编辑后再发。",
|
|
74
74
|
"confirm.openToolCall": "目标之后有未完成的工具调用,回溯会截断它",
|
|
75
|
+
"confirm.stateChanges": "这段区间里改过会话状态:{list};回溯只撤回对话,不会回滚这些状态",
|
|
76
|
+
"confirm.stateHint": "如需一并回退 plan/goal/待办等状态,请手动调整。",
|
|
77
|
+
"state.planMode": "计划模式",
|
|
78
|
+
"state.goal": "目标(goal)",
|
|
79
|
+
"state.todo": "待办清单",
|
|
80
|
+
"state.model": "模型选择",
|
|
81
|
+
"state.permission": "权限预设",
|
|
82
|
+
"state.sandbox": "沙箱模式",
|
|
83
|
+
"state.agentPreset": "智能体预设",
|
|
84
|
+
"state.schedule": "定时任务",
|
|
85
|
+
"state.workingDirectory": "工作目录",
|
|
86
|
+
"state.separator": "、",
|
|
87
|
+
"timeline.kindRescue": "文件恢复",
|
|
88
|
+
"timeline.rescueMeta": "从救援点恢复 {restored} 个文件 · {id}",
|
|
75
89
|
"toast.done": "已回退到此处,原消息已放回输入框",
|
|
76
90
|
"toast.draft": "已回退;输入框已有内容,未覆盖",
|
|
77
91
|
"toast.noComposer": "已回退,但未找到输入框",
|
|
@@ -202,6 +216,21 @@ window.__ModuleLoader__.load({
|
|
|
202
216
|
"confirm.resendHint":
|
|
203
217
|
"Use this to retry a turn; to change the text, edit it in the composer instead.",
|
|
204
218
|
"confirm.openToolCall": "An unfinished tool call follows the target; rewinding truncates it.",
|
|
219
|
+
"confirm.stateChanges":
|
|
220
|
+
"Session state changed inside this range: {list}. Rewinding only withdraws messages; these stay as they are.",
|
|
221
|
+
"confirm.stateHint": "Adjust plan/goal/todo state by hand if you need it rolled back too.",
|
|
222
|
+
"state.planMode": "plan mode",
|
|
223
|
+
"state.goal": "goal",
|
|
224
|
+
"state.todo": "todo list",
|
|
225
|
+
"state.model": "model selection",
|
|
226
|
+
"state.permission": "permission preset",
|
|
227
|
+
"state.sandbox": "sandbox mode",
|
|
228
|
+
"state.agentPreset": "agent preset",
|
|
229
|
+
"state.schedule": "scheduled task",
|
|
230
|
+
"state.workingDirectory": "working directory",
|
|
231
|
+
"state.separator": ", ",
|
|
232
|
+
"timeline.kindRescue": "File restore",
|
|
233
|
+
"timeline.rescueMeta": "Restored {restored} file(s) from rescue {id}",
|
|
205
234
|
"toast.stoppedTurn": ", and the running turn was stopped",
|
|
206
235
|
"toast.resent": ", and the message was sent again",
|
|
207
236
|
"toast.done": "Rewound. The original message is back in the composer.",
|
|
@@ -691,7 +720,25 @@ window.__ModuleLoader__.load({
|
|
|
691
720
|
* @param {any} value
|
|
692
721
|
* @returns {any}
|
|
693
722
|
*/
|
|
694
|
-
|
|
723
|
+
/** 会话状态变更提示(plan/goal/todo/模型…)。 @param {any} value */
|
|
724
|
+
function normalizeStateChanges(value) {
|
|
725
|
+
/** @type {any[]} */
|
|
726
|
+
let out = [];
|
|
727
|
+
let source = Array.isArray(value) ? value : [];
|
|
728
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
729
|
+
let item = source[i];
|
|
730
|
+
if (!item || typeof item !== "object") continue;
|
|
731
|
+
if (typeof item.labelKey !== "string") continue;
|
|
732
|
+
out.push({
|
|
733
|
+
type: typeof item.type === "string" ? item.type : "",
|
|
734
|
+
labelKey: item.labelKey,
|
|
735
|
+
count: typeof item.count === "number" && item.count > 0 ? item.count : 1,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
return out;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function normalizePreview(/** @type {any} */ value) {
|
|
695
742
|
let source = value && typeof value === "object" ? value : {};
|
|
696
743
|
let target = source.target && typeof source.target === "object" ? source.target : {};
|
|
697
744
|
let chat = source.chat && typeof source.chat === "object" ? source.chat : {};
|
|
@@ -731,6 +778,7 @@ window.__ModuleLoader__.load({
|
|
|
731
778
|
crossesOpenToolCall: chat.crossesOpenToolCall === true,
|
|
732
779
|
reachable: chat.reachable !== false,
|
|
733
780
|
},
|
|
781
|
+
stateChanges: normalizeStateChanges(source.stateChanges ?? chat.stateChanges),
|
|
734
782
|
files: normalizeFiles(source.files),
|
|
735
783
|
warnings: warnings,
|
|
736
784
|
};
|
|
@@ -965,8 +1013,13 @@ window.__ModuleLoader__.load({
|
|
|
965
1013
|
: typeof entry.id === "number"
|
|
966
1014
|
? String(entry.id)
|
|
967
1015
|
: "";
|
|
1016
|
+
let action = typeof entry.action === "string" ? entry.action : "";
|
|
1017
|
+
let kind =
|
|
1018
|
+
action === "rescue-restore" ? "rescue-restore" : entry.mode === "both" ? "both" : "chat";
|
|
968
1019
|
return {
|
|
969
1020
|
id: id,
|
|
1021
|
+
action: action,
|
|
1022
|
+
kind: kind,
|
|
970
1023
|
time: typeof entry.time === "number" ? entry.time : null,
|
|
971
1024
|
targetSeq: typeof entry.targetSeq === "number" ? entry.targetSeq : null,
|
|
972
1025
|
markerSeq: typeof entry.markerSeq === "number" ? entry.markerSeq : null,
|
|
@@ -989,6 +1042,12 @@ window.__ModuleLoader__.load({
|
|
|
989
1042
|
if (entry.id === "") continue;
|
|
990
1043
|
out.push(entry);
|
|
991
1044
|
}
|
|
1045
|
+
// 时间线按时间倒序(新在上);缺时间戳的排最后。
|
|
1046
|
+
out.sort((/** @type {any} */ left, /** @type {any} */ right) => {
|
|
1047
|
+
let l = typeof left.time === "number" ? left.time : -1;
|
|
1048
|
+
let r = typeof right.time === "number" ? right.time : -1;
|
|
1049
|
+
return r - l;
|
|
1050
|
+
});
|
|
992
1051
|
return out;
|
|
993
1052
|
}
|
|
994
1053
|
|
|
@@ -1928,6 +1987,27 @@ window.__ModuleLoader__.load({
|
|
|
1928
1987
|
h("div", { key: "tool", className: "dsh-srw-warn" }, t("confirm.openToolCall")),
|
|
1929
1988
|
);
|
|
1930
1989
|
}
|
|
1990
|
+
// 区间里改过的会话状态:回溯只切模型上下文,不会回滚这些设置。
|
|
1991
|
+
let stateChanges = Array.isArray(preview.stateChanges) ? preview.stateChanges : [];
|
|
1992
|
+
if (stateChanges.length > 0) {
|
|
1993
|
+
let labels = stateChanges
|
|
1994
|
+
.map((/** @type {any} */ item) =>
|
|
1995
|
+
item.count > 1 ? `${t(item.labelKey)} ×${item.count}` : t(item.labelKey),
|
|
1996
|
+
)
|
|
1997
|
+
.join(t("state.separator"));
|
|
1998
|
+
blocks.push(
|
|
1999
|
+
h(
|
|
2000
|
+
"div",
|
|
2001
|
+
{ key: "state", className: "dsh-srw-warn", "data-rewind-state": "true" },
|
|
2002
|
+
t("confirm.stateChanges", { list: labels }),
|
|
2003
|
+
),
|
|
2004
|
+
h(
|
|
2005
|
+
"div",
|
|
2006
|
+
{ key: "state-hint", className: "dsh-srw-option-hint" },
|
|
2007
|
+
t("confirm.stateHint"),
|
|
2008
|
+
),
|
|
2009
|
+
);
|
|
2010
|
+
}
|
|
1931
2011
|
blocks.push(
|
|
1932
2012
|
h(ModePicker, {
|
|
1933
2013
|
key: "modes",
|
|
@@ -2307,12 +2387,14 @@ window.__ModuleLoader__.load({
|
|
|
2307
2387
|
if (data.entries.length === 0) {
|
|
2308
2388
|
return h("div", { className: "dsh-srw-dim" }, t("audit.empty"));
|
|
2309
2389
|
}
|
|
2390
|
+
// 时间线:竖线 + 节点。回溯事件与「从救援点恢复文件」放在同一条轴上,按时间倒序。
|
|
2310
2391
|
return h(
|
|
2311
2392
|
"div",
|
|
2312
|
-
{ className: "dsh-srw-
|
|
2393
|
+
{ className: "dsh-srw-timeline" },
|
|
2313
2394
|
data.entries.map((/** @type {any} */ entry) => {
|
|
2314
2395
|
let expanded = data.expanded && data.expanded[entry.id] === true;
|
|
2315
2396
|
let files = entry.files;
|
|
2397
|
+
let isRescueRestore = entry.kind === "rescue-restore";
|
|
2316
2398
|
/** @type {any[]} */
|
|
2317
2399
|
let pathGroups = [];
|
|
2318
2400
|
if (files) {
|
|
@@ -2334,70 +2416,86 @@ window.__ModuleLoader__.load({
|
|
|
2334
2416
|
(/** @type {any} */ sum, /** @type {any} */ group) => sum + group.paths.length,
|
|
2335
2417
|
0,
|
|
2336
2418
|
);
|
|
2419
|
+
let badge = isRescueRestore ? t("timeline.kindRescue") : t(modeLabelKey(entry.mode));
|
|
2420
|
+
let meta = isRescueRestore
|
|
2421
|
+
? t("timeline.rescueMeta", {
|
|
2422
|
+
restored: files ? files.restored.length : 0,
|
|
2423
|
+
id: entry.rescueId || "",
|
|
2424
|
+
})
|
|
2425
|
+
: t("audit.meta", {
|
|
2426
|
+
mode: t(modeLabelKey(entry.mode)),
|
|
2427
|
+
n: entry.removedCount,
|
|
2428
|
+
tokens: formatCount(entry.tokens),
|
|
2429
|
+
});
|
|
2337
2430
|
return h(
|
|
2338
2431
|
"div",
|
|
2339
|
-
{
|
|
2432
|
+
{
|
|
2433
|
+
key: entry.id,
|
|
2434
|
+
className: "dsh-srw-tl-node",
|
|
2435
|
+
"data-kind": isRescueRestore ? "rescue-restore" : entry.mode,
|
|
2436
|
+
},
|
|
2437
|
+
h("span", { className: "dsh-srw-tl-dot", "aria-hidden": "true" }),
|
|
2340
2438
|
h(
|
|
2341
2439
|
"div",
|
|
2342
|
-
{ className: "dsh-srw-
|
|
2343
|
-
h("span", { className: "dsh-srw-strong" }, formatDateTime(entry.time) || entry.id),
|
|
2344
|
-
h("span", { className: "dsh-srw-badge" }, t(modeLabelKey(entry.mode))),
|
|
2440
|
+
{ className: "dsh-srw-tl-card" },
|
|
2345
2441
|
h(
|
|
2346
|
-
"
|
|
2347
|
-
{ className: "dsh-srw-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
n: entry.removedCount,
|
|
2351
|
-
tokens: formatCount(entry.tokens),
|
|
2352
|
-
}),
|
|
2442
|
+
"div",
|
|
2443
|
+
{ className: "dsh-srw-tl-head" },
|
|
2444
|
+
h("span", { className: "dsh-srw-strong" }, formatDateTime(entry.time) || entry.id),
|
|
2445
|
+
h("span", { className: "dsh-srw-badge" }, badge),
|
|
2353
2446
|
),
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
? h(
|
|
2357
|
-
"div",
|
|
2358
|
-
{ className: "dsh-srw-audit-files" },
|
|
2359
|
-
t("audit.files", {
|
|
2360
|
-
restored: files.restored.length,
|
|
2361
|
-
trashed: files.trashed.length,
|
|
2362
|
-
skipped: files.skipped.length,
|
|
2363
|
-
failed: files.failed.length,
|
|
2364
|
-
}),
|
|
2365
|
-
)
|
|
2366
|
-
: null,
|
|
2367
|
-
files?.rescueId
|
|
2368
|
-
? h("div", { className: "dsh-srw-dim" }, t("audit.rescueId", { id: files.rescueId }))
|
|
2369
|
-
: null,
|
|
2370
|
-
h(
|
|
2371
|
-
"div",
|
|
2372
|
-
{ className: "dsh-srw-item-actions dsh-srw-audit-actions" },
|
|
2373
|
-
pathCount > 0
|
|
2447
|
+
h("div", { className: "dsh-srw-time" }, meta),
|
|
2448
|
+
files
|
|
2374
2449
|
? h(
|
|
2375
|
-
|
|
2376
|
-
{
|
|
2377
|
-
|
|
2450
|
+
"div",
|
|
2451
|
+
{ className: "dsh-srw-audit-files" },
|
|
2452
|
+
t("audit.files", {
|
|
2453
|
+
restored: files.restored.length,
|
|
2454
|
+
trashed: files.trashed.length,
|
|
2455
|
+
skipped: files.skipped.length,
|
|
2456
|
+
failed: files.failed.length,
|
|
2457
|
+
}),
|
|
2378
2458
|
)
|
|
2379
2459
|
: null,
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2460
|
+
files?.rescueId
|
|
2461
|
+
? h(
|
|
2462
|
+
"div",
|
|
2463
|
+
{ className: "dsh-srw-dim" },
|
|
2464
|
+
t("audit.rescueId", { id: files.rescueId }),
|
|
2465
|
+
)
|
|
2466
|
+
: null,
|
|
2467
|
+
h(
|
|
2468
|
+
"div",
|
|
2469
|
+
{ className: "dsh-srw-item-actions dsh-srw-audit-actions" },
|
|
2470
|
+
pathCount > 0
|
|
2471
|
+
? h(
|
|
2472
|
+
LinkBtn,
|
|
2473
|
+
{ onClick: () => api.toggleAuditPaths(entry.id) },
|
|
2474
|
+
expanded ? t("audit.pathsLess") : t("audit.paths", { n: pathCount }),
|
|
2475
|
+
)
|
|
2476
|
+
: null,
|
|
2477
|
+
h(LinkBtn, { onClick: () => api.copyJson(entry) }, t("audit.copyJson")),
|
|
2478
|
+
),
|
|
2479
|
+
expanded
|
|
2480
|
+
? h(
|
|
2481
|
+
"div",
|
|
2482
|
+
{ className: "dsh-srw-paths" },
|
|
2483
|
+
pathGroups.map((/** @type {any} */ group) =>
|
|
2484
|
+
group.paths.map((/** @type {any} */ path) =>
|
|
2485
|
+
h(
|
|
2486
|
+
"div",
|
|
2487
|
+
{
|
|
2488
|
+
key: `${group.kind}:${path}`,
|
|
2489
|
+
className: "dsh-srw-path",
|
|
2490
|
+
"data-kind": group.kind,
|
|
2491
|
+
},
|
|
2492
|
+
h("span", { className: "dsh-srw-path-name", title: path }, path),
|
|
2493
|
+
),
|
|
2396
2494
|
),
|
|
2397
2495
|
),
|
|
2398
|
-
)
|
|
2399
|
-
|
|
2400
|
-
|
|
2496
|
+
)
|
|
2497
|
+
: null,
|
|
2498
|
+
),
|
|
2401
2499
|
);
|
|
2402
2500
|
}),
|
|
2403
2501
|
);
|
|
@@ -3004,6 +3102,16 @@ window.__ModuleLoader__.load({
|
|
|
3004
3102
|
".dsh-srw-tab[data-active=true]{color:var(--dsw-alias-label-primary,inherit);border-bottom-color:var(--dsw-alias-brand-primary,#4f7cff);}",
|
|
3005
3103
|
".dsh-srw-tab-body{display:flex;flex-direction:column;gap:10px;}",
|
|
3006
3104
|
".dsh-srw-cuts{display:flex;flex-direction:column;gap:12px;}",
|
|
3105
|
+
// 时间线:竖线 + 圆点 + 卡片
|
|
3106
|
+
".dsh-srw-timeline{display:flex;flex-direction:column;gap:0;position:relative;}",
|
|
3107
|
+
".dsh-srw-tl-node{position:relative;display:flex;gap:12px;padding:0 0 14px 0;}",
|
|
3108
|
+
".dsh-srw-tl-node::before{content:'';position:absolute;left:5px;top:14px;bottom:0;width:1px;background:var(--dsw-alias-border-secondary,rgba(128,128,128,.35));}",
|
|
3109
|
+
".dsh-srw-tl-node:last-child{padding-bottom:0;}",
|
|
3110
|
+
".dsh-srw-tl-node:last-child::before{display:none;}",
|
|
3111
|
+
".dsh-srw-tl-dot{flex:none;width:11px;height:11px;margin-top:4px;border-radius:50%;background:var(--dsw-alias-brand-primary,#4f7cff);box-shadow:0 0 0 2px var(--dsw-alias-bg-primary,transparent);}",
|
|
3112
|
+
".dsh-srw-tl-node[data-kind='rescue-restore'] .dsh-srw-tl-dot{background:var(--dsw-alias-label-secondary,GrayText);}",
|
|
3113
|
+
".dsh-srw-tl-card{display:flex;flex-direction:column;gap:4px;min-width:0;flex:1;}",
|
|
3114
|
+
".dsh-srw-tl-head{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;}",
|
|
3007
3115
|
".dsh-srw-cut{display:flex;flex-direction:column;gap:4px;}",
|
|
3008
3116
|
".dsh-srw-cut-head{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;}",
|
|
3009
3117
|
".dsh-srw-strong{font-weight:600;}",
|
|
@@ -4426,6 +4534,7 @@ window.__ModuleLoader__.load({
|
|
|
4426
4534
|
normalizePathList: normalizePathList,
|
|
4427
4535
|
normalizeFiles: normalizeFiles,
|
|
4428
4536
|
normalizePreview: normalizePreview,
|
|
4537
|
+
normalizeStateChanges: normalizeStateChanges,
|
|
4429
4538
|
fileModeState: fileModeState,
|
|
4430
4539
|
effectiveMode: effectiveMode,
|
|
4431
4540
|
conflictPathsOf: conflictPathsOf,
|
package/lib/service.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "./operations.js";
|
|
30
30
|
import { findOrphans, pruneOrphans } from "./orphans.js";
|
|
31
31
|
import { cutsOf, hasOpenToolCalls, listCandidatesPage, targetContent } from "./plan.js";
|
|
32
|
+
import { stateChangeSummary } from "./state-changes.js";
|
|
32
33
|
import { describeToolImpact, installRewindTool, listToolCandidates } from "./tool.js";
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -477,6 +478,8 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
477
478
|
const tokens = this.estimateTokens(events, plan.shadowedSeqs);
|
|
478
479
|
const target = targetContent(targetEvent);
|
|
479
480
|
const subtitle = target.text.replace(/\s+/gu, " ").trim();
|
|
481
|
+
// 被撤回区间里改过的会话状态(plan/goal/todo/模型…):回溯不会回滚它们,只提示。
|
|
482
|
+
const stateChanges = stateChangeSummary(events, plan.surfaceStart, plan.surfaceEnd);
|
|
480
483
|
/** @type {any} */
|
|
481
484
|
let files = {
|
|
482
485
|
available: false,
|
|
@@ -541,7 +544,9 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
541
544
|
tokens,
|
|
542
545
|
crossesOpenToolCall: hasOpenToolCalls(events, plan.targetSeq),
|
|
543
546
|
reachable: true,
|
|
547
|
+
stateChanges,
|
|
544
548
|
},
|
|
549
|
+
stateChanges,
|
|
545
550
|
files,
|
|
546
551
|
warnings,
|
|
547
552
|
};
|
|
@@ -852,6 +857,37 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
852
857
|
throw new RewindRequestError(ERROR_CODES.rescueNotFound, "a rescue id is required");
|
|
853
858
|
}
|
|
854
859
|
const result = await this.store.restoreRescue({ sessionId, cwd, rescueId });
|
|
860
|
+
// 审计:时间线里要把「从救援点恢复文件」和回溯事件放在同一条轴上。
|
|
861
|
+
try {
|
|
862
|
+
await this.store.appendAudit({
|
|
863
|
+
version: 1,
|
|
864
|
+
action: "rescue-restore",
|
|
865
|
+
id:
|
|
866
|
+
typeof this.store.newAuditId === "function"
|
|
867
|
+
? this.store.newAuditId()
|
|
868
|
+
: `rr-${Date.now()}`,
|
|
869
|
+
time: Date.now(),
|
|
870
|
+
sessionId,
|
|
871
|
+
rescueId,
|
|
872
|
+
mode: "files",
|
|
873
|
+
targetSeq: null,
|
|
874
|
+
markerSeq: null,
|
|
875
|
+
removedCount: null,
|
|
876
|
+
tokens: null,
|
|
877
|
+
files: {
|
|
878
|
+
restored: result.restored ?? [],
|
|
879
|
+
trashed: [],
|
|
880
|
+
skipped: [],
|
|
881
|
+
failed: result.failed ?? [],
|
|
882
|
+
rescueId: result.safetyRescueId ?? null,
|
|
883
|
+
},
|
|
884
|
+
requestedBy: "ui",
|
|
885
|
+
});
|
|
886
|
+
} catch (error) {
|
|
887
|
+
this.ctx?.logger?.warn?.(
|
|
888
|
+
`session-rewind: rescue-restore audit failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
855
891
|
if (Array.isArray(result.failed) && result.failed.length > 0) {
|
|
856
892
|
const error = new RewindRequestError(
|
|
857
893
|
ERROR_CODES.filesFailed,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// 「被撤回区间里改过哪些会话状态」的提示。
|
|
3
|
+
//
|
|
4
|
+
// 为什么需要:回溯只替换**模型可见的 surface**,日志与会话状态投影不动。所以区间里的
|
|
5
|
+
// `plan/mode`、`goal/change`、`todo/write` 这类状态事件在回溯后仍然生效——用户很容易误以为
|
|
6
|
+
// 「退回去就等于那些设置也退回去了」。这里只做事实提示,不做任何回滚。
|
|
7
|
+
//
|
|
8
|
+
// 未知事件类型一律忽略(AGENTS.md 约定 1:不写死内部实现,遇到不认识的保留)。
|
|
9
|
+
|
|
10
|
+
/** 已知的会话状态事件 → 文案键。 */
|
|
11
|
+
export const STATE_EVENT_LABELS = Object.freeze({
|
|
12
|
+
"plan/mode": "state.planMode",
|
|
13
|
+
"goal/change": "state.goal",
|
|
14
|
+
"todo/write": "state.todo",
|
|
15
|
+
"model/selection": "state.model",
|
|
16
|
+
"permission/preset": "state.permission",
|
|
17
|
+
"sandbox/mode": "state.sandbox",
|
|
18
|
+
"agent-preset/selected": "state.agentPreset",
|
|
19
|
+
"schedule/change": "state.schedule",
|
|
20
|
+
"working-directory/change": "state.workingDirectory",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} StateChange
|
|
25
|
+
* @property {string} type 事件类型
|
|
26
|
+
* @property {string} labelKey 文案键(`state.*`)
|
|
27
|
+
* @property {number} count 次数
|
|
28
|
+
* @property {number} firstSeq 第一次出现的 seq
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 扫描 [fromSeq, toSeq] 区间里的会话状态事件,按出现顺序聚合。
|
|
33
|
+
* @param {any[]} events
|
|
34
|
+
* @param {number} fromSeq 含
|
|
35
|
+
* @param {number} toSeq 含;传 `Number.MAX_SAFE_INTEGER` 表示到日志末尾
|
|
36
|
+
* @returns {StateChange[]}
|
|
37
|
+
*/
|
|
38
|
+
export function collectStateChanges(events, fromSeq, toSeq) {
|
|
39
|
+
const source = Array.isArray(events) ? events : [];
|
|
40
|
+
const start = typeof fromSeq === "number" ? fromSeq : 0;
|
|
41
|
+
const end = typeof toSeq === "number" ? toSeq : Number.MAX_SAFE_INTEGER;
|
|
42
|
+
/** @type {Map<string, StateChange>} */
|
|
43
|
+
const groups = new Map();
|
|
44
|
+
for (const event of source) {
|
|
45
|
+
if (!event || typeof event.seq !== "number") continue;
|
|
46
|
+
if (event.seq < start || event.seq > end) continue;
|
|
47
|
+
const type = event.type;
|
|
48
|
+
if (typeof type !== "string") continue;
|
|
49
|
+
const labelKey = /** @type {Record<string, string>} */ (STATE_EVENT_LABELS)[type];
|
|
50
|
+
if (labelKey === undefined) continue;
|
|
51
|
+
const existing = groups.get(type);
|
|
52
|
+
if (existing) existing.count += 1;
|
|
53
|
+
else groups.set(type, { type, labelKey, count: 1, firstSeq: event.seq });
|
|
54
|
+
}
|
|
55
|
+
return [...groups.values()].sort((left, right) => left.firstSeq - right.firstSeq);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 给提示用的简短摘要(不含本地化文案,只出 key 与次数)。
|
|
60
|
+
* @param {any[]} events
|
|
61
|
+
* @param {number} fromSeq
|
|
62
|
+
* @param {number} toSeq
|
|
63
|
+
* @returns {Array<{type: string, labelKey: string, count: number}>}
|
|
64
|
+
*/
|
|
65
|
+
export function stateChangeSummary(events, fromSeq, toSeq) {
|
|
66
|
+
return collectStateChanges(events, fromSeq, toSeq).map((item) => ({
|
|
67
|
+
type: item.type,
|
|
68
|
+
labelKey: item.labelKey,
|
|
69
|
+
count: item.count,
|
|
70
|
+
}));
|
|
71
|
+
}
|
package/locale/en.json
CHANGED
|
@@ -122,6 +122,20 @@
|
|
|
122
122
|
"toast.stoppedTurn": ", and the running turn was stopped",
|
|
123
123
|
"confirm.resend": "Send this message again right after rewinding",
|
|
124
124
|
"confirm.resendHint": "Use this to retry a turn; to change the text, edit it in the composer instead.",
|
|
125
|
-
"toast.resent": ", and the message was sent again"
|
|
125
|
+
"toast.resent": ", and the message was sent again",
|
|
126
|
+
"confirm.stateChanges": "Session state changed inside this range: {list}. Rewinding only withdraws messages; these stay as they are.",
|
|
127
|
+
"confirm.stateHint": "Adjust plan/goal/todo state by hand if you need it rolled back too.",
|
|
128
|
+
"state.planMode": "plan mode",
|
|
129
|
+
"state.goal": "goal",
|
|
130
|
+
"state.todo": "todo list",
|
|
131
|
+
"state.model": "model selection",
|
|
132
|
+
"state.permission": "permission preset",
|
|
133
|
+
"state.sandbox": "sandbox mode",
|
|
134
|
+
"state.agentPreset": "agent preset",
|
|
135
|
+
"state.schedule": "scheduled task",
|
|
136
|
+
"state.workingDirectory": "working directory",
|
|
137
|
+
"state.separator": ", ",
|
|
138
|
+
"timeline.kindRescue": "File restore",
|
|
139
|
+
"timeline.rescueMeta": "Restored {restored} file(s) from rescue {id}"
|
|
126
140
|
}
|
|
127
141
|
}
|
package/locale/zh.json
CHANGED
|
@@ -122,6 +122,20 @@
|
|
|
122
122
|
"toast.stoppedTurn": ",已停止运行中的回合",
|
|
123
123
|
"confirm.resend": "回退后立即重发这条消息",
|
|
124
124
|
"confirm.resendHint": "适合「重试这一轮」:回退后自动把原文发出去,想改内容就在输入框里编辑后再发。",
|
|
125
|
-
"toast.resent": ",已重发这条消息"
|
|
125
|
+
"toast.resent": ",已重发这条消息",
|
|
126
|
+
"confirm.stateChanges": "这段区间里改过会话状态:{list};回溯只撤回对话,不会回滚这些状态",
|
|
127
|
+
"confirm.stateHint": "如需一并回退 plan/goal/待办等状态,请手动调整。",
|
|
128
|
+
"state.planMode": "计划模式",
|
|
129
|
+
"state.goal": "目标(goal)",
|
|
130
|
+
"state.todo": "待办清单",
|
|
131
|
+
"state.model": "模型选择",
|
|
132
|
+
"state.permission": "权限预设",
|
|
133
|
+
"state.sandbox": "沙箱模式",
|
|
134
|
+
"state.agentPreset": "智能体预设",
|
|
135
|
+
"state.schedule": "定时任务",
|
|
136
|
+
"state.workingDirectory": "工作目录",
|
|
137
|
+
"state.separator": "、",
|
|
138
|
+
"timeline.kindRescue": "文件恢复",
|
|
139
|
+
"timeline.rescueMeta": "从救援点恢复 {restored} 个文件 · {id}"
|
|
126
140
|
}
|
|
127
141
|
}
|
package/package.json
CHANGED