@kangyishuai/dsh-session-rewind 0.5.0 → 0.6.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 +18 -4
- package/README.zh-CN.md +18 -4
- package/lib/client.js +472 -5
- package/locale/en.json +2 -0
- package/locale/zh.json +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ Long conversations drift. A few turns later you wish you had answered a prompt d
|
|
|
21
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.
|
|
22
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.
|
|
23
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).
|
|
24
|
+
- **Prompt recall without rewinding.** In the composer, bare `↑` on an empty draft (or with the caret on the first line) recalls the last prompt you sent in this session — `↑` again walks further back, `↓` walks forward and restores the draft you had before browsing. It only replaces the draft text: nothing is withdrawn from the context, sent or rewritten. Toggle it in **Settings**.
|
|
24
25
|
- **Every view stays in sync.** Rewind in one tab or window and other open views of the same session hide the withdrawn rows immediately, without a reload (the notice travels through `localStorage`; the host's cuts remain the only source of truth).
|
|
25
26
|
- **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).
|
|
26
27
|
|
|
@@ -35,6 +36,16 @@ The plugin also registers one **read-only** tool, `session_rewind_candidates`, s
|
|
|
35
36
|
|
|
36
37
|
The tool never rewinds, never edits files and never appends to the session log — the rewind itself stays a user action (that is the point of the ADR 0006 design). Set `readOnlyTool: false` to unregister it; **Settings → Diagnostics** reports whether it registered.
|
|
37
38
|
|
|
39
|
+
## What it looks like
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
Both screenshots come from a throwaway DSH home containing only a synthetic workspace and session (`pnpm shots:session-rewind` regenerates them), cropped to the dialog and the panel.
|
|
46
|
+
|
|
47
|
+
> Image links are absolute (`raw.githubusercontent.com`) on purpose: npm does not ship `assets/` in the tarball, so a relative path would render as a broken image on npmjs.com.
|
|
48
|
+
|
|
38
49
|
## Install
|
|
39
50
|
|
|
40
51
|
```bash
|
|
@@ -59,6 +70,8 @@ Re-run `pnpm pack` + `add` after changing the code. The plugin has a host half,
|
|
|
59
70
|
|
|
60
71
|
**Commands.** `/rewind` opens the same candidate list. `/rewind @<seq> [chat|both]` executes directly (the preview's seq is shown in the list). `/rewind` is decorated by the client; the bare form never sends text to the model.
|
|
61
72
|
|
|
73
|
+
**Composer history — `↑` / `↓`.** Focus the composer and press `↑`: the newest prompt you sent in this session fills the draft; `↑` again walks further back, `↓` walks forward, and one more `↓` past the newest entry restores the draft you were typing. It works with an empty draft or when the caret sits on the first line — inside a multi-line draft on a later line the arrows keep moving the caret. Recall never rewinds, never sends and never writes to the session log. History covers the messages still in the model-visible surface (100 newest, older ones dropped) and excludes what an earlier rewind withdrew — those live in the panel's **Withdrawn** tab.
|
|
74
|
+
|
|
62
75
|
**After a rewind** the target text returns to the composer (images are re-attached best-effort) and the composer is focused. If the composer already held a draft, the draft is kept and a notice says so.
|
|
63
76
|
|
|
64
77
|
**What is refused**
|
|
@@ -102,13 +115,14 @@ On startup the plugin also removes data for sessions that no longer exist (they
|
|
|
102
115
|
- **The marker is visible to the model** as a short `(empty message)` user message where the withdrawn turns used to be. It is not shown in the transcript.
|
|
103
116
|
- **No network, no telemetry.** Everything is local; file snapshots may contain sensitive content, so treat `<DSH_HOME>/session-rewind/` like your workspace.
|
|
104
117
|
- **Private client seams.** Hiding rows and injecting the ↶ button rely on DSH's internal view/slot registries. They are shape-checked at install and degrade to “context rewound, transcript not hidden” or “button missing” rather than breaking a session.
|
|
118
|
+
- **Recall is a draft edit.** It fills the composer through the host facade, so `Ctrl+Z` steps back through recalls like any other draft change; it never appends to the session or the model context.
|
|
105
119
|
- **POSIX/macOS paths only** in this version; Windows separators and permission bits are not verified.
|
|
106
120
|
|
|
107
121
|
## Compatibility
|
|
108
122
|
|
|
109
|
-
| DSH version | Status
|
|
110
|
-
| ----------- |
|
|
111
|
-
| 0.2.2-rc.1 | Verified:
|
|
123
|
+
| DSH version | Status |
|
|
124
|
+
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
125
|
+
| 0.2.2-rc.1 | Verified 2026-11-09: 205 unit tests + web `smoke:session-rewind` 49/49 (temp DSH home) + desktop `smoke:session-rewind-desktop` 23/23 read-only and 30/30 with `--recall` (real Electron renderer, CDP). |
|
|
112
126
|
|
|
113
127
|
The plugin declares no `engines.dsh` and no `@deepseek-ai/*` peer range: DSH is pre-release and semver ranges over prereleases are unreliable (see [ADR 0005](../../docs/adr/0005-dsh-version-compatibility.md)). Treat the table as the compatibility statement. After a DSH upgrade run `pnpm probe:runtime`, `pnpm smoke:session-rewind`, and check the plugin's diagnostics tab; if a private seam changed, the plugin degrades instead of breaking the session.
|
|
114
128
|
|
|
@@ -118,7 +132,7 @@ The plugin declares no `engines.dsh` and no `@deepseek-ai/*` peer range: DSH is
|
|
|
118
132
|
pnpm test # unit tests (host engine, host RPC, client helpers)
|
|
119
133
|
pnpm verify # repo-wide gates
|
|
120
134
|
pnpm smoke:session-rewind # temp DSH home + real GUI end-to-end (needs dsh & ego-browser)
|
|
121
|
-
pnpm smoke:session-rewind-desktop # desktop (Electron)
|
|
135
|
+
pnpm smoke:session-rewind-desktop # desktop (Electron) read-only check (23); --recall adds 5 composer-recall checks, --deep --workspace <dir> adds the file-mode run
|
|
122
136
|
```
|
|
123
137
|
|
|
124
138
|
Design notes: [ADR 0006](../../docs/adr/0006-session-rewind-architecture.md) and [ADR 0007](../../docs/adr/0007-session-rewind-file-restore.md) (Chinese; the repository docs are written in Chinese).
|
package/README.zh-CN.md
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
- **状态变更提示**:如果要撤回的区间里改过会话状态(plan 模式、goal、待办、模型选择、权限/沙箱预设、智能体预设、定时任务、工作目录),确认框会列出来并明确说明——回溯只撤回对话,**不会**回滚这些状态。
|
|
22
22
|
- **运行中也能回退**:确认回退时如果这一轮正在跑,会先取消它(保留队列)、等它空闲,再对新历史重新求值。
|
|
23
23
|
- **输入框**:目标消息文本(以及图片)放回输入框,改完可直接重发;已有草稿绝不覆盖。勾选「回退后立即重发」可一键重试这一轮(仅对话模式)。
|
|
24
|
+
- **不回溯也能召回历史输入**:输入框为空时(或光标在第一行时)按 `↑`,上一条已发送的提示词回到草稿;再按 `↑` 继续往前翻,`↓` 往回走、到底时恢复你浏览前正在写的草稿。它只替换草稿文本,不从上下文撤回、不发送、不改写任何东西。可在**设置**里关闭。
|
|
24
25
|
- **多视图自动同步**:在一个标签页/窗口里回溯后,其它已打开的同会话视图会立刻隐藏被撤回的行,无需刷新(通知走 `localStorage`,渲染只认宿主 cuts)。
|
|
25
26
|
- **回溯面板**:会话头部新增一个入口,四个页签:**已撤回**(每次回溯撤回的全部消息,可搜索、复制、放回输入框)、**回溯记录**(时间线:每次回溯与每次救援点恢复,新在上;范围、条数、token、文件结果)、**救援点**(手动恢复文件)、**设置**(默认范围、每页数量、列表确认、诊断)。
|
|
26
27
|
|
|
@@ -35,6 +36,16 @@
|
|
|
35
36
|
|
|
36
37
|
这个工具不回溯、不碰文件、不写会话日志——真正的回退始终由用户在界面确认(这是 ADR 0006 的设计前提)。设 `readOnlyTool: false` 可关闭;**设置 → 诊断**里能看到它是否注册成功。
|
|
37
38
|
|
|
39
|
+
## 长什么样
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
两张图都来自一个只含**合成工作区与合成会话**的临时 DSH 目录(`pnpm shots:session-rewind` 可重新生成),并且按 DOM 矩形裁剪到对话框/面板本身。
|
|
46
|
+
|
|
47
|
+
> 图片用绝对地址(`raw.githubusercontent.com`)而不是相对路径:npm 的 tarball 不含 `assets/`,相对路径在 npmjs.com 上会是破图。
|
|
48
|
+
|
|
38
49
|
## 安装
|
|
39
50
|
|
|
40
51
|
```bash
|
|
@@ -59,6 +70,8 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
|
|
|
59
70
|
|
|
60
71
|
**命令。** `/rewind` 打开同一份候选列表;`/rewind @<序号> [chat|both]` 直接执行(序号在列表里可见)。客户端会接管裸 `/rewind`,不会把命令发给模型。
|
|
61
72
|
|
|
73
|
+
**输入框历史 — `↑` / `↓`。** 把光标放进输入框后按 `↑`:本会话最新一条已发送提示词填进草稿;再按 `↑` 继续往前,`↓` 往回走,越过最新一条后再按一次 `↓` 就恢复你浏览前正在写的草稿。空草稿或光标在第一行时生效;多行草稿里光标在中间行时,方向键仍然只是移动光标。召回不回溯、不发送、不写会话日志。历史覆盖仍在模型可见上下文里的消息(取最新 100 条),已被回溯撤下的消息不在其中——它们在面板的**已撤回**页签里。
|
|
74
|
+
|
|
62
75
|
**回溯之后**,目标文本回到输入框(图片尽力重新挂回)并获得焦点。如果输入框里本来有草稿,不会被覆盖,并会提示草稿已保留。
|
|
63
76
|
|
|
64
77
|
**会被拒绝的情况**
|
|
@@ -102,13 +115,14 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
|
|
|
102
115
|
- **marker 对模型可见**:在被撤回回合的位置,模型会看到一条很短的 `(empty message)` user 消息;界面不显示它。
|
|
103
116
|
- **不联网、不上报**;文件快照可能含敏感内容,请把 `<DSH_HOME>/session-rewind/` 当工作区数据对待。
|
|
104
117
|
- **客户端私有 seam**:隐藏行和注入 ↶ 按钮依赖 DSH 内部 view/slot 注册表;安装时做形状检查,失效时降级为「上下文已回溯、界面不隐藏」或「按钮消失」,不破坏会话。
|
|
118
|
+
- **召回只是改草稿**:它通过宿主输入框 facade 整篇替换草稿,所以 `Ctrl+Z` 会像普通编辑一样一步步退回;它不会向会话或模型上下文追加任何东西。
|
|
105
119
|
- **本版只验证 POSIX/macOS 路径**;Windows 分隔符与权限位未验证。
|
|
106
120
|
|
|
107
121
|
## 兼容性
|
|
108
122
|
|
|
109
|
-
| DSH 版本 | 状态
|
|
110
|
-
| ---------- |
|
|
111
|
-
| 0.2.2-rc.1 | 已验证:
|
|
123
|
+
| DSH 版本 | 状态 |
|
|
124
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
125
|
+
| 0.2.2-rc.1 | 2026-11-09 已验证:205 个单测 + Web `smoke:session-rewind` 49/49(临时 DSH home)+ 桌面 `smoke:session-rewind-desktop` 只读 23/23、加 `--recall` 30/30(真实 Electron 渲染进程,走 CDP)。 |
|
|
112
126
|
|
|
113
127
|
插件不声明 `engines.dsh`,也不声明 `@deepseek-ai/*` 的 peer 范围:DSH 还在预发布阶段,预发布版本的 semver 范围不可靠(见 [ADR 0005](../../docs/adr/0005-dsh-version-compatibility.md))。以这张表为准。DSH 升级后跑 `pnpm probe:runtime`、`pnpm smoke:session-rewind`,并看插件诊断页签;私有 seam 变了会降级而不是让会话出错。
|
|
114
128
|
|
|
@@ -118,7 +132,7 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
|
|
|
118
132
|
pnpm test # 单测(宿主文件引擎、宿主 RPC、客户端 helper)
|
|
119
133
|
pnpm verify # 仓库统一门禁
|
|
120
134
|
pnpm smoke:session-rewind # 临时 DSH 目录 + 真实 GUI 端到端(需要 dsh 与 ego-browser)
|
|
121
|
-
pnpm smoke:session-rewind-desktop # 桌面(Electron
|
|
135
|
+
pnpm smoke:session-rewind-desktop # 桌面(Electron)只读回归(23 项);--recall 再加 5 条召回断言,--deep --workspace <dir> 再跑文件模式
|
|
122
136
|
```
|
|
123
137
|
|
|
124
138
|
设计记录:[ADR 0006](../../docs/adr/0006-session-rewind-architecture.md)、[ADR 0007](../../docs/adr/0007-session-rewind-file-restore.md)。
|
package/lib/client.js
CHANGED
|
@@ -147,6 +147,9 @@ window.__ModuleLoader__.load({
|
|
|
147
147
|
"settings.pageSize": "候选每页数量",
|
|
148
148
|
"settings.confirmOnList": "从候选列表选择时先确认",
|
|
149
149
|
"settings.confirmOnListHint": "关闭后,默认「仅回退对话」时会直接执行;文件模式始终先确认。",
|
|
150
|
+
"settings.recall": "输入框 ↑/↓ 召回历史输入",
|
|
151
|
+
"settings.recallHint":
|
|
152
|
+
"空草稿(或光标在第一行)按 ↑ 召回上一条已发送消息,↓ 前进、到底恢复你原来的草稿;只填草稿,不回溯、不发送。",
|
|
150
153
|
"settings.saved": "已保存",
|
|
151
154
|
"settings.healthTitle": "诊断",
|
|
152
155
|
"settings.healthRefresh": "刷新",
|
|
@@ -295,6 +298,9 @@ window.__ModuleLoader__.load({
|
|
|
295
298
|
"settings.confirmOnList": "Confirm before executing from the list",
|
|
296
299
|
"settings.confirmOnListHint":
|
|
297
300
|
"When off, the default conversation-only mode runs directly; file mode always asks first.",
|
|
301
|
+
"settings.recall": "↑/↓ recalls earlier prompts in the composer",
|
|
302
|
+
"settings.recallHint":
|
|
303
|
+
"In an empty draft (or with the caret on the first line) ↑ recalls the last prompt you sent, ↓ walks forward and restores your own draft at the bottom; it only fills the draft — never rewinds, never sends.",
|
|
298
304
|
"settings.saved": "Saved",
|
|
299
305
|
"settings.healthTitle": "Diagnostics",
|
|
300
306
|
"settings.healthRefresh": "Refresh",
|
|
@@ -606,6 +612,7 @@ window.__ModuleLoader__.load({
|
|
|
606
612
|
defaultMode: "chat",
|
|
607
613
|
pageSize: 100,
|
|
608
614
|
confirmOnList: true,
|
|
615
|
+
recallEnabled: true,
|
|
609
616
|
});
|
|
610
617
|
|
|
611
618
|
/** 候选每页数量的合法范围。 */
|
|
@@ -642,7 +649,7 @@ window.__ModuleLoader__.load({
|
|
|
642
649
|
/**
|
|
643
650
|
* 规整客户端偏好(localStorage 里可能是旧版本、脏数据或非对象)。
|
|
644
651
|
* @param {any} raw
|
|
645
|
-
* @returns {{ defaultMode: "chat" | "both", pageSize: number, confirmOnList: boolean }}
|
|
652
|
+
* @returns {{ defaultMode: "chat" | "both", pageSize: number, confirmOnList: boolean, recallEnabled: boolean }}
|
|
646
653
|
*/
|
|
647
654
|
function sanitizePrefs(raw) {
|
|
648
655
|
let source = raw && typeof raw === "object" ? raw : {};
|
|
@@ -650,6 +657,155 @@ window.__ModuleLoader__.load({
|
|
|
650
657
|
defaultMode: source.defaultMode === "both" ? "both" : "chat",
|
|
651
658
|
pageSize: clampInt(source.pageSize, PAGE_SIZE_MIN, PAGE_SIZE_MAX, PREF_DEFAULTS.pageSize),
|
|
652
659
|
confirmOnList: source.confirmOnList !== false,
|
|
660
|
+
recallEnabled: source.recallEnabled !== false,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ------------------------------------------------------------------
|
|
665
|
+
// 输入历史召回(非破坏性):纯数据部分
|
|
666
|
+
// ------------------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
/** 召回历史条目上限(更旧的先丢)。 */
|
|
669
|
+
let RECALL_LIMIT = 100;
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* 历史缓存的保鲜期(ms)。宿主 RPC 是本地调用,成本很低;用「先给旧的、后台刷新」
|
|
673
|
+
* 而不是每次按键都等一次往返。
|
|
674
|
+
*/
|
|
675
|
+
let RECALL_TTL_MS = 1500;
|
|
676
|
+
|
|
677
|
+
/** 判定首/末行时允许的误差(行高比例):行内任意位置都算该行。 */
|
|
678
|
+
let CARET_LINE_RATIO = 0.6;
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* 把宿主 `sessionRewind/candidates` 的一页(新→旧)转成召回历史(旧→新)。
|
|
682
|
+
* host 已经过滤掉非真人消息、已被 compaction 遮蔽的消息,这里只补两件事:
|
|
683
|
+
* 丢掉空文本、截到 RECALL_LIMIT。
|
|
684
|
+
* @param {any} candidates
|
|
685
|
+
* @returns {Array<{seq: number, text: string}>}
|
|
686
|
+
*/
|
|
687
|
+
function historyFromCandidates(candidates) {
|
|
688
|
+
let source = Array.isArray(candidates) ? candidates : [];
|
|
689
|
+
/** @type {Array<{seq: number, text: string}>} */
|
|
690
|
+
let out = [];
|
|
691
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
692
|
+
let item = source[i];
|
|
693
|
+
if (!item || typeof item.seq !== "number") continue;
|
|
694
|
+
let text = typeof item.text === "string" ? item.text : "";
|
|
695
|
+
if (text.trim() === "") continue;
|
|
696
|
+
out.push({ seq: item.seq, text: text });
|
|
697
|
+
}
|
|
698
|
+
/* host 给的是新→旧;召回要按时间升序(末尾最新) */
|
|
699
|
+
out.reverse();
|
|
700
|
+
return out.slice(-RECALL_LIMIT);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* 是否是召回手势:裸 ↑/↓、无组合键、非输入法组合、尚未被别的监听者消费。
|
|
705
|
+
* 宿主在 fixed input 通道里已经算好 composing/defaultPrevented,这里只做判定。
|
|
706
|
+
* @param {any} gesture
|
|
707
|
+
* @returns {boolean}
|
|
708
|
+
*/
|
|
709
|
+
function isRecallGesture(gesture) {
|
|
710
|
+
if (!gesture || typeof gesture !== "object") return false;
|
|
711
|
+
if (gesture.code !== "ArrowUp" && gesture.code !== "ArrowDown") return false;
|
|
712
|
+
if (gesture.control || gesture.alt || gesture.shift || gesture.meta) return false;
|
|
713
|
+
if (gesture.composing) return false;
|
|
714
|
+
if (gesture.defaultPrevented) return false;
|
|
715
|
+
return true;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* 浏览一步(纯函数):
|
|
720
|
+
* - `index === -1` 表示停在「正在编辑的那一行」;
|
|
721
|
+
* - `saved` 是开始浏览前用户自己的草稿,一路 ↓ 到底时原样还回去;
|
|
722
|
+
* - `restore !== null` 表示这一步要写回的文本(live line),否则按 index 取历史。
|
|
723
|
+
* @param {{index: number, saved: string}} state
|
|
724
|
+
* @param {"ArrowUp" | "ArrowDown"} code
|
|
725
|
+
* @param {number} total 历史条数
|
|
726
|
+
* @param {string} draft 当前草稿
|
|
727
|
+
* @returns {{index: number, saved: string, restore: string | null}}
|
|
728
|
+
*/
|
|
729
|
+
function recallStep(state, code, total, draft) {
|
|
730
|
+
let index = state ? state.index : -1;
|
|
731
|
+
let saved = state ? state.saved : "";
|
|
732
|
+
if (total <= 0) return { index: -1, saved: "", restore: null };
|
|
733
|
+
if (code === "ArrowUp") {
|
|
734
|
+
if (index < 0) return { index: total - 1, saved: draft || "", restore: null };
|
|
735
|
+
return { index: Math.max(0, index - 1), saved: saved, restore: null };
|
|
736
|
+
}
|
|
737
|
+
if (index < 0) return { index: -1, saved: "", restore: null };
|
|
738
|
+
let next = index + 1;
|
|
739
|
+
if (next >= total) return { index: -1, saved: "", restore: saved };
|
|
740
|
+
return { index: next, saved: saved, restore: null };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* 光标是否贴着输入框的首行 / 末行(只看矩形,便于单测)。
|
|
745
|
+
*
|
|
746
|
+
* 返回两个独立的布尔值而不是一个枚举:单行草稿的「首行」就是「末行」,
|
|
747
|
+
* 用枚举会丢掉其中一个(↑ 能用、↓ 反而被挡住的经典 bug)。
|
|
748
|
+
* @param {any} rect 光标的 getBoundingClientRect()
|
|
749
|
+
* @param {any} box 输入框的 getBoundingClientRect()
|
|
750
|
+
* @param {number} lineHeight 行高(px)
|
|
751
|
+
* @returns {{first: boolean, last: boolean}}
|
|
752
|
+
*/
|
|
753
|
+
function caretEdges(rect, box, lineHeight) {
|
|
754
|
+
let none = { first: false, last: false };
|
|
755
|
+
if (!rect || !box) return none;
|
|
756
|
+
if (
|
|
757
|
+
typeof rect.top !== "number" ||
|
|
758
|
+
typeof rect.bottom !== "number" ||
|
|
759
|
+
typeof box.top !== "number" ||
|
|
760
|
+
typeof box.bottom !== "number"
|
|
761
|
+
)
|
|
762
|
+
return none;
|
|
763
|
+
let line = typeof lineHeight === "number" && lineHeight > 0 ? lineHeight : 20;
|
|
764
|
+
let tolerance = line * CARET_LINE_RATIO;
|
|
765
|
+
return {
|
|
766
|
+
first: rect.top - box.top <= tolerance,
|
|
767
|
+
last: box.bottom - rect.bottom <= tolerance,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* 每会话的召回历史缓存:`peek` 拿旧值(立刻可用),`fresh` 判断要不要后台刷新。
|
|
773
|
+
* 宿主会话日志是唯一事实来源,这里只缓存它的投影。
|
|
774
|
+
* @param {() => number} [now] 可注入的时钟,便于单测
|
|
775
|
+
*/
|
|
776
|
+
function createRecallCache(now) {
|
|
777
|
+
let clock = typeof now === "function" ? now : Date.now;
|
|
778
|
+
/** @type {Map<string, {items: Array<{seq: number, text: string}>, at: number}>} */
|
|
779
|
+
let entries = new Map();
|
|
780
|
+
|
|
781
|
+
/** @param {any} sessionId */
|
|
782
|
+
function keyOf(sessionId) {
|
|
783
|
+
return typeof sessionId === "string" ? sessionId : "";
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return {
|
|
787
|
+
/** 最近一次读到的历史(可能已过期;没有时 undefined)。 */
|
|
788
|
+
peek(/** @type {any} */ sessionId) {
|
|
789
|
+
let entry = entries.get(keyOf(sessionId));
|
|
790
|
+
return entry ? entry.items : undefined;
|
|
791
|
+
},
|
|
792
|
+
/** 是否还在保鲜期内。 */
|
|
793
|
+
fresh(/** @type {any} */ sessionId) {
|
|
794
|
+
let entry = entries.get(keyOf(sessionId));
|
|
795
|
+
if (!entry) return false;
|
|
796
|
+
return clock() - entry.at < RECALL_TTL_MS;
|
|
797
|
+
},
|
|
798
|
+
/** 写入一页历史。 */
|
|
799
|
+
set(/** @type {any} */ sessionId, /** @type {any} */ items) {
|
|
800
|
+
entries.set(keyOf(sessionId), {
|
|
801
|
+
items: Array.isArray(items) ? items : [],
|
|
802
|
+
at: clock(),
|
|
803
|
+
});
|
|
804
|
+
},
|
|
805
|
+
/** 回溯/切换后丢弃缓存,下一次按键重新拉。 */
|
|
806
|
+
forget(/** @type {any} */ sessionId) {
|
|
807
|
+
entries.delete(keyOf(sessionId));
|
|
808
|
+
},
|
|
653
809
|
};
|
|
654
810
|
}
|
|
655
811
|
|
|
@@ -2708,6 +2864,24 @@ window.__ModuleLoader__.load({
|
|
|
2708
2864
|
h("div", { className: "dsh-srw-option-hint" }, t("settings.confirmOnListHint")),
|
|
2709
2865
|
);
|
|
2710
2866
|
|
|
2867
|
+
let recallRow = h(
|
|
2868
|
+
"div",
|
|
2869
|
+
{ className: "dsh-srw-setting" },
|
|
2870
|
+
h(
|
|
2871
|
+
"label",
|
|
2872
|
+
{ className: "dsh-srw-check" },
|
|
2873
|
+
h("input", {
|
|
2874
|
+
type: "checkbox",
|
|
2875
|
+
checked: prefs.recallEnabled !== false,
|
|
2876
|
+
onChange: (/** @type {any} */ event) => {
|
|
2877
|
+
if (event?.target) api.updatePrefs({ recallEnabled: event.target.checked === true });
|
|
2878
|
+
},
|
|
2879
|
+
}),
|
|
2880
|
+
h("span", null, t("settings.recall")),
|
|
2881
|
+
),
|
|
2882
|
+
h("div", { className: "dsh-srw-option-hint" }, t("settings.recallHint")),
|
|
2883
|
+
);
|
|
2884
|
+
|
|
2711
2885
|
let healthBlock;
|
|
2712
2886
|
if (health.loading) {
|
|
2713
2887
|
healthBlock = h("div", { className: "dsh-srw-dim" }, t("settings.healthLoading"));
|
|
@@ -2787,6 +2961,7 @@ window.__ModuleLoader__.load({
|
|
|
2787
2961
|
),
|
|
2788
2962
|
pageRow,
|
|
2789
2963
|
confirmRow,
|
|
2964
|
+
recallRow,
|
|
2790
2965
|
data.saved === true
|
|
2791
2966
|
? h("div", { className: "dsh-srw-dim dsh-srw-saved" }, t("settings.saved"))
|
|
2792
2967
|
: null,
|
|
@@ -3174,6 +3349,9 @@ window.__ModuleLoader__.load({
|
|
|
3174
3349
|
|
|
3175
3350
|
let cutStore = createCutStore();
|
|
3176
3351
|
|
|
3352
|
+
/** 输入框召回历史缓存(来源是宿主 sessionRewind/candidates)。 */
|
|
3353
|
+
let recallCache = createRecallCache();
|
|
3354
|
+
|
|
3177
3355
|
/** 读客户端偏好(localStorage 缺失/脏数据都退回默认值)。 */
|
|
3178
3356
|
function readPrefs() {
|
|
3179
3357
|
try {
|
|
@@ -3397,8 +3575,13 @@ window.__ModuleLoader__.load({
|
|
|
3397
3575
|
}
|
|
3398
3576
|
}
|
|
3399
3577
|
|
|
3400
|
-
|
|
3401
|
-
|
|
3578
|
+
/**
|
|
3579
|
+
* 某会话输入框的 façade(`conversation.input.for(scope)`)。
|
|
3580
|
+
* 拿不到时返回 undefined —— 调用方自己决定退化路径。
|
|
3581
|
+
* @param {any} sessionId
|
|
3582
|
+
* @returns {any}
|
|
3583
|
+
*/
|
|
3584
|
+
function composerFacadeOf(/** @type {any} */ sessionId) {
|
|
3402
3585
|
try {
|
|
3403
3586
|
let conversation = safeGet(ctx, "conversation");
|
|
3404
3587
|
let scope =
|
|
@@ -3410,11 +3593,281 @@ window.__ModuleLoader__.load({
|
|
|
3410
3593
|
typeof conversation.input.for === "function" &&
|
|
3411
3594
|
scope !== undefined
|
|
3412
3595
|
) {
|
|
3413
|
-
|
|
3596
|
+
return conversation.input.for(scope);
|
|
3597
|
+
}
|
|
3598
|
+
} catch (_error) {
|
|
3599
|
+
/* 退化到 DOM 通道 */
|
|
3600
|
+
}
|
|
3601
|
+
return undefined;
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
/**
|
|
3605
|
+
* 把整段文本写进输入框草稿(召回用:允许覆盖当前草稿,与 fillComposer 的区别就在这里)。
|
|
3606
|
+
* 优先走宿主 façade 的 `setDraft`;退化路径与 fillComposer 的 DOM 兜底一致。
|
|
3607
|
+
* @param {any} sessionId
|
|
3608
|
+
* @param {any} text
|
|
3609
|
+
* @returns {"ok" | "none"}
|
|
3610
|
+
*/
|
|
3611
|
+
function writeComposerDraft(/** @type {any} */ sessionId, /** @type {any} */ text) {
|
|
3612
|
+
let value = typeof text === "string" ? text : "";
|
|
3613
|
+
let facade = composerFacadeOf(sessionId);
|
|
3614
|
+
if (facade && typeof facade.setDraft === "function") {
|
|
3615
|
+
try {
|
|
3616
|
+
facade.setDraft(value);
|
|
3617
|
+
return "ok";
|
|
3618
|
+
} catch (_error) {
|
|
3619
|
+
/* 退化到 DOM 通道 */
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
let el = null;
|
|
3623
|
+
try {
|
|
3624
|
+
el = document.querySelector("[data-composer-input]");
|
|
3625
|
+
} catch (_error) {
|
|
3626
|
+
el = null;
|
|
3627
|
+
}
|
|
3628
|
+
if (!el || !(el instanceof HTMLElement)) return "none";
|
|
3629
|
+
try {
|
|
3630
|
+
el.focus();
|
|
3631
|
+
let selection =
|
|
3632
|
+
typeof document.getSelection === "function" ? document.getSelection() : null;
|
|
3633
|
+
if (selection) {
|
|
3634
|
+
let range = document.createRange();
|
|
3635
|
+
range.selectNodeContents(el);
|
|
3636
|
+
selection.removeAllRanges();
|
|
3637
|
+
selection.addRange(range);
|
|
3638
|
+
}
|
|
3639
|
+
let ok = false;
|
|
3640
|
+
try {
|
|
3641
|
+
ok = document.execCommand("insertText", false, value);
|
|
3642
|
+
} catch (_error) {
|
|
3643
|
+
ok = false;
|
|
3644
|
+
}
|
|
3645
|
+
if (!ok) {
|
|
3646
|
+
el.textContent = value;
|
|
3647
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
3648
|
+
ok = true;
|
|
3649
|
+
}
|
|
3650
|
+
return ok ? "ok" : "none";
|
|
3651
|
+
} catch (_error) {
|
|
3652
|
+
return "none";
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
/**
|
|
3657
|
+
* 光标是否贴着输入框首/末行(浏览器侧封装;纯判定交给 caretEdges)。
|
|
3658
|
+
* @param {any} el contenteditable 输入框
|
|
3659
|
+
* @returns {{first: boolean, last: boolean}}
|
|
3660
|
+
*/
|
|
3661
|
+
function caretEdgesOf(/** @type {any} */ el) {
|
|
3662
|
+
try {
|
|
3663
|
+
let selection =
|
|
3664
|
+
typeof document.getSelection === "function" ? document.getSelection() : null;
|
|
3665
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed !== true)
|
|
3666
|
+
return { first: false, last: false };
|
|
3667
|
+
let range = selection.getRangeAt(0).cloneRange();
|
|
3668
|
+
range.collapse(true);
|
|
3669
|
+
let rect = range.getBoundingClientRect();
|
|
3670
|
+
if (
|
|
3671
|
+
!rect ||
|
|
3672
|
+
(rect.top === 0 && rect.bottom === 0 && rect.width === 0 && rect.height === 0)
|
|
3673
|
+
) {
|
|
3674
|
+
let rects = range.getClientRects();
|
|
3675
|
+
if (rects && rects.length > 0) rect = rects[0];
|
|
3414
3676
|
}
|
|
3677
|
+
let box = el.getBoundingClientRect();
|
|
3678
|
+
let style =
|
|
3679
|
+
typeof window.getComputedStyle === "function" ? window.getComputedStyle(el) : null;
|
|
3680
|
+
let lineHeight = style ? parseFloat(style.lineHeight) : NaN;
|
|
3681
|
+
if (!Number.isFinite(lineHeight) || lineHeight <= 0)
|
|
3682
|
+
lineHeight = rect && rect.height > 0 ? rect.height : 20;
|
|
3683
|
+
return caretEdges(rect, box, lineHeight);
|
|
3415
3684
|
} catch (_error) {
|
|
3416
|
-
|
|
3685
|
+
return { first: false, last: false };
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
/**
|
|
3690
|
+
* 读输入框草稿与提交阶段:优先宿主 façade,拿不到时退回 DOM 文本。
|
|
3691
|
+
* @param {any} sessionId
|
|
3692
|
+
* @param {any} target 焦点里的 contenteditable
|
|
3693
|
+
* @returns {{draft: string, phase: string}}
|
|
3694
|
+
*/
|
|
3695
|
+
function readComposer(/** @type {any} */ sessionId, /** @type {any} */ target) {
|
|
3696
|
+
let facade = composerFacadeOf(sessionId);
|
|
3697
|
+
let snapshot = facade ? facade.snapshot : undefined;
|
|
3698
|
+
if (snapshot) {
|
|
3699
|
+
return {
|
|
3700
|
+
draft: typeof snapshot.draft === "string" ? snapshot.draft : "",
|
|
3701
|
+
phase: typeof snapshot.phase === "string" ? snapshot.phase : "plain",
|
|
3702
|
+
};
|
|
3703
|
+
}
|
|
3704
|
+
return {
|
|
3705
|
+
draft: typeof target?.textContent === "string" ? target.textContent : "",
|
|
3706
|
+
phase: "plain",
|
|
3707
|
+
};
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
/**
|
|
3711
|
+
* 输入框历史召回(↑/↓):只替换草稿,绝不回溯、绝不发送。
|
|
3712
|
+
*
|
|
3713
|
+
* 历史来自本插件自己的宿主 RPC `sessionRewind/candidates`(真人消息、surface 感知、
|
|
3714
|
+
* 完整文本),按会话缓存;键处理走宿主的 fixed input 通道
|
|
3715
|
+
* (`shortcuts.observeFixedInput`)——裸 ↑/↓ 已被宿主固定动作 `move` 占用,
|
|
3716
|
+
* 注册快捷键会冲突,而观察通道连输入法组合与「是否已被消费」都替我们算好了。
|
|
3717
|
+
* @param {any} scope 拿到 shortcuts 的作用域
|
|
3718
|
+
* @param {any} cache createRecallCache() 的实例
|
|
3719
|
+
*/
|
|
3720
|
+
function installComposerRecall(/** @type {any} */ scope, /** @type {any} */ cache) {
|
|
3721
|
+
let shortcuts = scope?.shortcuts;
|
|
3722
|
+
if (!shortcuts || typeof shortcuts.observeFixedInput !== "function") return;
|
|
3723
|
+
/**
|
|
3724
|
+
* 每会话的浏览状态。`written` 记录我们最后写进草稿的文本:
|
|
3725
|
+
* façade 的快照可能晚一拍,用它区分「快照还没跟上」和「用户真的改了」。
|
|
3726
|
+
* @type {Map<string, {index: number, saved: string, written: string}>}
|
|
3727
|
+
*/
|
|
3728
|
+
let states = new Map();
|
|
3729
|
+
/** @type {Map<string, Promise<any>>} 并发合并:同一会话只发一次 RPC。 */
|
|
3730
|
+
let inflight = new Map();
|
|
3731
|
+
|
|
3732
|
+
/** @param {any} sessionId */
|
|
3733
|
+
function stateOf(sessionId) {
|
|
3734
|
+
let found = states.get(sessionId);
|
|
3735
|
+
if (!found) {
|
|
3736
|
+
found = { index: -1, saved: "", written: "" };
|
|
3737
|
+
states.set(sessionId, found);
|
|
3738
|
+
}
|
|
3739
|
+
return found;
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
/**
|
|
3743
|
+
* 拉一页历史写进缓存。
|
|
3744
|
+
* @param {any} sessionId
|
|
3745
|
+
* @returns {Promise<Array<{seq: number, text: string}>>}
|
|
3746
|
+
*/
|
|
3747
|
+
function loadHistory(sessionId) {
|
|
3748
|
+
let running = inflight.get(sessionId);
|
|
3749
|
+
if (running) return running;
|
|
3750
|
+
let promise = call("candidates", { sessionId: sessionId, limit: RECALL_LIMIT })
|
|
3751
|
+
.then((value) => historyFromCandidates(normalizeCandidatePage(value).candidates))
|
|
3752
|
+
.catch(() => [])
|
|
3753
|
+
.then((items) => {
|
|
3754
|
+
inflight.delete(sessionId);
|
|
3755
|
+
cache.set(sessionId, items);
|
|
3756
|
+
return items;
|
|
3757
|
+
});
|
|
3758
|
+
inflight.set(sessionId, promise);
|
|
3759
|
+
return promise;
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
/**
|
|
3763
|
+
* 走一步浏览:算新状态、必要时写草稿。
|
|
3764
|
+
* @param {any} sessionId
|
|
3765
|
+
* @param {"ArrowUp" | "ArrowDown"} code
|
|
3766
|
+
* @param {string} draft
|
|
3767
|
+
* @param {Array<{seq: number, text: string}>} items
|
|
3768
|
+
* @param {any} input fixed-input 输入(异步补填时传空对象,没有 consume)
|
|
3769
|
+
* @returns {void}
|
|
3770
|
+
*/
|
|
3771
|
+
function stepOnce(sessionId, code, draft, items, input) {
|
|
3772
|
+
let state = stateOf(sessionId);
|
|
3773
|
+
if (state.index >= 0) {
|
|
3774
|
+
let current = items[state.index];
|
|
3775
|
+
/* 用户自己改过草稿(既不是我们填的那条,也不是刚写进去的那条)→ 退出浏览 */
|
|
3776
|
+
if (!current || (current.text !== draft && state.written !== draft)) {
|
|
3777
|
+
state.index = -1;
|
|
3778
|
+
state.saved = "";
|
|
3779
|
+
state.written = "";
|
|
3780
|
+
return;
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
let step = recallStep(state, code, items.length, draft);
|
|
3784
|
+
state.index = step.index;
|
|
3785
|
+
state.saved = step.saved;
|
|
3786
|
+
let value =
|
|
3787
|
+
step.restore !== null
|
|
3788
|
+
? step.restore
|
|
3789
|
+
: step.index >= 0 && items[step.index]
|
|
3790
|
+
? items[step.index].text
|
|
3791
|
+
: null;
|
|
3792
|
+
if (typeof input?.consume === "function") input.consume();
|
|
3793
|
+
if (value === null) return;
|
|
3794
|
+
if (writeComposerDraft(sessionId, value) !== "ok") {
|
|
3795
|
+
showToastKey("toast.refillNoComposer");
|
|
3796
|
+
return;
|
|
3797
|
+
}
|
|
3798
|
+
state.written = value;
|
|
3417
3799
|
}
|
|
3800
|
+
|
|
3801
|
+
/** @param {any} input */
|
|
3802
|
+
function onFixedInput(input) {
|
|
3803
|
+
try {
|
|
3804
|
+
if (!prefs.recallEnabled) return;
|
|
3805
|
+
if (input?.type !== "keydown") return;
|
|
3806
|
+
if (!isRecallGesture(input.gesture)) return;
|
|
3807
|
+
let context = input.context || {};
|
|
3808
|
+
if (context.modal !== null && context.modal !== undefined) return;
|
|
3809
|
+
if (context.region !== "editable") return;
|
|
3810
|
+
let target = context.target;
|
|
3811
|
+
if (target?.isContentEditable !== true) return;
|
|
3812
|
+
let occurrence = target.closest ? target.closest("[data-conversation-session]") : null;
|
|
3813
|
+
if (!occurrence) return;
|
|
3814
|
+
let sessionId = occurrence.getAttribute("data-conversation-session");
|
|
3815
|
+
if (!sessionId || isInert(sessionId)) return;
|
|
3816
|
+
|
|
3817
|
+
let code = input.gesture.code;
|
|
3818
|
+
let composer = readComposer(sessionId, target);
|
|
3819
|
+
if (composer.phase !== "plain") return;
|
|
3820
|
+
let draft = composer.draft;
|
|
3821
|
+
let browsing = stateOf(sessionId).index >= 0;
|
|
3822
|
+
|
|
3823
|
+
if (!browsing) {
|
|
3824
|
+
if (code === "ArrowDown") return;
|
|
3825
|
+
/* 有草稿时只有光标贴着首行才接管 ↑,否则方向键交给编辑器 */
|
|
3826
|
+
if (draft.trim() !== "" && !caretEdgesOf(target).first) return;
|
|
3827
|
+
} else if (code === "ArrowDown" && !caretEdgesOf(target).last) {
|
|
3828
|
+
return;
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
/* 浏览中:列表必须保持稳定(否则下标会指向别的消息),只有浏览开始时才重拉。 */
|
|
3832
|
+
if (browsing) {
|
|
3833
|
+
let browsingItems = cache.peek(sessionId);
|
|
3834
|
+
if (browsingItems === undefined || browsingItems.length === 0) return;
|
|
3835
|
+
stepOnce(sessionId, code, draft, browsingItems, input);
|
|
3836
|
+
return;
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
/* 开始浏览:缓存还新鲜就直接用,否则先拉一份最新的(本地 RPC,毫秒级),
|
|
3840
|
+
* 这样「刚发出去的那条」一定在历史里。 */
|
|
3841
|
+
let items = cache.peek(sessionId);
|
|
3842
|
+
if (items !== undefined && cache.fresh(sessionId)) {
|
|
3843
|
+
stepOnce(sessionId, code, draft, items, input);
|
|
3844
|
+
return;
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
/* 冷启动或过期:先把方向键吃掉(避免光标乱跳),拿到历史再填草稿 */
|
|
3848
|
+
if (typeof input.consume === "function") input.consume();
|
|
3849
|
+
loadHistory(sessionId)
|
|
3850
|
+
.then((loaded) => {
|
|
3851
|
+
if (loaded.length === 0) return;
|
|
3852
|
+
let again = readComposer(sessionId, target);
|
|
3853
|
+
if (again.phase !== "plain" || again.draft !== draft) return;
|
|
3854
|
+
if (stateOf(sessionId).index >= 0) return;
|
|
3855
|
+
stepOnce(sessionId, "ArrowUp", draft, loaded, null);
|
|
3856
|
+
})
|
|
3857
|
+
.catch(() => {});
|
|
3858
|
+
} catch (_error) {
|
|
3859
|
+
/* 召回是锦上添花:任何异常都不影响输入框本身 */
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3863
|
+
scope.effect(
|
|
3864
|
+
() => shortcuts.observeFixedInput(onFixedInput),
|
|
3865
|
+
"session-rewind: composer recall",
|
|
3866
|
+
);
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3869
|
+
function fillComposer(/** @type {any} */ sessionId, /** @type {any} */ text) {
|
|
3870
|
+
let facade = composerFacadeOf(sessionId);
|
|
3418
3871
|
if (facade) {
|
|
3419
3872
|
let draft = "";
|
|
3420
3873
|
try {
|
|
@@ -3796,6 +4249,8 @@ window.__ModuleLoader__.load({
|
|
|
3796
4249
|
markerSeq: summary.markerSeq === null ? target.seq : summary.markerSeq,
|
|
3797
4250
|
});
|
|
3798
4251
|
rebuildSessionById(sessionId);
|
|
4252
|
+
/* 召回历史也要跟着收缩:被这次回溯撤下的消息不能再被 ↑ 翻出来 */
|
|
4253
|
+
recallCache.forget(sessionId);
|
|
3799
4254
|
let outcome = fillComposer(sessionId, summary.text || target.text || "");
|
|
3800
4255
|
// 同源其它视图也要立刻隐藏这些行(它们不会被这次 append 唤醒)。
|
|
3801
4256
|
broadcastCut(sessionId, summary);
|
|
@@ -4476,6 +4931,9 @@ window.__ModuleLoader__.load({
|
|
|
4476
4931
|
}),
|
|
4477
4932
|
"session-rewind: esc esc",
|
|
4478
4933
|
);
|
|
4934
|
+
|
|
4935
|
+
// 输入框 ↑/↓ 历史召回(非破坏性):同一通道的第二个观察者。
|
|
4936
|
+
installComposerRecall(scope, recallCache);
|
|
4479
4937
|
});
|
|
4480
4938
|
} catch (_error) {
|
|
4481
4939
|
/* shortcuts 缺失时降级:无 Esc Esc 入口 */
|
|
@@ -4645,6 +5103,15 @@ window.__ModuleLoader__.load({
|
|
|
4645
5103
|
rewindSummaryOf: rewindSummaryOf,
|
|
4646
5104
|
normalizeTab: normalizeTab,
|
|
4647
5105
|
modeLabelKey: modeLabelKey,
|
|
5106
|
+
// v3:输入历史召回(非破坏性)
|
|
5107
|
+
RECALL_LIMIT: RECALL_LIMIT,
|
|
5108
|
+
CARET_LINE_RATIO: CARET_LINE_RATIO,
|
|
5109
|
+
historyFromCandidates: historyFromCandidates,
|
|
5110
|
+
isRecallGesture: isRecallGesture,
|
|
5111
|
+
recallStep: recallStep,
|
|
5112
|
+
caretEdges: caretEdges,
|
|
5113
|
+
createRecallCache: createRecallCache,
|
|
5114
|
+
RECALL_TTL_MS: RECALL_TTL_MS,
|
|
4648
5115
|
dictionaries: Object.freeze({ zh: zh, en: en }),
|
|
4649
5116
|
}),
|
|
4650
5117
|
});
|
package/locale/en.json
CHANGED
|
@@ -89,6 +89,8 @@
|
|
|
89
89
|
"settings.pageSize": "Candidates per page",
|
|
90
90
|
"settings.confirmOnList": "Confirm before executing from the list",
|
|
91
91
|
"settings.confirmOnListHint": "When off, the default conversation-only mode runs directly; file mode always asks first.",
|
|
92
|
+
"settings.recall": "↑/↓ recalls earlier prompts in the composer",
|
|
93
|
+
"settings.recallHint": "In an empty draft (or with the caret on the first line) ↑ recalls the last prompt you sent, ↓ walks forward and restores your own draft at the bottom; it only fills the draft — never rewinds, never sends.",
|
|
92
94
|
"settings.saved": "Saved",
|
|
93
95
|
"settings.healthTitle": "Diagnostics",
|
|
94
96
|
"settings.healthRefresh": "Refresh",
|
package/locale/zh.json
CHANGED
|
@@ -89,6 +89,8 @@
|
|
|
89
89
|
"settings.pageSize": "候选每页数量",
|
|
90
90
|
"settings.confirmOnList": "从候选列表选择时先确认",
|
|
91
91
|
"settings.confirmOnListHint": "关闭后,默认「仅回退对话」时会直接执行;文件模式始终先确认。",
|
|
92
|
+
"settings.recall": "输入框 ↑/↓ 召回历史输入",
|
|
93
|
+
"settings.recallHint": "空草稿(或光标在第一行)按 ↑ 召回上一条已发送消息,↓ 前进、到底恢复你原来的草稿;只填草稿,不回溯、不发送。",
|
|
92
94
|
"settings.saved": "已保存",
|
|
93
95
|
"settings.healthTitle": "诊断",
|
|
94
96
|
"settings.healthRefresh": "刷新",
|
package/package.json
CHANGED