@240xu/dsh-message-ops 0.2.5 → 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
@@ -155,3 +155,20 @@ node --check src/*.js
155
155
  ## License
156
156
 
157
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.5",
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
@@ -58,6 +58,18 @@ window.__ModuleLoader__.load({
58
58
  'done.delete': '删除完成,刷新页面后生效',
59
59
  'action.reload': '刷新页面',
60
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 重放被遮蔽的消息(原日志不变)',
61
73
  'menu.ops': '消息操作',
62
74
  }
63
75
 
@@ -80,6 +92,14 @@ window.__ModuleLoader__.load({
80
92
  'op.deleteDesc': 'Shadows only the picked message; everything else stays.',
81
93
  'op.branch': 'Branch into a new session from here',
82
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)',
83
103
  'ack.revert': 'I understand: revert shadows the picked message and everything after it',
84
104
  'ack.delete': 'I understand: this message will be shadowed (kept in the log)',
85
105
  'confirm.revert': 'Revert',
@@ -89,6 +109,10 @@ window.__ModuleLoader__.load({
89
109
  'busy.delete': 'Deleting…',
90
110
  'busy.branch': 'Branching…',
91
111
  'done.branch': 'Branched: new session {id} (visible after list refresh)',
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)',
92
116
  'done.revert': 'Reverted; reload to apply',
93
117
  'done.delete': 'Deleted; reload to apply',
94
118
  'action.reload': 'Reload page',
@@ -98,6 +122,9 @@ window.__ModuleLoader__.load({
98
122
 
99
123
  var __locale = null
100
124
  var __sessionsSvc = null
125
+ var __uiWorkspace = null
126
+ // messageId→seq 索引缓存(每会话一次拉取;0.3.0 assistant-actions 槽用)
127
+ var __seqIndexCache = new Map()
101
128
 
102
129
  function localeFallbackLang() {
103
130
  if (typeof navigator === 'undefined') return 'zh'
@@ -209,7 +236,8 @@ window.__ModuleLoader__.load({
209
236
  const [messages, setMessages] = useState([])
210
237
  const [sessionMeta, setSessionMeta] = useState(null)
211
238
  const [picked, setPicked] = useState(null) // seq
212
- 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
213
241
  const [acknowledged, setAcknowledged] = useState(false)
214
242
  const [busyMsg, setBusyMsg] = useState('')
215
243
  const [renderLimit, setRenderLimit] = useState(PAGE_SIZE) // 已展开的渲染条数
@@ -221,8 +249,11 @@ window.__ModuleLoader__.load({
221
249
  const d = e && e.detail ? e.detail : {}
222
250
  setTarget({ sessionId: d.sessionId || null, title: d.title || null, running: d.running === true })
223
251
  setState('loading')
224
- setMessages([]); setSessionMeta(null); setPicked(null)
225
- 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('')
226
257
  setRenderLimit(PAGE_SIZE)
227
258
  }
228
259
  window.addEventListener(EVENT, handler)
@@ -266,27 +297,20 @@ window.__ModuleLoader__.load({
266
297
  setMode(m); setAcknowledged(false); setError(null)
267
298
  }, [picked, state])
268
299
 
269
- const run = useCallback(() => {
270
- if (state === 'busy' || picked == null || !mode) return
271
- if (mode !== 'branch' && !acknowledged) return
272
- setBusyMsg(t(mode === 'revert' ? 'busy.revert' : mode === 'delete' ? 'busy.delete' : 'busy.branch'))
273
- setState('busy'); setError(null)
274
- const path = mode === 'revert' ? 'revert' : mode === 'delete' ? 'delete' : 'branch'
275
- const body = mode === 'branch'
276
- ? { sessionId: target.sessionId, upToSeq: picked }
277
- : { sessionId: target.sessionId, seq: picked }
278
- fetch('/api/message-ops/' + path, {
279
- method: 'POST',
280
- headers: { 'content-type': 'application/json' },
281
- body: JSON.stringify(body),
282
- })
283
- .then(async (res) => {
284
- let data = {}
285
- try { data = await res.json() } catch { /* keep {} */ }
286
- if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
287
- if (mode === 'branch') {
288
- setDoneMsg(t('done.branch', { id: data.newId || '' }))
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 || '' }))
289
312
  setBusyMsg('')
313
+ setChildId(null)
290
314
  setState('done')
291
315
  // ISessions.refresh() 是宿主现行 API;refreshList 是旧名兜底。
292
316
  if (__sessionsSvc) {
@@ -297,20 +321,68 @@ window.__ModuleLoader__.load({
297
321
  if (r != null) Promise.resolve(r).catch(() => {})
298
322
  } catch { /* ignore */ }
299
323
  }
300
- } else {
301
- // S6 修复(G-M1):不再 900ms 裸 location.reload。成功走 devkit
302
- // 标准 toast(无 devkit 时降级为对话框内 doneMsg),并提供手动
303
- // 「刷新页面」按钮;与 branch 路径的 refreshList 不刷新行为拉齐。
304
- setDoneMsg(t(mode === 'revert' ? 'done.revert' : 'done.delete'))
324
+ })
325
+ .catch((reason) => {
305
326
  setBusyMsg('')
306
- setState('done')
307
- try {
308
- const dk = window.__dshDevkit
309
- if (dk && typeof dk.toast === 'function') {
310
- dk.toast(t(mode === 'revert' ? 'done.revert' : 'done.delete'), { kind: 'ok' })
311
- }
312
- } catch { /* toast 缺席不阻断成功反馈 */ }
313
- }
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
+
338
+ const run = useCallback(() => {
339
+ if (state === 'busy' || picked == null || !mode) return
340
+ if (mode !== 'branch' && mode !== 'restore' && !acknowledged) return
341
+ setBusyMsg(t('busy.' + (mode === 'revert' || mode === 'delete' || mode === 'restore' ? mode : 'branch')))
342
+ setState('busy'); setError(null)
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 }
367
+ fetch('/api/message-ops/' + path, {
368
+ method: 'POST',
369
+ headers: { 'content-type': 'application/json' },
370
+ body: JSON.stringify(body),
371
+ })
372
+ .then(async (res) => {
373
+ let data = {}
374
+ try { data = await res.json() } catch { /* keep {} */ }
375
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
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)
314
386
  })
315
387
  .catch((reason) => {
316
388
  setBusyMsg('')
@@ -371,7 +443,8 @@ window.__ModuleLoader__.load({
371
443
  )),
372
444
  ),
373
445
  pickedMsg ? React.createElement('div', { key: 'ops' },
374
- ['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 } },
375
448
  React.createElement('label', { style: optStyle },
376
449
  React.createElement('input', {
377
450
  type: 'radio', name: 'dsh-message-ops-mode', checked: mode === m,
@@ -386,7 +459,7 @@ window.__ModuleLoader__.load({
386
459
  type: 'checkbox', checked: acknowledged, disabled: state === 'busy',
387
460
  onChange: (e) => setAcknowledged(e.target.checked),
388
461
  }),
389
- t(mode === 'revert' ? 'ack.revert' : 'ack.delete'))
462
+ t(mode === 'revert' ? 'ack.revert' : mode === 'restore' ? 'ack.restore' : 'ack.delete'))
390
463
  : null,
391
464
  ])
392
465
  }
@@ -419,6 +492,16 @@ window.__ModuleLoader__.load({
419
492
  onClick: () => { try { window.location.reload() } catch { /* non-browser guard */ } },
420
493
  style: primaryBtnStyle,
421
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'))] : []),
422
505
  ],
423
506
  }, [
424
507
  React.createElement('div', { key: 'meta', style: metaStyle },
@@ -431,6 +514,22 @@ window.__ModuleLoader__.load({
431
514
 
432
515
  // --- 头部按钮 ---------------------------------------------------------------
433
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
+
434
533
  function OpsButton(props) {
435
534
  const { sessionId, useSessions } = props
436
535
  const t = (props && props.t) || __t
@@ -451,6 +550,67 @@ window.__ModuleLoader__.load({
451
550
  }, React.createElement(BranchIcon))
452
551
  }
453
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
+
454
614
  // --- 侧栏行菜单注入(DOM 级,同 session-delete 模式) ------------------------
455
615
 
456
616
  function findOpenSessionRow() {
@@ -554,6 +714,10 @@ window.__ModuleLoader__.load({
554
714
 
555
715
  function apply(ctx) {
556
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
+ }
557
721
  if (!__sessionsSvc) {
558
722
  ctx.inject(['sessions'], (sub) => { __sessionsSvc = sub.sessions })
559
723
  }
@@ -569,6 +733,11 @@ window.__ModuleLoader__.load({
569
733
  name: SLOT, id: ROW_ID, order: 31,
570
734
  ...(__locale ? { locale: NS } : {}),
571
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))
572
741
  ctx.slots.inject(OVERLAY_SLOT, () => ctx.slots.register({
573
742
  name: OVERLAY_SLOT, id: DIALOG_ID, order: 101,
574
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,