dsh-recall-plugin 2.3.13 → 2.3.15

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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
+ ## [2.3.15] - 2026-09-11
6
+
7
+ ### 新增
8
+
9
+ - **撤回回填扩展为「文本 + 附件」**:此前撤回只把被撤回消息的文本回填到输入框,消息里的附件(图片/文件)不会回来;现在两者一起回填,改完可直接重发。实现沿用官方 composer 自己的附件链路(`dsh-client-ui-conversation` 的 `addFiles` 实证):**早读**——撤回执行一开始(源会话仍在册、附件引用可解析时)经 `sessions.binding(sessionId).session.readAttachment(attachmentId)` 取回原始字节与 mediaType,重建为浏览器 `File`(缺文件名按 mediaType 派生 `attachment-N.<subtype>`);**后写**——fork + open 完成后在子会话里 `conversation.createDrafts(sessionId, files)` 注册草稿附件、`shell.actions.addAttachments(ids)` 进入输入态(未接纳时 `releaseDraftAttachments` 释放,与官方 `addFiles` 的失败回滚一致)。附件读取授权绑定「消息所在会话」,故必须用源会话早读——被撤回消息不在 fork 出的子会话日志中,直读子会话拿不到授权。附件读取与草稿注册均逐项 try/catch、服务面全部 typeof 探测:任一步缺失只降级附件,文本回填与撤回主流程不受影响(旧版 dsh 无 `conversation`/`createDrafts`/`readAttachment` 时恒降级)。`refillDraft` 开关语义同步为「文本与附件」(设置描述与 README 双语同步);新增 `attachmentRefsFromBlocks` / `defaultAttachmentName` 纯函数与 3 例单测,compat-audit 新增 I34。客户端 bundle 结构与既有行为零变化。
10
+
11
+ ## [2.3.14] - 2026-09-11
12
+
13
+ ### 修复
14
+
15
+ - **撤回 fork 出的子会话内,撤回预览对任何消息都误报「该消息是本会话中第一条用户消息」**:`resolveCutSeq` 冷读取走 `sessionQuery.readSession`,官方实现内部却用 `Session.create(events, header, inheritedEventCount)` 做回放校验——快照模式要求 seeded 头的 `inheritedEventCount` 恰等于 `log.length`(「seeded session constructor seed must equal its inherited prefix」),而读取面交给它的是全量逻辑日志(`snapshotEvents()` 无参=继承前缀+自身事件;冷读返回完整存储日志),两者必然不等,`readSession` 对任何 seeded 会话(本插件每次撤回 fork 出的子会话即此类)直接抛错;此前的 `catch` 把异常静默折成 null,与「真首条」不可分——于是子会话里点**任何**消息,面板都显示「该消息是本会话中第一条用户消息,无法回退对话;确认后仅回退项目文件」。修复:`resolveCutSeq` 增加降级链——`readSession` 抛错、或消息根本没出现在它给的事件里(读取面缺继承前缀的版本差异)时,改用 `sessionQuery.observeSession`(经 `Session.fromRestore` 恢复,restore 模式无该约束)读取全量逻辑日志;租约以 `Symbol.dispose` 显式释放(prepared 缓存项靠它减引用,漏释放会卡淘汰);只有「消息在、其前无 turn/end」才是可信的“首条”,两跳都失败才落到 null。`scanCutSeq` 拆出 `scanCutSeqDetail`(cut + found)供降级判断;契约补 `observeSession` 与 `SessionObservationLease`(可选——旧版 dsh 无此 API 时维持原行为)。**真机复验**(dsh web 0.1.5-rc.2 + POST `/api/recall/preview`):修前子会话 6 条消息 cutSeq 全 null、父会话正常;修后子会话 156/43/62/88/197、真首条(你好)仍 null、父会话不变。新增 `tests/unit/snapshots-cutseq.test.js`(6 例),compat-audit 新增 I33。插件功能、客户端与其余行为零变化。
16
+
5
17
  ## [2.3.13] - 2026-09-11
6
18
 
7
19
  ### 修复
package/README.en.md CHANGED
@@ -107,7 +107,7 @@ All options can be edited visually in the "**Settings → Plugin Config → Reca
107
107
  | `maxSnapshotsPerWorkspace` | 500 | Maximum snapshots kept per workspace; oldest pruned beyond the cap. 0 = unlimited |
108
108
  | `retentionDays` | 0 | Keep snapshots for this many days; older ones are deleted. 0 = disabled (works independently of the cap) |
109
109
  | `baseExcludes` | `.git`, `node_modules/`, `.dsh-recall-snapshots/`, `dsh-recall-snapshots/` | Base exclusion list (gitignore syntax, lower priority than exclude.txt) |
110
- | `refillDraft` | true | Refill the recalled message text into the input box after a recall |
110
+ | `refillDraft` | true | Refill the recalled message (text and attachments) into the input box after a recall |
111
111
  | `snapshotEnabled` | true | Master snapshot switch (off = no new snapshots; existing snapshots remain recallable) |
112
112
  | `archiveOriginal` | true | Archive the original session after a recall (off = the original session stays in the session list) |
113
113
 
package/README.md CHANGED
@@ -109,7 +109,7 @@ dsh plugin --profile web remove dsh-recall-plugin
109
109
  | `maxSnapshotsPerWorkspace` | 500 | 每个工作区保留的最大快照数,超限自动删除最旧的;0 = 不限制 |
110
110
  | `retentionDays` | 0 | 按天数保留快照,超期自动删除;0 = 不启用(与条数上限各自独立生效) |
111
111
  | `baseExcludes` | `.git`、`node_modules/`、`.dsh-recall-snapshots/`、`dsh-recall-snapshots/` | 基础排除表(gitignore 语法,优先级低于 exclude.txt) |
112
- | `refillDraft` | true | 撤回后把被撤回的消息文本回填到输入框 |
112
+ | `refillDraft` | true | 撤回后把被撤回的消息(文本与附件)回填到输入框 |
113
113
  | `snapshotEnabled` | true | 快照总开关(关闭只冻结新建,已有快照仍可撤回) |
114
114
  | `archiveOriginal` | true | 撤回后归档原会话(关闭后原会话保留在会话列表中) |
115
115
 
package/lib/client.js CHANGED
@@ -380,6 +380,24 @@ function buildUtil() {
380
380
  }
381
381
 
382
382
  // src/client/recall-node.ts
383
+ function attachmentRefsFromBlocks(blocks) {
384
+ const refs = [];
385
+ for (const block of blocks) {
386
+ if (!block || block.type !== "image" && block.type !== "file") continue;
387
+ const ref = block.attachment;
388
+ if (!ref || typeof ref.attachmentId !== "string" || ref.attachmentId === "") continue;
389
+ const entry = { attachmentId: ref.attachmentId };
390
+ if (typeof ref.name === "string" && ref.name !== "") entry.name = ref.name;
391
+ refs.push(entry);
392
+ }
393
+ return refs;
394
+ }
395
+ function defaultAttachmentName(mediaType, index) {
396
+ const text = String(mediaType || "");
397
+ const slash = text.indexOf("/");
398
+ const sub = (slash >= 0 ? text.slice(slash + 1) : text).replace(/[^a-z0-9.+-]/gi, "");
399
+ return "attachment-" + String(index + 1) + "." + (sub || "bin");
400
+ }
383
401
  var KIND_INFO = {
384
402
  modified: { label: "\u4FEE\u6539", cls: "modified" },
385
403
  restored: { label: "\u6062\u590D", cls: "restored" },
@@ -507,31 +525,89 @@ function buildRecallNode(React, util, ctx, sessionsSvc, workspacesSvc) {
507
525
  }
508
526
  return null;
509
527
  }
510
- function fillDraft(targetSessionId, draftText) {
511
- if (!draftText || !targetSessionId) return;
528
+ function fillDraft(targetSessionId, draftText, attachmentFiles) {
529
+ if (!targetSessionId || !draftText && attachmentFiles === null) return;
512
530
  let attempts = 0;
531
+ let attachStarted = false;
513
532
  const attempt = () => {
533
+ let textDone = draftText === "";
514
534
  try {
515
535
  const conversation = ctx.get("conversation");
516
536
  if (conversation && conversation.input && typeof conversation.input.shell === "function") {
517
537
  const shell = conversation.input.shell(targetSessionId);
518
538
  if (shell) {
519
- if (shell.actions && typeof shell.actions.setDraft === "function") {
520
- shell.actions.setDraft(draftText);
521
- return;
539
+ if (!textDone) {
540
+ if (shell.actions && typeof shell.actions.setDraft === "function") {
541
+ shell.actions.setDraft(draftText);
542
+ textDone = true;
543
+ } else if (typeof shell.setDraft === "function") {
544
+ shell.setDraft(draftText);
545
+ textDone = true;
546
+ }
522
547
  }
523
- if (typeof shell.setDraft === "function") {
524
- shell.setDraft(draftText);
525
- return;
548
+ if (!attachStarted && attachmentFiles !== null) {
549
+ attachStarted = startAttachDrafts(conversation, shell, targetSessionId, attachmentFiles);
526
550
  }
527
551
  }
528
552
  }
529
553
  } catch (e) {
530
554
  }
531
- if (attempts++ < 8) setTimeout(attempt, 150);
555
+ const attachDone = attachmentFiles === null || attachStarted;
556
+ if ((!textDone || !attachDone) && attempts++ < 8) setTimeout(attempt, 150);
532
557
  };
533
558
  attempt();
534
559
  }
560
+ function startAttachDrafts(conversation, shell, sessionId, files) {
561
+ if (typeof conversation.createDrafts !== "function") return false;
562
+ const actions = shell.actions;
563
+ let add = null;
564
+ if (actions && typeof actions.addAttachments === "function") add = (ids) => actions.addAttachments ? actions.addAttachments(ids) : false;
565
+ else if (typeof shell.addAttachments === "function") add = (ids) => shell.addAttachments ? shell.addAttachments(ids) : false;
566
+ if (add === null) return false;
567
+ (async () => {
568
+ let list = [];
569
+ try {
570
+ list = await files;
571
+ } catch (e) {
572
+ return;
573
+ }
574
+ if (!Array.isArray(list) || list.length === 0) return;
575
+ try {
576
+ const drafts = conversation.createDrafts ? conversation.createDrafts(sessionId, list) : [];
577
+ const descriptors = Array.isArray(drafts) ? drafts : [];
578
+ const ids = descriptors.map((draft) => draft && draft.id).filter((id) => typeof id === "string" && id !== "");
579
+ if (ids.length === 0) return;
580
+ const accepted = add(ids);
581
+ if (accepted === false && typeof conversation.releaseDraftAttachments === "function") conversation.releaseDraftAttachments(descriptors);
582
+ } catch (e) {
583
+ }
584
+ })();
585
+ return true;
586
+ }
587
+ function preloadAttachmentFiles(sourceSessionId, refs) {
588
+ return (async () => {
589
+ const sessions = ctx.sessions;
590
+ const binding = sessions && typeof sessions.binding === "function" ? sessions.binding(sourceSessionId) : void 0;
591
+ const session = binding && binding.session;
592
+ if (!session || typeof session.readAttachment !== "function") return [];
593
+ const out = [];
594
+ for (const ref of refs) {
595
+ try {
596
+ const result = await session.readAttachment(ref.attachmentId);
597
+ if (!result || result.ok !== true || !result.value || result.value.data === void 0 || result.value.data === null) continue;
598
+ const raw = result.value.data;
599
+ const bytes = raw instanceof Uint8Array ? raw : Uint8Array.from(raw);
600
+ const meta = result.value.attachment;
601
+ const mediaType = meta && typeof meta.mediaType === "string" && meta.mediaType !== "" ? meta.mediaType : "application/octet-stream";
602
+ const name = ref.name || (meta && typeof meta.name === "string" && meta.name !== "" ? meta.name : defaultAttachmentName(mediaType, out.length));
603
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
604
+ out.push(new File([buffer], name, { type: mediaType }));
605
+ } catch (e) {
606
+ }
607
+ }
608
+ return out;
609
+ })();
610
+ }
535
611
  function UserRecallNode(props) {
536
612
  const node = props && props.node;
537
613
  const renderMessageImages = props && props.renderMessageImages;
@@ -542,6 +618,7 @@ function buildRecallNode(React, util, ctx, sessionsSvc, workspacesSvc) {
542
618
  const text = blocks.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join("");
543
619
  const imageBlocks = blocks.filter((b) => b && b.type === "image" && b.attachment).map((b) => ({ attachment: b.attachment }));
544
620
  const rest = blocks.filter((b) => !b || !(b.type === "text" && typeof b.text === "string") && !(b.type === "image" && b.attachment));
621
+ const attachmentRefs = attachmentRefsFromBlocks(blocks);
545
622
  const [copied, setCopied] = React.useState(false);
546
623
  const [hasSnapshot, setHasSnapshot] = React.useState(false);
547
624
  const [recall, setRecall] = React.useState({ stage: "idle" });
@@ -625,6 +702,7 @@ function buildRecallNode(React, util, ctx, sessionsSvc, workspacesSvc) {
625
702
  const changes = recall.changes || [];
626
703
  const previewCut = typeof recall.cutSeq === "number" ? recall.cutSeq : null;
627
704
  const previewTotal = typeof recall.total === "number" ? recall.total : changes.length;
705
+ const attachmentFiles = pluginConfig.refillDraft && attachmentRefs.length > 0 && sessionId ? preloadAttachmentFiles(sessionId, attachmentRefs) : null;
628
706
  setRecall({ stage: "executing", changes });
629
707
  api("execute", { messageId, sessionId, previewTotal, previewTreeId: recall.treeId || void 0, previewAt: Date.now() }).then(async (res) => {
630
708
  if (!res || !res.ok) {
@@ -676,7 +754,7 @@ function buildRecallNode(React, util, ctx, sessionsSvc, workspacesSvc) {
676
754
  chatError = String(error);
677
755
  }
678
756
  }
679
- if (pluginConfig.refillDraft) fillDraft(fillTarget, text);
757
+ if (pluginConfig.refillDraft) fillDraft(fillTarget, text, attachmentFiles);
680
758
  setHasSnapshot(false);
681
759
  setRecall({ stage: "done", count: typeof res.count === "number" ? res.count : changes.length, chatReverted, chatError });
682
760
  }).catch((error) => {
package/lib/config.js CHANGED
@@ -9,7 +9,7 @@ const Config = Schema.object({
9
9
  // HOME 时(容器 root=/root 等)它落在工作区内,漏排除会让 git add -A
10
10
  // 把影子仓库自己吞进去、快照全部失败(issue #6)
11
11
  baseExcludes: Schema.array(Schema.string()).default([".git", "node_modules/", ".dsh-recall-snapshots/", "dsh-recall-snapshots/"]).description("\u57FA\u7840\u6392\u9664\u8868\uFF08gitignore \u8BED\u6CD5\uFF0C\u4F18\u5148\u7EA7\u4F4E\u4E8E exclude.txt\uFF09"),
12
- refillDraft: Schema.boolean().default(true).description("\u64A4\u56DE\u540E\u628A\u88AB\u64A4\u56DE\u7684\u6D88\u606F\u6587\u672C\u56DE\u586B\u5230\u8F93\u5165\u6846"),
12
+ refillDraft: 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"),
13
13
  snapshotEnabled: 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"),
14
14
  archiveOriginal: 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"),
15
15
  retentionDays: Schema.number().default(0).description("\u6309\u5929\u6570\u4FDD\u7559\u5FEB\u7167\uFF0C\u8D85\u671F\u81EA\u52A8\u5220\u9664\uFF1B0 \u8868\u793A\u4E0D\u542F\u7528")
package/lib/snapshots.js CHANGED
@@ -52,7 +52,7 @@ function parseDiffOutput(text, isWin, maxChanges) {
52
52
  const changes = all.slice(0, maxChanges);
53
53
  return { changes, total: finalTotal, truncated: finalTotal > changes.length, treeId };
54
54
  }
55
- function scanCutSeq(events, messageId) {
55
+ function scanCutSeqDetail(events, messageId) {
56
56
  let anchor = -1;
57
57
  for (let i = 0; i < events.length; i++) {
58
58
  const e = events[i];
@@ -61,12 +61,21 @@ function scanCutSeq(events, messageId) {
61
61
  break;
62
62
  }
63
63
  }
64
- if (anchor < 0) return null;
64
+ if (anchor < 0) return { cut: null, found: false };
65
65
  for (let i = anchor - 1; i >= 0; i--) {
66
66
  const e = events[i];
67
- if (e && e.type === "turn/end" && typeof e.seq === "number") return e.seq;
67
+ if (e && e.type === "turn/end" && typeof e.seq === "number") return { cut: e.seq, found: true };
68
68
  }
69
- return null;
69
+ return { cut: null, found: true };
70
+ }
71
+ function scanCutSeq(events, messageId) {
72
+ return scanCutSeqDetail(events, messageId).cut;
73
+ }
74
+ function disposeLease(lease) {
75
+ const disposeSymbol = Symbol.dispose;
76
+ if (!disposeSymbol || lease === null || typeof lease !== "object") return;
77
+ const dispose = lease[disposeSymbol];
78
+ if (typeof dispose === "function") dispose.call(lease);
70
79
  }
71
80
  async function rescueRollback(deps, opts) {
72
81
  const { root, store, safetyId, safetyOk, rollbackError } = opts;
@@ -374,11 +383,26 @@ function createSnapshots(ctx, rt, config) {
374
383
  } else {
375
384
  const query = ctx.get("sessionQuery");
376
385
  if (query) {
386
+ let fallback = false;
377
387
  try {
378
388
  const log = await query.readSession(sessionId);
379
- result = scanCutSeq(Array.isArray(log && log.events) ? log.events : [], messageId);
389
+ const detail = scanCutSeqDetail(Array.isArray(log && log.events) ? log.events : [], messageId);
390
+ result = detail.cut;
391
+ fallback = !detail.found;
380
392
  } catch (error) {
381
- result = null;
393
+ fallback = true;
394
+ }
395
+ if (fallback && typeof query.observeSession === "function") {
396
+ try {
397
+ const lease = await query.observeSession(sessionId);
398
+ try {
399
+ result = scanCutSeq(lease && Array.isArray(lease.events) ? lease.events : [], messageId);
400
+ } finally {
401
+ disposeLease(lease);
402
+ }
403
+ } catch (error) {
404
+ result = null;
405
+ }
382
406
  }
383
407
  }
384
408
  }
@@ -396,5 +420,6 @@ export {
396
420
  parseTagsWithTime,
397
421
  parseTreeId,
398
422
  rescueRollback,
399
- scanCutSeq
423
+ scanCutSeq,
424
+ scanCutSeqDetail
400
425
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "2.3.13",
3
+ "version": "2.3.15",
4
4
  "description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
5
5
  "type": "module",
6
6
  "repository": {