@aiti-ai/aiti-draft-keeper 0.0.0-stage → 0.1.8
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/AGENTS.md +28 -0
- package/LICENSE +21 -0
- package/README.md +91 -2
- package/check-client.mjs +273 -0
- package/client/src/aiti-draft-keeper-client.mjs +128 -0
- package/client/src/core.mjs +437 -0
- package/client/src/core.test.mjs +467 -0
- package/deploy-commit.mjs +52 -0
- package/dev-pack.mjs +246 -0
- package/dist/aiti-draft-keeper-client.mjs +128 -0
- package/dist/core.mjs +437 -0
- package/dist/core.test.mjs +467 -0
- package/index.ts +22 -0
- package/package.json +19 -4
- package/reload-instances.mjs +60 -0
- package/tools/deploy.mjs +53 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DraftKeeperCore —— 草稿状态机纯逻辑(唯一所有者)。
|
|
3
|
+
*
|
|
4
|
+
* 设计(composer 契约的收敛):core(pi-web)只发意图(applyIntent);
|
|
5
|
+
* 用户输入/清空经 enhancement 回调(onChange/onKeyChange)进同一状态机。
|
|
6
|
+
* 所有槽位读/写/清/搬在本状态机单一顺序内完成——消除此前 core 直接
|
|
7
|
+
* clearDraft/rekeyDraft 与 keeper onChange 清空链交错产生的竞态窗口
|
|
8
|
+
* (已发送消息残留进 crashrestore-<sid>、「切换丢草稿」等均源于此)。
|
|
9
|
+
*
|
|
10
|
+
* 注入依赖(可测性):
|
|
11
|
+
* storage {getItem,setItem,removeItem,keys()} 槽位持久层
|
|
12
|
+
* timers {setTimeout,clearTimeout} 延时(测试可注入假钟)
|
|
13
|
+
* onRestore(draft) => boolean 写框回调(壳负责 hydration 门控)
|
|
14
|
+
* log(msg) 调试
|
|
15
|
+
* delays {flushMs,scanMs,clearConfirmMs} 各延时(测试调小)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const PREFIX = "crashrestore-";
|
|
19
|
+
|
|
20
|
+
// ---------- 编码 ----------
|
|
21
|
+
|
|
22
|
+
export function encodeDraft(draft, encoder) {
|
|
23
|
+
const bytes = encoder.encode(JSON.stringify(draft));
|
|
24
|
+
let bin = "";
|
|
25
|
+
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
26
|
+
return btoa(bin);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function decodeSlot(raw, decoder) {
|
|
30
|
+
try {
|
|
31
|
+
let ts = 0, b64 = raw;
|
|
32
|
+
const idx = raw.indexOf(":");
|
|
33
|
+
if (idx > 0) { const head = Number(raw.slice(0, idx)); if (Number.isFinite(head)) { ts = head; b64 = raw.slice(idx + 1); } }
|
|
34
|
+
const bin = atob(b64);
|
|
35
|
+
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
36
|
+
const d = JSON.parse(decoder.decode(bytes));
|
|
37
|
+
if (typeof d?.value !== "string") return null;
|
|
38
|
+
return {
|
|
39
|
+
ts,
|
|
40
|
+
draft: {
|
|
41
|
+
value: d.value,
|
|
42
|
+
images: Array.isArray(d.images) ? d.images.filter((im) => im && typeof im.data === "string" && typeof im.mimeType === "string") : [],
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
} catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 现有草稿与失败提交合并:提交在前、原稿在后(原样重复则不重复加)。 */
|
|
49
|
+
export function mergeSubmission(text, images, curText, curImages) {
|
|
50
|
+
const value = curText && !curText.includes(text) ? `${text}\n\n${curText}` : text;
|
|
51
|
+
const mergedImages = [...(images ?? []), ...(curImages ?? [])].slice(0, 10);
|
|
52
|
+
return { value, images: mergedImages };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------- 状态机 ----------
|
|
56
|
+
|
|
57
|
+
export function createKeeperCore(opts) {
|
|
58
|
+
const {
|
|
59
|
+
storage,
|
|
60
|
+
timers = { setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms), clearTimeout: (t) => globalThis.clearTimeout(t) },
|
|
61
|
+
onRestore = () => false,
|
|
62
|
+
log = () => {},
|
|
63
|
+
delays = {},
|
|
64
|
+
textEncoder = new TextEncoder(),
|
|
65
|
+
textDecoder = new TextDecoder(),
|
|
66
|
+
} = opts;
|
|
67
|
+
const flushMs = delays.flushMs ?? 300;
|
|
68
|
+
const scanMs = delays.scanMs ?? 120;
|
|
69
|
+
const clearConfirmMs = delays.clearConfirmMs ?? 300;
|
|
70
|
+
|
|
71
|
+
// ---- 状态 ----
|
|
72
|
+
const drafts = new Map(); // key -> {value,images} 内存未落盘副本
|
|
73
|
+
const flushTimers = new Map(); // key -> timer
|
|
74
|
+
const known = new Map(); // key -> 本页最后显示/写入的 value(null=未显示过)
|
|
75
|
+
const aliases = new Map(); // provisionalKey(new:…) -> sid 会话提升映射
|
|
76
|
+
const clearTimers = new Map(); // key -> timer(清空确认;回调闭包捕获 key,防误清新 key)
|
|
77
|
+
let currentKey; // keeper 内部唯一真相(core 的 draftKey prop 只是提示)
|
|
78
|
+
let lastSnap = null;
|
|
79
|
+
let pendingScan = false;
|
|
80
|
+
let stagedRestore = null; // 已恢复但可能被 hydration 重渲染抹掉(壳负责门控)
|
|
81
|
+
let scanTimer = null;
|
|
82
|
+
|
|
83
|
+
const slotKeyOf = (key) => {
|
|
84
|
+
if (typeof key === "string" && key.startsWith("new:")) {
|
|
85
|
+
// draftId 可能自身含冒号(如 ?cwd= 引导的 initial:<cwd>)→ 取最后一个冒号作 cwd 分界
|
|
86
|
+
const ci = key.lastIndexOf(":");
|
|
87
|
+
if (ci > 4) return PREFIX + "new:" + key.slice(ci + 1);
|
|
88
|
+
}
|
|
89
|
+
return PREFIX + key;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function readSlot(key) {
|
|
93
|
+
try {
|
|
94
|
+
const raw = storage.getItem(slotKeyOf(key));
|
|
95
|
+
if (raw) return decodeSlot(raw, textDecoder);
|
|
96
|
+
} catch { /* ignore */ }
|
|
97
|
+
// 旧 per-tab 槽位迁移(crashrestore-<key>-<tabId> → 共享槽位;new: 族匹配同 cwd 全部旧槽)
|
|
98
|
+
const isNew = typeof key === "string" && key.startsWith("new:");
|
|
99
|
+
const cwdSuffix = isNew ? `:${key.slice(key.lastIndexOf(":") + 1)}-` : null;
|
|
100
|
+
const legacyPrefix = `${PREFIX}${key}-`;
|
|
101
|
+
const familyPrefix = isNew ? `${PREFIX}new:` : null;
|
|
102
|
+
let best = null;
|
|
103
|
+
try {
|
|
104
|
+
for (const k of storage.keys()) {
|
|
105
|
+
if (!k || !k.startsWith(PREFIX)) continue;
|
|
106
|
+
const isFamily = isNew && k.startsWith(familyPrefix) && k.includes(cwdSuffix);
|
|
107
|
+
if (!k.startsWith(legacyPrefix) && !isFamily) continue;
|
|
108
|
+
const parsed = decodeSlot(storage.getItem(k) ?? "", textDecoder);
|
|
109
|
+
if (parsed && (!best || parsed.ts > best.ts)) best = { ...parsed, legacyKey: k };
|
|
110
|
+
}
|
|
111
|
+
} catch { /* ignore */ }
|
|
112
|
+
if (best) {
|
|
113
|
+
try {
|
|
114
|
+
storage.setItem(slotKeyOf(key), `${best.ts}:${encodeDraft(best.draft, textEncoder)}`);
|
|
115
|
+
storage.removeItem(best.legacyKey);
|
|
116
|
+
} catch { /* ignore */ }
|
|
117
|
+
return { ts: best.ts, draft: best.draft };
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function writeNow(key) {
|
|
123
|
+
const draft = drafts.get(key);
|
|
124
|
+
if (!draft) return;
|
|
125
|
+
try { storage.setItem(slotKeyOf(key), `${Date.now()}:${encodeDraft(draft, textEncoder)}`); } catch { /* 配额满:内存副本仍在 */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function setDraft(key, draft) {
|
|
129
|
+
drafts.set(key, { value: draft.value, images: (draft.images ?? []).map((im) => ({ ...im })) });
|
|
130
|
+
const t = flushTimers.get(key);
|
|
131
|
+
if (t) timers.clearTimeout(t);
|
|
132
|
+
flushTimers.set(key, timers.setTimeout(() => { flushTimers.delete(key); writeNow(key); }, flushMs));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function clearSlot(key) {
|
|
136
|
+
drafts.delete(key);
|
|
137
|
+
const t = flushTimers.get(key);
|
|
138
|
+
if (t) { timers.clearTimeout(t); flushTimers.delete(key); }
|
|
139
|
+
try { storage.removeItem(slotKeyOf(key)); } catch { /* ignore */ }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readSlotValue(key) {
|
|
143
|
+
const cached = drafts.get(key);
|
|
144
|
+
if (cached) return { ts: 0, draft: cached };
|
|
145
|
+
return readSlot(key);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function flushAll() {
|
|
149
|
+
for (const key of drafts.keys()) {
|
|
150
|
+
// 清除确认中的槽不回写:用户刚清空/发走,等确认链裁决;回写会让「已清除」
|
|
151
|
+
// 的草稿在切换瞬间重新落盘(确认链到期再删也只是赌链路不中断)
|
|
152
|
+
if (clearTimers.has(key)) continue;
|
|
153
|
+
// 已提升作废的 provisional(sessionCreated 已把它重定向到 sid):不得再写回。
|
|
154
|
+
// 归一化槽位 crashrestore-new:<cwd> 按 cwd 共享,这里写回会污染共享槽位
|
|
155
|
+
// (「新建会话发送后残留」根因之一)。
|
|
156
|
+
if (aliases.has(key)) continue;
|
|
157
|
+
writeNow(key);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------- 恢复 ----------
|
|
162
|
+
|
|
163
|
+
function applyRestore(key, draft) {
|
|
164
|
+
known.set(key, draft.value); // 恢复不清暂存(known = 恢复值,随后 onChange 一致 → 不写)
|
|
165
|
+
stagedRestore = { value: draft.value, images: draft.images };
|
|
166
|
+
const ok = onRestore({ value: draft.value, images: draft.images });
|
|
167
|
+
log("applyRestore ok=" + ok + " v=" + draft.value.slice(0, 15));
|
|
168
|
+
if (!ok) stagedRestore = null;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function scan(key, snap) {
|
|
173
|
+
// 清空确认窗口否决:本 key 已排定清槽(刚发送/清空,确认链未到期)时不得恢复。
|
|
174
|
+
// 槽里此刻的内容就是刚发走的那条(suppress 被击穿落槽的预填文本、或竞态窗口内
|
|
175
|
+
// 的旧稿),恢复它既把消息填回框,又会污染 lastSnap 快照使确认链失效
|
|
176
|
+
// (「从这里编辑→发送→输入框不空」实测链路)。确认链走完(槽位已清)后恢复不受影响。
|
|
177
|
+
if (clearTimers.has(key)) return false;
|
|
178
|
+
const entry = readSlotValue(key);
|
|
179
|
+
const slotVal = entry ? entry.draft.value : null;
|
|
180
|
+
const knownVal = known.get(key) ?? null;
|
|
181
|
+
|
|
182
|
+
if (snap.hasInput) {
|
|
183
|
+
// 框有内容(本页在输入):仅当存储 ⊇ 本页已知(别的窗口扩展了草稿、本页未分叉)→ 同步进框
|
|
184
|
+
if (knownVal !== null && slotVal !== null && slotVal !== snap.value && slotVal.includes(knownVal) && slotVal.length > knownVal.length) {
|
|
185
|
+
return applyRestore(key, entry.draft);
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
// 框空:有暂存且非本页已知 → 静默恢复
|
|
190
|
+
if (slotVal && slotVal !== knownVal) return applyRestore(key, entry.draft);
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function scheduleScan() {
|
|
195
|
+
if (scanTimer) timers.clearTimeout(scanTimer);
|
|
196
|
+
scanTimer = timers.setTimeout(() => {
|
|
197
|
+
scanTimer = null;
|
|
198
|
+
// 恢复不在定时器里做:这里能拿到的 lastSnap 往往是切换/重挂前的过期快照
|
|
199
|
+
// (清屏 onChange 还没到),拿它扫描要么扫不出来、要么把草稿抢写进即将被
|
|
200
|
+
// 清屏的旧 DOM(「恢复即删」)。真正的清屏快照(onChange "")到达时由
|
|
201
|
+
// onChange 的 pendingScan 分支负责扫描恢复——known 已在 onKeyChange 失效,
|
|
202
|
+
// 那一刻才是唯一可信的扫描时机。这里只保留 staged 兜底。
|
|
203
|
+
if (stagedRestore && lastSnap && !lastSnap.hasInput) {
|
|
204
|
+
if (onRestore({ value: stagedRestore.value, images: stagedRestore.images })) stagedRestore = null;
|
|
205
|
+
}
|
|
206
|
+
}, scanMs);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------- enhancement 回调(用户输入/清空/换 key)----------
|
|
210
|
+
|
|
211
|
+
function onChange(snap) {
|
|
212
|
+
try {
|
|
213
|
+
lastSnap = snap;
|
|
214
|
+
// 水合窗口补偿:恢复过的文本若被 hydration 重渲染清空(框空),重放
|
|
215
|
+
if (stagedRestore) {
|
|
216
|
+
if (snap.hasInput) {
|
|
217
|
+
stagedRestore = null; // 用户已有输入:本页最大,放弃 stage
|
|
218
|
+
} else if (snap.value !== stagedRestore.value) {
|
|
219
|
+
if (onRestore({ value: stagedRestore.value, images: stagedRestore.images })) stagedRestore = null;
|
|
220
|
+
} else {
|
|
221
|
+
stagedRestore = null; // 框内已是恢复值
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (pendingScan && currentKey) {
|
|
225
|
+
pendingScan = false;
|
|
226
|
+
// ★ 恢复发生时本轮快照(空框)已过时:applyRestore 已把 known 指向恢复值
|
|
227
|
+
// 且正在写框,若继续落入下方清空分支会在确认延时后把刚恢复的草稿删掉
|
|
228
|
+
//(「恢复即删」——原实现靠写框引发的回流 onChange 侥幸存活,回流失败
|
|
229
|
+
// 即偶发丢失)。直接返回,等写框引发的新 onChange。
|
|
230
|
+
if (scan(currentKey, snap)) return;
|
|
231
|
+
}
|
|
232
|
+
if (!currentKey || snap.suppress) return;
|
|
233
|
+
const knownVal = known.get(currentKey) ?? null;
|
|
234
|
+
if (snap.value === knownVal) return; // ★ 无贡献不回写
|
|
235
|
+
if (snap.value === "") {
|
|
236
|
+
// 清空/发走 → 清暂存(known 非空才清;打开时空框不清)。
|
|
237
|
+
// 延迟确认防陈旧实例空快照误清;回调闭包捕获 key(期间换 key 不误清新 key 槽位)。
|
|
238
|
+
known.set(currentKey, null);
|
|
239
|
+
if (knownVal) {
|
|
240
|
+
const clearedKey = currentKey;
|
|
241
|
+
// ★ 立即删内存副本 + 挂起的 flush 定时器:确认窗口(clearConfirmMs)内任何
|
|
242
|
+
// flushAll(切换/隐藏/键变化)都不得把已清除的草稿写回 —— 否则发送后残留
|
|
243
|
+
// (「新建会话发出后不清理」根因)。期间用户重新输入会经 setDraft 恢复副本。
|
|
244
|
+
drafts.delete(clearedKey);
|
|
245
|
+
const ftt = flushTimers.get(clearedKey);
|
|
246
|
+
if (ftt) { timers.clearTimeout(ftt); flushTimers.delete(clearedKey); }
|
|
247
|
+
const t = clearTimers.get(clearedKey);
|
|
248
|
+
if (t) timers.clearTimeout(t);
|
|
249
|
+
clearTimers.set(clearedKey, timers.setTimeout(() => {
|
|
250
|
+
clearTimers.delete(clearedKey);
|
|
251
|
+
// 裁决看该 key 自己的 known(空值分支置 null;确认窗内同 key 新输入会重新写入非空):
|
|
252
|
+
// 不能看全局 lastSnap——跨 key 切换后它描述的是别的框,新框有内容会把「已清除
|
|
253
|
+
// 的槽」误判成「仍有输入」而永久保留(清除后切走→切回草稿复活)。
|
|
254
|
+
if (known.get(clearedKey) === null) { clearSlot(clearedKey); log("clear (empty confirmed) " + clearedKey.slice(0, 20)); }
|
|
255
|
+
}, clearConfirmMs));
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// ★ 有贡献才写(currentKey 已被 sessionCreated 重定向时自然落 sid 槽位)
|
|
260
|
+
setDraft(currentKey, { value: snap.value, images: snap.images ?? [] });
|
|
261
|
+
known.set(currentKey, snap.value);
|
|
262
|
+
log("WRITE v=" + snap.value.slice(0, 15));
|
|
263
|
+
} catch (e) {
|
|
264
|
+
log("onChange ERR: " + (e && e.message));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function onKeyChange(prev, next) {
|
|
269
|
+
try {
|
|
270
|
+
if (prev) flushAll(); // 切换前把未落盘内容写掉
|
|
271
|
+
// ★ 废弃 draftId(new:*)的内存副本立即清掉:草稿已由 flushAll 落盘到归一化槽位
|
|
272
|
+
// `crashrestore-new:<cwd>`,且同一 draftId 的框不会再现(每次新建是新 draftId)。
|
|
273
|
+
// 保留它会让后续任何 onKeyChange/flushAll 把旧草稿写回共享槽位(残留根因)。
|
|
274
|
+
// 切回(新 draftId)时经 scan 从共享槽位恢复,不丢内容。
|
|
275
|
+
// 若该 key 正处清空确认链(已清空/已发送,clearTimers 挂着),则保留 known=null 与
|
|
276
|
+
// clearTimers,让确认链跑完 clearSlot 删槽位(不得掐断——否则清空失效导致残留)。
|
|
277
|
+
if (prev && typeof prev === "string" && prev.startsWith("new:") && prev !== next) {
|
|
278
|
+
drafts.delete(prev);
|
|
279
|
+
const ft = flushTimers.get(prev);
|
|
280
|
+
if (ft) { timers.clearTimeout(ft); flushTimers.delete(prev); }
|
|
281
|
+
if (!clearTimers.has(prev)) known.delete(prev);
|
|
282
|
+
}
|
|
283
|
+
currentKey = next;
|
|
284
|
+
// 切换/重挂后 ChatInput 的 [draftKey] effect 会清屏(setValue(""))——
|
|
285
|
+
// known 描述的是「上一段 DOM 时代框里显示过什么」,清屏即失效。若保留旧值,
|
|
286
|
+
// 同 key 切回(同一 session 的框)时 scan 的 slotVal !== knownVal 恒假:
|
|
287
|
+
// 既不恢复,又让随后的空快照走清空确认链把槽位删掉(「切换丢草稿」根因)。
|
|
288
|
+
// 置 null:清屏快照到达时槽内草稿视作可恢复;正常发送清槽不受影响
|
|
289
|
+
// (发送不走 onKeyChange,clearTimers 否决 + 空值分支语义不变)。
|
|
290
|
+
if (next) known.set(next, null);
|
|
291
|
+
pendingScan = true; // 等清屏后的第一次 onChange 再扫描
|
|
292
|
+
scheduleScan();
|
|
293
|
+
} catch (e) {
|
|
294
|
+
log("onKeyChange ERR: " + (e && e.message));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------- 意图(core 唯一写入口)----------
|
|
299
|
+
|
|
300
|
+
function applyIntent(intent) {
|
|
301
|
+
try {
|
|
302
|
+
if (intent.type === "sessionCreated") {
|
|
303
|
+
const { provisionalKey: pk, sessionId: sid } = intent;
|
|
304
|
+
if (pk === sid) return;
|
|
305
|
+
aliases.set(pk, sid);
|
|
306
|
+
// ★ 发送即作废该 cwd 的**整族** provisional:归一化槽位 `crashrestore-new:<cwd>`
|
|
307
|
+
// 按 cwd 共享,任何 draftId 的残留内存副本都会经 flushAll 写回该共享槽位
|
|
308
|
+
// (「新建会话发出后残留」根因)。发送后同 cwd 不再有活跃新建草稿,全清。
|
|
309
|
+
const familySlot = slotKeyOf(pk);
|
|
310
|
+
for (const k of [...drafts.keys()]) {
|
|
311
|
+
if (slotKeyOf(k) !== familySlot) continue;
|
|
312
|
+
drafts.delete(k);
|
|
313
|
+
const ft = flushTimers.get(k);
|
|
314
|
+
if (ft) { timers.clearTimeout(ft); flushTimers.delete(k); }
|
|
315
|
+
known.delete(k);
|
|
316
|
+
}
|
|
317
|
+
// provisional 草稿立即作废(消息已落会话;发送后的 onChange("") 清空确认链
|
|
318
|
+
// 稍后跑到也只会对已空的槽位重复清除,无副作用)
|
|
319
|
+
clearSlot(pk);
|
|
320
|
+
known.set(pk, null);
|
|
321
|
+
if (currentKey === pk) {
|
|
322
|
+
// 重定向:promote 后、core 的 draftKey prop 切换前,用户已开始的新输入
|
|
323
|
+
// 自然落 sid 槽位(不再写回 provisional 造成「已发送消息残留」)
|
|
324
|
+
currentKey = sid;
|
|
325
|
+
known.set(sid, known.get(sid) ?? null);
|
|
326
|
+
}
|
|
327
|
+
log("sessionCreated " + pk.slice(0, 24) + " -> " + sid.slice(0, 12));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (intent.type === "rejected") {
|
|
331
|
+
const resolvedKey = aliases.get(intent.key) ?? intent.key;
|
|
332
|
+
const cur = readSlotValue(resolvedKey);
|
|
333
|
+
const curText = cur?.draft.value ?? "";
|
|
334
|
+
const curImages = cur?.draft.images ?? [];
|
|
335
|
+
const merged = mergeSubmission(intent.text, intent.images, curText, curImages);
|
|
336
|
+
setDraft(resolvedKey, merged);
|
|
337
|
+
known.set(resolvedKey, merged.value);
|
|
338
|
+
// 目标是当前框(含经 alias 重定向)→ 写回框内让用户重试
|
|
339
|
+
const effectiveCurrent = aliases.get(currentKey) ?? currentKey;
|
|
340
|
+
if (resolvedKey === effectiveCurrent) {
|
|
341
|
+
onRestore({ value: merged.value, images: merged.images });
|
|
342
|
+
}
|
|
343
|
+
log("rejected -> " + resolvedKey.slice(0, 24));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
log("unknown intent " + intent.type);
|
|
347
|
+
} catch (e) {
|
|
348
|
+
log("applyIntent ERR: " + (e && e.message));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------- 对外只读 / 维护 ----------
|
|
353
|
+
|
|
354
|
+
function getDraft(key) {
|
|
355
|
+
const e = readSlotValue(aliases.get(key) ?? key);
|
|
356
|
+
return e ? { ...e.draft } : null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function clearAllDrafts(sid) {
|
|
360
|
+
clearSlot(sid);
|
|
361
|
+
try {
|
|
362
|
+
for (const k of storage.keys()) {
|
|
363
|
+
if (k && k.startsWith(PREFIX + sid + "-")) storage.removeItem(k); // 旧 per-tab 残留一并清
|
|
364
|
+
}
|
|
365
|
+
} catch { /* ignore */ }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 启动时一次性清理:超过 maxAgeMs 未写入的槽位(含旧 per-tab 残留、已发送
|
|
369
|
+
* 会话的孤儿草稿)自动过期。非轮询——仅 register 时调一次。活跃草稿 ts 新,
|
|
370
|
+
* 不受影响。 */
|
|
371
|
+
function pruneStale(maxAgeMs = 7 * 24 * 60 * 60 * 1000) {
|
|
372
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
373
|
+
let removed = 0;
|
|
374
|
+
try {
|
|
375
|
+
for (const k of storage.keys()) {
|
|
376
|
+
if (!k || !k.startsWith(PREFIX)) continue;
|
|
377
|
+
const raw = storage.getItem(k) ?? "";
|
|
378
|
+
const idx = raw.indexOf(":");
|
|
379
|
+
const ts = idx > 0 ? Number(raw.slice(0, idx)) : NaN;
|
|
380
|
+
// 无 ts 的旧格式槽位视为陈旧一并清理;解析不出 ts 的保守跳过
|
|
381
|
+
if (Number.isFinite(ts) && ts >= cutoff) continue;
|
|
382
|
+
if (!Number.isFinite(ts) && idx > 0) continue;
|
|
383
|
+
storage.removeItem(k);
|
|
384
|
+
removed++;
|
|
385
|
+
}
|
|
386
|
+
} catch { /* ignore */ }
|
|
387
|
+
if (removed) log("pruneStale removed " + removed);
|
|
388
|
+
return removed;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------- deprecated(v1 兼容:未升级的旧 core 直调;新 core 不再使用)----------
|
|
392
|
+
|
|
393
|
+
function saveDraft(key, draft) { setDraft(key, draft); known.set(key, draft.value); }
|
|
394
|
+
function clearDraft(key) { clearSlot(aliases.get(key) ?? key); known.set(key, null); }
|
|
395
|
+
function rekeyDraft(previousKey, nextKey, currentDraft) {
|
|
396
|
+
if (previousKey === nextKey) return currentDraft ?? getDraft(nextKey);
|
|
397
|
+
const prevEntry = currentDraft && (currentDraft.value || currentDraft.images.length)
|
|
398
|
+
? { ts: 0, draft: currentDraft }
|
|
399
|
+
: readSlotValue(previousKey);
|
|
400
|
+
const nextEntry = readSlotValue(nextKey);
|
|
401
|
+
clearSlot(previousKey);
|
|
402
|
+
if (!prevEntry) return nextEntry ? { ...nextEntry.draft } : null;
|
|
403
|
+
const merged = nextEntry
|
|
404
|
+
? {
|
|
405
|
+
value: prevEntry.draft.value.includes(nextEntry.draft.value)
|
|
406
|
+
? prevEntry.draft.value
|
|
407
|
+
: `${nextEntry.draft.value}\n\n${prevEntry.draft.value}`,
|
|
408
|
+
images: [...prevEntry.draft.images, ...nextEntry.draft.images].slice(0, 10),
|
|
409
|
+
}
|
|
410
|
+
: prevEntry.draft;
|
|
411
|
+
setDraft(nextKey, merged);
|
|
412
|
+
known.set(nextKey, merged.value);
|
|
413
|
+
return { ...merged };
|
|
414
|
+
}
|
|
415
|
+
function restoreSubmission(key, text, images) {
|
|
416
|
+
const resolvedKey = aliases.get(key) ?? key;
|
|
417
|
+
const cur = readSlotValue(resolvedKey);
|
|
418
|
+
const merged = mergeSubmission(text, images, cur?.draft.value ?? "", cur?.draft.images ?? []);
|
|
419
|
+
setDraft(resolvedKey, merged);
|
|
420
|
+
known.set(resolvedKey, merged.value);
|
|
421
|
+
return { ...merged };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function dispose() {
|
|
425
|
+
for (const t of flushTimers.values()) timers.clearTimeout(t);
|
|
426
|
+
for (const t of clearTimers.values()) timers.clearTimeout(t);
|
|
427
|
+
if (scanTimer) timers.clearTimeout(scanTimer);
|
|
428
|
+
flushAll();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
onChange, onKeyChange, applyIntent,
|
|
433
|
+
getDraft, clearAllDrafts, flushAll, pruneStale,
|
|
434
|
+
saveDraft, clearDraft, rekeyDraft, restoreSubmission, // deprecated
|
|
435
|
+
dispose,
|
|
436
|
+
};
|
|
437
|
+
}
|