@240xu/dsh-message-ops 0.2.4 → 0.3.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 CHANGED
@@ -44,6 +44,13 @@ dsh plugin --profile web add file:C:/path/to/dsh-message-ops
44
44
  | GET | `/api/message-ops/export?sessionId=<id>&seq=<可选>` | 导出 Markdown(seq ≤ 上界,缺省全部),附件下载 |
45
45
  | POST | `/api/message-ops/restore` | `{sessionId, seq}` 回滚恢复:seq 为某次 revert/delete 标记事件的 seq |
46
46
 
47
+ ## 0.2.4 UI GAP(ux-scout dsh-ui-spec G-M1)
48
+
49
+ - **S6 违规修复**:回滚/删除成功后不再 900ms 裸 `location.reload()`——改为
50
+ devkit 标准 toast(`window.__dshDevkit.toast` 探测,无 devkit 时降级为对话框
51
+ 内完成文案)+ done 态新增「刷新页面」按钮由用户手动刷新;与 branch 路径
52
+ (refreshList 不刷新页面)行为拉齐。
53
+
47
54
  ## 0.2.3 格式兼容(compat-audit + 用户实测紧急修复)
48
55
 
49
56
  - **P0 旧单帧 `session.jsonl.zstd` 读取必崩**:`readSessionFile` 旧实现把帧 0
@@ -148,3 +155,20 @@ node --check src/*.js
148
155
  ## License
149
156
 
150
157
  MIT
158
+
159
+ ## 0.3.0 · 用户 UI 回撤体系(官方槽集成)
160
+
161
+ 针对 dsh 0.2.0-rc.2 的用户级操作面(agent 工具保留,但 UI 为主推入口):
162
+
163
+ - **每条 AI 消息旁的原生回撤按钮**:注入官方 `conversation.chat.assistant-actions`
164
+ 槽(0.2.0 正式扩展点,条目收持久化 messageId)——「⏪ 回滚到此 / ✂ 删除此条 /
165
+ ⑂ 从此分支」直接出现在消息操作行,与官方点赞/点踩并列;0.1.x 无此槽自动 no-op。
166
+ messageId→seq 反查经 `/api/message-ops/messages`(每会话索引缓存)。
167
+ - **分支双路径**:0.2.0+ 优先官方 `sessions.fork({sessionId, atSeq, increaseTitle})`
168
+ (子会话自动进宿主列表,成功后可一键 `uiWorkspace.openSession` 打开);
169
+ 官方 fork 失败或 0.1.x 回退磁盘级 applyBranch(原路径保留)。
170
+ - **恢复(restore)补完**:选中 revert/delete 落定的 replace 标记行时,对话框
171
+ 提供「恢复」操作(重放语义:新 seq + `[恢复]` 前缀,不可重放事件计数跳过),
172
+ 确认弹窗内联语义说明(评审缺口销账)。
173
+ - 官方已有但易混淆的:0.2.0 原生分支按钮(仅限已完成轮次最后一条消息)与
174
+ 本插件的「任意位置分支/回滚」互补不冲突。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@240xu/dsh-message-ops",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Roll back, branch, and export any DSH conversation — from the UI or straight from the agent via a message_ops tool. Non-destructive: logs stay append-only and recoverable. 消息回滚/删除/分支/导出四合一,内置 message_ops agent 工具;非破坏语义,日志只追加可恢复。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/client.js CHANGED
@@ -54,9 +54,22 @@ window.__ModuleLoader__.load({
54
54
  'busy.delete': '删除中…',
55
55
  'busy.branch': '分支中…',
56
56
  'done.branch': '分支完成:新会话 {id}(列表刷新后可见)',
57
- 'done.revert': '回滚完成,页面即将刷新…',
58
- 'done.delete': '删除完成,页面即将刷新…',
57
+ 'done.revert': '回滚完成,刷新页面后生效',
58
+ 'done.delete': '删除完成,刷新页面后生效',
59
+ 'action.reload': '刷新页面',
59
60
  'errorPrefix': '操作失败:',
61
+ 'op.restore': '恢复(重放被遮蔽的消息)',
62
+ 'op.restoreDesc': '仅当选中行是回滚/删除标记时可用。这是重放而非取消遮蔽:被遮蔽的用户/助手消息会以新 seq 重新追加并带「[恢复]」前缀;不可重放的事件(如工具调用)会被跳过并计数。',
63
+ 'slot.revert': '回滚到此条',
64
+ 'slot.delete': '删除此条',
65
+ 'slot.branch': '从此分支',
66
+ 'toast.openNew': '打开新会话',
67
+ 'fork.official': '分支完成:新会话 {id}',
68
+ 'fork.disk': '分支完成:新会话 {id}(列表刷新后可见)',
69
+ 'done.restore': '恢复完成:重放 {n} 条,跳过不可重放 {s} 条',
70
+ 'busy.restore': '恢复中…',
71
+ 'confirm.restore': '恢复',
72
+ 'ack.restore': '我已了解:恢复将以新 seq 重放被遮蔽的消息(原日志不变)',
60
73
  'menu.ops': '消息操作',
61
74
  }
62
75
 
@@ -79,6 +92,14 @@ window.__ModuleLoader__.load({
79
92
  'op.deleteDesc': 'Shadows only the picked message; everything else stays.',
80
93
  'op.branch': 'Branch into a new session from here',
81
94
  'op.branchDesc': 'Copies everything up to and including the picked message into a new session (original untouched, non-destructive).',
95
+ 'op.restore': 'Restore (replay shadowed messages)',
96
+ 'op.restoreDesc': 'Only when the selected row is a revert/delete marker. This is a replay, not an un-shadow: shadowed user/assistant messages are re-appended with NEW seqs and a [Restored] prefix; non-replayable events (tool calls) are skipped and counted.',
97
+ 'slot.revert': '⏪ Revert here',
98
+ 'slot.delete': '✂ Delete this',
99
+ 'slot.branch': '⑂ Branch here',
100
+ 'toast.openNew': 'Open new session',
101
+ 'fork.official': 'Branched (official fork): new session {id}',
102
+ 'fork.disk': 'Branched: new session {id} (visible after list refresh)',
82
103
  'ack.revert': 'I understand: revert shadows the picked message and everything after it',
83
104
  'ack.delete': 'I understand: this message will be shadowed (kept in the log)',
84
105
  'confirm.revert': 'Revert',
@@ -88,14 +109,22 @@ window.__ModuleLoader__.load({
88
109
  'busy.delete': 'Deleting…',
89
110
  'busy.branch': 'Branching…',
90
111
  'done.branch': 'Branched: new session {id} (visible after list refresh)',
91
- 'done.revert': 'Reverted; reloading…',
92
- 'done.delete': 'Deleted; reloading…',
112
+ 'done.restore': 'Restored: replayed {n}, skipped non-replayable {s}',
113
+ 'busy.restore': 'Restoring…',
114
+ 'confirm.restore': 'Restore',
115
+ 'ack.restore': 'I understand: restore re-appends shadowed messages with new seqs (the log stays append-only)',
116
+ 'done.revert': 'Reverted; reload to apply',
117
+ 'done.delete': 'Deleted; reload to apply',
118
+ 'action.reload': 'Reload page',
93
119
  'errorPrefix': 'Operation failed: ',
94
120
  'menu.ops': 'Message ops',
95
121
  }
96
122
 
97
123
  var __locale = null
98
124
  var __sessionsSvc = null
125
+ var __uiWorkspace = null
126
+ // messageId→seq 索引缓存(每会话一次拉取;0.3.0 assistant-actions 槽用)
127
+ var __seqIndexCache = new Map()
99
128
 
100
129
  function localeFallbackLang() {
101
130
  if (typeof navigator === 'undefined') return 'zh'
@@ -207,7 +236,8 @@ window.__ModuleLoader__.load({
207
236
  const [messages, setMessages] = useState([])
208
237
  const [sessionMeta, setSessionMeta] = useState(null)
209
238
  const [picked, setPicked] = useState(null) // seq
210
- const [mode, setMode] = useState(null) // revert|delete|branch
239
+ const [mode, setMode] = useState(null) // revert|delete|branch|restore
240
+ const [childId, setChildId] = useState(null) // 官方 fork 成功后的子会话 id
211
241
  const [acknowledged, setAcknowledged] = useState(false)
212
242
  const [busyMsg, setBusyMsg] = useState('')
213
243
  const [renderLimit, setRenderLimit] = useState(PAGE_SIZE) // 已展开的渲染条数
@@ -219,8 +249,11 @@ window.__ModuleLoader__.load({
219
249
  const d = e && e.detail ? e.detail : {}
220
250
  setTarget({ sessionId: d.sessionId || null, title: d.title || null, running: d.running === true })
221
251
  setState('loading')
222
- setMessages([]); setSessionMeta(null); setPicked(null)
223
- setMode(null); setAcknowledged(false); setError(null); setDoneMsg('')
252
+ setMessages([]); setSessionMeta(null)
253
+ // 槽按钮预置:携带 seq(picked)与 mode 直接进入确认态
254
+ setPicked(typeof d.seq === 'number' ? d.seq : null)
255
+ setMode(typeof d.mode === 'string' ? d.mode : null)
256
+ setAcknowledged(false); setError(null); setDoneMsg('')
224
257
  setRenderLimit(PAGE_SIZE)
225
258
  }
226
259
  window.addEventListener(EVENT, handler)
@@ -264,15 +297,73 @@ window.__ModuleLoader__.load({
264
297
  setMode(m); setAcknowledged(false); setError(null)
265
298
  }, [picked, state])
266
299
 
300
+ // 磁盘分支回退(0.1.x / 官方 fork 失败时):POST /api/message-ops/branch
301
+ const diskBranch = (cause) => {
302
+ const finish = () => {
303
+ fetch('/api/message-ops/branch', {
304
+ method: 'POST', headers: { 'content-type': 'application/json' },
305
+ body: JSON.stringify({ sessionId: target.sessionId, upToSeq: picked }),
306
+ })
307
+ .then(async (res) => {
308
+ let data = {}
309
+ try { data = await res.json() } catch { /* keep {} */ }
310
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
311
+ setDoneMsg(t('fork.disk', { id: data.newId || '' }))
312
+ setBusyMsg('')
313
+ setChildId(null)
314
+ setState('done')
315
+ // ISessions.refresh() 是宿主现行 API;refreshList 是旧名兜底。
316
+ if (__sessionsSvc) {
317
+ try {
318
+ const r = typeof __sessionsSvc.refresh === 'function'
319
+ ? __sessionsSvc.refresh()
320
+ : (typeof __sessionsSvc.refreshList === 'function' ? __sessionsSvc.refreshList() : null)
321
+ if (r != null) Promise.resolve(r).catch(() => {})
322
+ } catch { /* ignore */ }
323
+ }
324
+ })
325
+ .catch((reason) => {
326
+ setBusyMsg('')
327
+ setState('ready')
328
+ setError(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)))
329
+ })
330
+ }
331
+ if (cause) {
332
+ // 官方 fork 失败原因留痕后回退
333
+ setError(t('errorPrefix') + (cause && cause.message ? cause.message : String(cause)) + ' → fallback')
334
+ }
335
+ finish()
336
+ }
337
+
267
338
  const run = useCallback(() => {
268
339
  if (state === 'busy' || picked == null || !mode) return
269
- if (mode !== 'branch' && !acknowledged) return
270
- setBusyMsg(t(mode === 'revert' ? 'busy.revert' : mode === 'delete' ? 'busy.delete' : 'busy.branch'))
340
+ if (mode !== 'branch' && mode !== 'restore' && !acknowledged) return
341
+ setBusyMsg(t('busy.' + (mode === 'revert' || mode === 'delete' || mode === 'restore' ? mode : 'branch')))
271
342
  setState('busy'); setError(null)
272
- const path = mode === 'revert' ? 'revert' : mode === 'delete' ? 'delete' : 'branch'
273
- const body = mode === 'branch'
274
- ? { sessionId: target.sessionId, upToSeq: picked }
275
- : { sessionId: target.sessionId, seq: picked }
343
+ if (mode === 'branch') {
344
+ // 0.3.0 分支双路径:官方 sessions.fork({atSeq})(0.2.0+,子会话进宿主
345
+ // 列表并可立即打开)优先;0.1.x / fork 缺席回退磁盘 applyBranch。
346
+ const forkPath = pickForkPath(__sessionsSvc)
347
+ if (forkPath.kind === 'official') {
348
+ forkPath.fork({ sessionId: target.sessionId, atSeq: picked, increaseTitle: true })
349
+ .then((childId) => {
350
+ setDoneMsg(t('fork.official', { id: String(childId || '') }))
351
+ setBusyMsg('')
352
+ setChildId(childId || null)
353
+ setState('done')
354
+ notifyDone(t('fork.official', { id: String(childId || '') }))
355
+ })
356
+ .catch((reason) => {
357
+ // 官方 fork 失败(如未编目)→ 回退磁盘分支,不中断用户
358
+ return diskBranch(reason)
359
+ })
360
+ return
361
+ }
362
+ diskBranch(null)
363
+ return
364
+ }
365
+ const path = mode === 'restore' ? 'restore' : mode
366
+ const body = { sessionId: target.sessionId, seq: picked }
276
367
  fetch('/api/message-ops/' + path, {
277
368
  method: 'POST',
278
369
  headers: { 'content-type': 'application/json' },
@@ -282,19 +373,16 @@ window.__ModuleLoader__.load({
282
373
  let data = {}
283
374
  try { data = await res.json() } catch { /* keep {} */ }
284
375
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
285
- if (mode === 'branch') {
286
- setDoneMsg(t('done.branch', { id: data.newId || '' }))
287
- setBusyMsg('')
288
- setState('done')
289
- if (__sessionsSvc && typeof __sessionsSvc.refreshList === 'function') {
290
- try { Promise.resolve(__sessionsSvc.refreshList()).catch(() => {}) } catch { /* ignore */ }
291
- }
292
- } else {
293
- setDoneMsg(t(mode === 'revert' ? 'done.revert' : 'done.delete'))
294
- setBusyMsg('')
295
- setState('done')
296
- setTimeout(() => { window.location.reload() }, 900)
297
- }
376
+ // S6 修复(G-M1):不再 900ms 裸 location.reload。成功走 devkit
377
+ // 标准 toast(无 devkit 时降级为对话框内 doneMsg),并提供手动
378
+ // 「刷新页面」按钮。
379
+ const okMsg = mode === 'restore'
380
+ ? t('done.restore', { n: String(data.restoredCount != null ? data.restoredCount : '?'), s: String(data.skipped != null ? data.skipped : 0) })
381
+ : t(mode === 'revert' ? 'done.revert' : 'done.delete')
382
+ setDoneMsg(okMsg)
383
+ setBusyMsg('')
384
+ setState('done')
385
+ notifyDone(okMsg)
298
386
  })
299
387
  .catch((reason) => {
300
388
  setBusyMsg('')
@@ -355,7 +443,8 @@ window.__ModuleLoader__.load({
355
443
  )),
356
444
  ),
357
445
  pickedMsg ? React.createElement('div', { key: 'ops' },
358
- ['revert', 'delete', 'branch'].map((m, i) => React.createElement('div', { key: m, style: { marginTop: i === 0 ? 8 : 4 } },
446
+ // restore 仅对 revert/delete 落定的 replace 标记行提供(重放语义)
447
+ ['revert', 'delete', 'branch'].concat(pickedMsg.marker ? ['restore'] : []).map((m, i) => React.createElement('div', { key: m, style: { marginTop: i === 0 ? 8 : 4 } },
359
448
  React.createElement('label', { style: optStyle },
360
449
  React.createElement('input', {
361
450
  type: 'radio', name: 'dsh-message-ops-mode', checked: mode === m,
@@ -370,7 +459,7 @@ window.__ModuleLoader__.load({
370
459
  type: 'checkbox', checked: acknowledged, disabled: state === 'busy',
371
460
  onChange: (e) => setAcknowledged(e.target.checked),
372
461
  }),
373
- t(mode === 'revert' ? 'ack.revert' : 'ack.delete'))
462
+ t(mode === 'revert' ? 'ack.revert' : mode === 'restore' ? 'ack.restore' : 'ack.delete'))
374
463
  : null,
375
464
  ])
376
465
  }
@@ -398,6 +487,21 @@ window.__ModuleLoader__.load({
398
487
  ...(!canRun ? { opacity: 0.5, cursor: 'default' } : {}),
399
488
  },
400
489
  }, confirmLabel),
490
+ ...(state === 'done' && (mode === 'revert' || mode === 'delete') ? [React.createElement('button', {
491
+ key: 'reload', type: 'button',
492
+ onClick: () => { try { window.location.reload() } catch { /* non-browser guard */ } },
493
+ style: primaryBtnStyle,
494
+ }, t('action.reload'))] : []),
495
+ ...(state === 'done' && mode === 'branch' && childId ? [React.createElement('button', {
496
+ key: 'open-new', type: 'button',
497
+ onClick: () => {
498
+ try {
499
+ // 0.2.0+:uiWorkspace.openSession 是宿主唯一打开通道
500
+ if (__uiWorkspace && typeof __uiWorkspace.openSession === 'function') __uiWorkspace.openSession(childId)
501
+ } catch { /* 打开失败不阻断 */ }
502
+ },
503
+ style: primaryBtnStyle,
504
+ }, t('toast.openNew'))] : []),
401
505
  ],
402
506
  }, [
403
507
  React.createElement('div', { key: 'meta', style: metaStyle },
@@ -410,6 +514,22 @@ window.__ModuleLoader__.load({
410
514
 
411
515
  // --- 头部按钮 ---------------------------------------------------------------
412
516
 
517
+ // 0.3.0 分支双路径选择:0.2.0+ 官方 sessions.fork({atSeq}) 优先。
518
+ function pickForkPath(sessionsSvc) {
519
+ if (sessionsSvc && typeof sessionsSvc.fork === 'function') {
520
+ return { kind: 'official', fork: sessionsSvc.fork.bind(sessionsSvc) }
521
+ }
522
+ return { kind: 'disk' }
523
+ }
524
+
525
+ // 成功反馈统一出口:devkit 标准 toast 优先,无 devkit 时对话框内文案兜底。
526
+ function notifyDone(msg) {
527
+ try {
528
+ const dk = window.__dshDevkit
529
+ if (dk && typeof dk.toast === 'function') dk.toast(msg, { kind: 'ok' })
530
+ } catch { /* toast 缺席不阻断成功反馈 */ }
531
+ }
532
+
413
533
  function OpsButton(props) {
414
534
  const { sessionId, useSessions } = props
415
535
  const t = (props && props.t) || __t
@@ -430,6 +550,67 @@ window.__ModuleLoader__.load({
430
550
  }, React.createElement(BranchIcon))
431
551
  }
432
552
 
553
+ // --- 0.3.0 assistant-actions 官方槽:每条 AI 消息旁的原生回撤按钮 ------------
554
+ // 契约(dsh-cordis-client-runner):scope session,条目组件收 { messageId } +
555
+ // 标准props(useSessions/sessionId/useSession/useChat…)。0.1.x 无此 key,
556
+ // slots.inject 自动 no-op。
557
+ const CHAT_ACTIONS_SLOT = 'conversation.chat.assistant-actions'
558
+ const CHAT_ACTIONS_ID = 'message-ops-row'
559
+
560
+ function MsgSlotActions(props) {
561
+ const { messageId, sessionId, useSessions, useSession } = props
562
+ const t = (props && props.t) || __t
563
+ useLocaleRevision()
564
+ const sessions = useSessions ? useSessions((s) => s) : undefined
565
+ const summary = sessions && sessions.byId ? sessions.byId[sessionId] : undefined
566
+ const running = summary ? summary.running === true : false
567
+ // messageId → seq 反查:官方槽只给 messageId;seq 由槽按钮需要。
568
+ // 路径:messages 端点一次性拉取建索引(每会话缓存),失败则按钮隐藏。
569
+ const [seq, setSeq] = React.useState(null)
570
+ React.useEffect(() => {
571
+ if (!messageId || !sessionId) return
572
+ let alive = true
573
+ const cached = __seqIndexCache.get(sessionId)
574
+ const resolve = (index) => {
575
+ if (!alive) return
576
+ const hit = index.get(String(messageId))
577
+ if (typeof hit === 'number') setSeq(hit)
578
+ }
579
+ if (cached && typeof cached.then === 'function') cached.then(resolve).catch(() => {})
580
+ else if (cached) resolve(cached)
581
+ else {
582
+ const p = fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId))
583
+ .then((r) => (r.ok ? r.json() : { messages: [] }))
584
+ .then((data) => {
585
+ const index = new Map()
586
+ for (const m of (data && data.messages) || []) {
587
+ if (m && m.id != null) index.set(String(m.id), m.seq)
588
+ }
589
+ __seqIndexCache.set(sessionId, index)
590
+ return index
591
+ })
592
+ __seqIndexCache.set(sessionId, p)
593
+ p.then(resolve).catch(() => {})
594
+ }
595
+ return () => { alive = false }
596
+ }, [messageId, sessionId])
597
+ if (seq == null) return null
598
+ const open = (mode) => window.dispatchEvent(new CustomEvent(EVENT, {
599
+ detail: { sessionId, seq, mode, title: summary && summary.title ? summary.title : null, running },
600
+ }))
601
+ const btn = (label, mode, disabled) => React.createElement('button', {
602
+ type: 'button', title: t('slot.' + mode), 'aria-label': t('slot.' + mode), disabled: !!disabled,
603
+ style: { padding: '2px 6px', border: 'none', background: 'transparent', color: 'inherit',
604
+ opacity: disabled ? 0.4 : 0.72, cursor: disabled ? 'default' : 'pointer', font: 'inherit', fontSize: 12 },
605
+ onClick: () => { if (!disabled) open(mode) },
606
+ }, label)
607
+ return React.createElement('span', { style: { display: 'inline-flex', gap: 2 } },
608
+ btn(t('slot.revert'), 'revert', running),
609
+ btn(t('slot.delete'), 'delete', running),
610
+ btn(t('slot.branch'), 'branch', false),
611
+ )
612
+ }
613
+
433
614
  // --- 侧栏行菜单注入(DOM 级,同 session-delete 模式) ------------------------
434
615
 
435
616
  function findOpenSessionRow() {
@@ -533,6 +714,10 @@ window.__ModuleLoader__.load({
533
714
 
534
715
  function apply(ctx) {
535
716
  __sessionsSvc = ctx.get('sessions')
717
+ __uiWorkspace = typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : null
718
+ if (!__uiWorkspace) {
719
+ try { ctx.inject(['uiWorkspace'], (sub) => { __uiWorkspace = sub.uiWorkspace || sub }) } catch { /* 0.1.x 无此服务 */ }
720
+ }
536
721
  if (!__sessionsSvc) {
537
722
  ctx.inject(['sessions'], (sub) => { __sessionsSvc = sub.sessions })
538
723
  }
@@ -548,6 +733,11 @@ window.__ModuleLoader__.load({
548
733
  name: SLOT, id: ROW_ID, order: 31,
549
734
  ...(__locale ? { locale: NS } : {}),
550
735
  }, OpsButton))
736
+ // 0.3.0:每条 AI 消息旁的原生回撤按钮(0.1.x 无此槽,inject 自动 no-op)
737
+ ctx.slots.inject(CHAT_ACTIONS_SLOT, () => ctx.slots.register({
738
+ name: CHAT_ACTIONS_SLOT, id: CHAT_ACTIONS_ID, order: 20,
739
+ ...(__locale ? { locale: NS } : {}),
740
+ }, MsgSlotActions))
551
741
  ctx.slots.inject(OVERLAY_SLOT, () => ctx.slots.register({
552
742
  name: OVERLAY_SLOT, id: DIALOG_ID, order: 101,
553
743
  ...(__locale ? { locale: NS } : {}),
package/src/ops-core.js CHANGED
@@ -260,3 +260,37 @@ export function isJsonContentType(req) {
260
260
  const ct = headerOf(req && req.headers, "content-type") || "";
261
261
  return ct.split(";")[0].trim().toLowerCase() === "application/json";
262
262
  }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // 0.3.0 用户 UI 回撤体系:分支双路径 feature-detect(纯函数,client 与测试共用)
266
+ // ---------------------------------------------------------------------------
267
+
268
+ /**
269
+ * 分支双路径判定:0.2.0+ 的官方 sessions.fork({sessionId, atSeq})(原生 fork、
270
+ * 子会话进宿主列表)优先;0.1.x / fork 缺席 / 调用失败回退本插件磁盘
271
+ * applyBranch(写盘后需刷新列表才可见)。
272
+ * @param {object=} svc ctx.get('sessions') 服务(0.1.x/0.2.0 形状均可)
273
+ * @returns {{kind:'official', fork:(opts:object)=>Promise<string>} | {kind:'disk'}}
274
+ */
275
+ export function pickForkPath(svc) {
276
+ if (svc && typeof svc.fork === "function") {
277
+ return { kind: "official", fork: (opts) => svc.fork(opts) };
278
+ }
279
+ return { kind: "disk" };
280
+ }
281
+
282
+ /**
283
+ * messageId → seq 索引(0.2.0 assistant-actions 槽 ownerProps 只给 messageId;
284
+ * seq 由 messages 列表反查)。同 id 多条取最小 seq(重试链取最早可见节点)。
285
+ * @param {Array<{seq:number, id?:string|null}>} messages listMessages 输出
286
+ * @returns {Map<string, number>}
287
+ */
288
+ export function buildSeqIndex(messages) {
289
+ const map = new Map();
290
+ for (const m of messages || []) {
291
+ if (!m || typeof m.seq !== "number" || !m.id) continue;
292
+ const prev = map.get(m.id);
293
+ if (prev === undefined || m.seq < prev) map.set(m.id, m.seq);
294
+ }
295
+ return map;
296
+ }
@@ -171,10 +171,16 @@ export function listMessages(events) {
171
171
  if (e.type === "user/message" || e.type === "assistant/message" || e.type === "system/message") {
172
172
  const text = messageText(e);
173
173
  const msg = e.data && e.data.message;
174
+ // id:宿主持久化的消息身份(user 在 data.id、assistant 在 data.message.id),
175
+ // 供 0.2.0 assistant-actions 槽(ownerProps={messageId})反查 seq。
176
+ // marker:本事件是 revert/delete 落定的 replace 标记 → 对话框据此提供「恢复」模式。
177
+ const isReplaceMarker = !!(e.surfaceOp && typeof e.surfaceOp === "object" && e.surfaceOp.op === "replace");
174
178
  messages.push({
175
179
  seq: e.seq,
176
180
  type: e.type,
177
181
  role: (msg && msg.role) || (e.type === "user/message" ? "user" : e.type === "assistant/message" ? "assistant" : "system"),
182
+ id: (msg && msg.id) || e.data.id || null,
183
+ marker: isReplaceMarker,
178
184
  snippet: text.replace(/\s+/g, " ").trim().slice(0, 160),
179
185
  time: e.time ?? null,
180
186
  turn: (e.data && e.data.turn) ?? null,