@240xu/dsh-message-ops 0.3.1 → 0.4.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
@@ -161,8 +161,8 @@ MIT
161
161
  针对 dsh 0.2.0-rc.2 的用户级操作面(agent 工具保留,但 UI 为主推入口):
162
162
 
163
163
  - **每条 AI 消息旁的原生回撤按钮**:注入官方 `conversation.chat.assistant-actions`
164
- 槽(0.2.0 正式扩展点,条目收持久化 messageId)——「⏪ 回滚到此 / ✂ 删除此条 /
165
- ⑂ 从此分支」直接出现在消息操作行,与官方点赞/点踩并列;0.1.x 无此槽自动 no-op。
164
+ 槽(0.2.0 正式扩展点,条目收持久化 messageId)——「回滚到此 / 删除此条 /
165
+ 从此分支」直接出现在消息操作行,与官方点赞/点踩并列;0.1.x 无此槽自动 no-op。
166
166
  messageId→seq 反查经 `/api/message-ops/messages`(每会话索引缓存)。
167
167
  - **分支双路径**:0.2.0+ 优先官方 `sessions.fork({sessionId, atSeq, increaseTitle})`
168
168
  (子会话自动进宿主列表,成功后可一键 `uiWorkspace.openSession` 打开);
@@ -181,3 +181,20 @@ MIT
181
181
  (限已完成轮次最后一条);任意位置分支保留在对话框(官方没有的能力)。
182
182
  - **原生观感**:按钮改用官方 primitives 图标(IconClockOutline/IconTrashOutline,
183
183
  require 失败回退内联 SVG);S3 命中区 ≥44px;S11 双提交防护(busy 互斥)。
184
+
185
+ ### 0.3.2 · 图标规范化
186
+
187
+ - 槽按钮去掉文字 emoji——0.2.0+ 直接用官方 primitives 图标(与原生操作行 1:1 同款);
188
+ 回退路径的 SVG 也改为官方 IconClockOutline/IconTrashOutline 的精确 path 数据
189
+ (1px stroke,与 Regular 变体一致),不再手绘。
190
+ - 字典文案去 emoji(label 仅用于 tooltip/aria,不参与渲染)。
191
+
192
+ ### 0.4.0 · opencode 式回撤深化(composer 回填 + 回撤 dock)
193
+
194
+ - **Composer 回填**:回滚**用户消息**后,原文经官方 `InputActions.setDraft`
195
+ 自动回填输入框——「编辑重发」零按钮(opencode 招牌交互)。
196
+ - **结构化回撤 dock**:注入官方 `conversation.input.dock` 槽(composer 卡片上方
197
+ 全宽条目)——「已回撤 N 条(可恢复)」折叠面板,展开列出每个回撤/删除标记,
198
+ 逐条「恢复」(restoring 中间态禁用,S11);无标记时 dock 不渲染。
199
+ - 数据链:messages 端点为 user 消息新增 `fullText` 字段(回填原文)。
200
+ - 0.1.x 宿主两个槽均不存在,inject 自动 no-op。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@240xu/dsh-message-ops",
3
- "version": "0.3.1",
3
+ "version": "0.4.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
@@ -70,6 +70,9 @@ window.__ModuleLoader__.load({
70
70
  'busy.restore': '恢复中…',
71
71
  'confirm.restore': '恢复',
72
72
  'ack.restore': '我已了解:恢复将以新 seq 重放被遮蔽的消息(原日志不变)',
73
+ 'dock.title': '已回撤 {n} 条(可恢复)',
74
+ 'dock.restore': '恢复',
75
+ 'dock.restoring': '恢复中…',
73
76
  'menu.ops': '消息操作',
74
77
  }
75
78
 
@@ -94,9 +97,9 @@ window.__ModuleLoader__.load({
94
97
  'op.branchDesc': 'Copies everything up to and including the picked message into a new session (original untouched, non-destructive).',
95
98
  'op.restore': 'Restore (replay shadowed messages)',
96
99
  '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
+ 'slot.revert': 'Revert to here',
101
+ 'slot.delete': 'Delete this message',
102
+ 'slot.branch': 'Branch from here',
100
103
  'toast.openNew': 'Open new session',
101
104
  'fork.official': 'Branched (official fork): new session {id}',
102
105
  'fork.disk': 'Branched: new session {id} (visible after list refresh)',
@@ -117,11 +120,15 @@ window.__ModuleLoader__.load({
117
120
  'done.delete': 'Deleted; reload to apply',
118
121
  'action.reload': 'Reload page',
119
122
  'errorPrefix': 'Operation failed: ',
123
+ 'dock.title': '{n} rolled back (restorable)',
124
+ 'dock.restore': 'Restore',
125
+ 'dock.restoring': 'Restoring…',
120
126
  'menu.ops': 'Message ops',
121
127
  }
122
128
 
123
129
  var __locale = null
124
130
  var __sessionsSvc = null
131
+ var __inputActions = null // 0.4.0: 从 session 槽捕获(InputActions.setDraft → composer 回填)
125
132
  var __uiWorkspace = null
126
133
  // messageId→seq 索引缓存(每会话一次拉取;0.3.0 assistant-actions 槽用)
127
134
  var __seqIndexCache = new Map()
@@ -379,6 +386,11 @@ window.__ModuleLoader__.load({
379
386
  const okMsg = mode === 'restore'
380
387
  ? t('done.restore', { n: String(data.restoredCount != null ? data.restoredCount : '?'), s: String(data.skipped != null ? data.skipped : 0) })
381
388
  : t(mode === 'revert' ? 'done.revert' : 'done.delete')
389
+ // 0.4.0 composer 回填(opencode 式):回滚用户消息 → 原文回填输入框,「编辑重发」零按钮
390
+ if (mode === 'revert' && pickedMsg && pickedMsg.role === 'user' && pickedMsg.fullText
391
+ && __inputActions && typeof __inputActions.setDraft === 'function') {
392
+ try { __inputActions.setDraft(pickedMsg.fullText) } catch { /* 回填失败不阻断成功反馈 */ }
393
+ }
382
394
  setDoneMsg(okMsg)
383
395
  setBusyMsg('')
384
396
  setState('done')
@@ -559,14 +571,19 @@ window.__ModuleLoader__.load({
559
571
  IconClock = P && P.IconClockOutlineRegular
560
572
  IconTrash = P && P.IconTrashOutlineRegular
561
573
  } catch { /* fallback below */ }
562
- if (!IconClock) IconClock = function () {
563
- return React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none' },
564
- React.createElement('circle', { cx: 8, cy: 8, r: 6.2, stroke: 'currentColor', strokeWidth: 1.3 }),
565
- React.createElement('path', { d: 'M8 4.8V8l2.2 1.6', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round' }))
574
+ if (!IconClock) IconClock = function ClockFallback() {
575
+ // 官方 IconClockOutlineArtwork 1:1 路径( Regular = 1px stroke)
576
+ return React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true, stroke: 'currentColor', strokeWidth: 1 },
577
+ React.createElement('path', { d: 'M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z' }),
578
+ React.createElement('path', { d: 'M8 4.31V8.46L11 10.08' }))
566
579
  }
567
- if (!IconTrash) IconTrash = function () {
568
- return React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none' },
569
- React.createElement('path', { d: 'M3 5h10M6.5 5V3.5h3V5M4.5 5l.6 7.5h5.8L11.5 5', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round' }))
580
+ if (!IconTrash) IconTrash = function TrashFallback() {
581
+ // 官方 IconTrashOutlineArtwork 1:1 路径
582
+ return React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true, stroke: 'currentColor', strokeWidth: 1 },
583
+ React.createElement('path', { d: 'M1.28149 3.88831H14.7187' }),
584
+ React.createElement('path', { d: 'M5.41602 3.88833V2.47962C5.41602 2.29282 5.52492 2.11366 5.71876 1.98157C5.9126 1.84948 6.17551 1.77527 6.44964 1.77527H9.55053C9.82466 1.77527 10.0876 1.84948 10.2814 1.98157C10.4752 2.11366 10.5841 2.29282 10.5841 2.47962V3.88833' }),
585
+ React.createElement('path', { d: 'M3.29749 5.10193L4.06585 13.0192C4.10899 13.4595 4.48223 13.7942 4.92504 13.7942H11.0751C11.5179 13.7942 11.8912 13.4595 11.9343 13.0192L12.7027 5.10193' }),
586
+ React.createElement('path', { d: 'M6.27637 7.51831V11.1829M9.72378 7.51831V11.1829' }))
570
587
  }
571
588
 
572
589
  // --- 0.3.0 assistant-actions 官方槽:每条 AI 消息旁的原生回撤按钮 ------------
@@ -577,7 +594,8 @@ window.__ModuleLoader__.load({
577
594
  const CHAT_ACTIONS_ID = 'message-ops-row'
578
595
 
579
596
  function MsgSlotActions(props) {
580
- const { messageId, sessionId, useSessions } = props
597
+ const { messageId, sessionId, useSessions, inputActions } = props
598
+ if (inputActions && typeof inputActions.setDraft === 'function') __inputActions = inputActions
581
599
  const t = (props && props.t) || __t
582
600
  useLocaleRevision()
583
601
  const sessions = useSessions ? useSessions((s) => s) : undefined
@@ -651,6 +669,101 @@ window.__ModuleLoader__.load({
651
669
  )
652
670
  }
653
671
 
672
+ // --- 0.4.0 回撤 dock:composer 上方「N 条已回撤」结构化面板(opencode 式) ----
673
+ // 官方槽 conversation.input.dock(scope session,composer 卡片上方全宽条目)。
674
+ // ownerProps/standardProps 含 sessionId + useSessions + inputActions。
675
+ const INPUT_DOCK_SLOT = 'conversation.input.dock'
676
+ const INPUT_DOCK_ID = 'message-ops-revert-dock'
677
+
678
+ function RevertDock(props) {
679
+ const { sessionId, useSessions, inputActions } = props
680
+ const t = (props && props.t) || __t
681
+ useLocaleRevision()
682
+ if (inputActions && typeof inputActions.setDraft === 'function') __inputActions = inputActions
683
+ const sessions = useSessions ? useSessions((s) => s) : undefined
684
+ const summary = sessions && sessions.byId ? sessions.byId[sessionId] : undefined
685
+ const running = summary ? summary.running === true : false
686
+ const [open, setOpen] = useState(false)
687
+ const [rows, setRows] = useState(null) // [{seq, snippet, role}] replace 标记行
688
+ const [shadowedCount, setShadowedCount] = useState(0)
689
+ const [restoring, setRestoring] = useState(null)
690
+
691
+ useEffect(() => {
692
+ let alive = true
693
+ fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId))
694
+ .then((r) => (r.ok ? r.json() : null))
695
+ .then((data) => {
696
+ if (!alive || !data || !data.ok) return
697
+ const msgs = data.messages || []
698
+ setShadowedCount(msgs.filter((m) => m.visible === false && m.role !== 'system').length)
699
+ setRows(msgs.filter((m) => m.marker))
700
+ })
701
+ .catch(() => {})
702
+ return () => { alive = false }
703
+ }, [sessionId])
704
+
705
+ if (!rows || !rows.length) return null // 无回撤标记 → dock 不渲染
706
+
707
+ const restoreRow = (row) => {
708
+ if (restoring != null || running) return
709
+ setRestoring(row.seq)
710
+ fetch('/api/message-ops/restore', {
711
+ method: 'POST', headers: { 'content-type': 'application/json' },
712
+ body: JSON.stringify({ sessionId, seq: row.seq }),
713
+ })
714
+ .then(async (res) => {
715
+ let data = {}
716
+ try { data = await res.json() } catch { /* keep {} */ }
717
+ if (!res.ok || !data.ok) throw new Error(data.error || ('HTTP ' + res.status))
718
+ notifyDone(t('done.restore', { n: String(data.restoredCount != null ? data.restoredCount : '?'), s: String(data.skipped != null ? data.skipped : 0) }))
719
+ // 刷新 dock 数据(重放后标记行状态变化)
720
+ const fresh = await fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId)).then((r) => (r.ok ? r.json() : null)).catch(() => null)
721
+ if (fresh && fresh.ok) {
722
+ const msgs = fresh.messages || []
723
+ setShadowedCount(msgs.filter((m) => m.visible === false && m.role !== 'system').length)
724
+ setRows(msgs.filter((m) => m.marker))
725
+ }
726
+ })
727
+ .catch((reason) => {
728
+ notifyDone(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error')
729
+ })
730
+ .finally(() => setRestoring(null))
731
+ }
732
+
733
+ const rowEl = (row) => React.createElement('div', {
734
+ key: row.seq,
735
+ style: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 10px',
736
+ borderTop: '1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.15))' },
737
+ },
738
+ React.createElement('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, opacity: 0.85 } },
739
+ '#' + row.seq + ' ' + (row.role || '') + ' · ' + (row.snippet || '')),
740
+ React.createElement('button', {
741
+ type: 'button', 'aria-label': t('dock.restore'), disabled: restoring != null || running,
742
+ style: { border: 'none', background: 'transparent', color: 'var(--dsw-alias-link,#8ab4f8)',
743
+ font: 'inherit', fontSize: 12, cursor: restoring != null || running ? 'default' : 'pointer',
744
+ opacity: restoring != null || running ? 0.5 : 1, padding: '4px 8px', minHeight: 28 },
745
+ onClick: () => restoreRow(row),
746
+ }, restoring === row.seq ? t('dock.restoring') : t('dock.restore')),
747
+ )
748
+
749
+ return React.createElement('div', {
750
+ role: 'region', 'aria-label': t('dock.title', { n: String(shadowedCount) }),
751
+ style: { border: '1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.25))', borderRadius: 8, margin: '0 0 8px', overflow: 'hidden',
752
+ background: 'var(--dsw-alias-bg-elevated,rgba(128,128,128,.06))' },
753
+ },
754
+ React.createElement('button', {
755
+ type: 'button', 'aria-expanded': open,
756
+ style: { display: 'flex', width: '100%', alignItems: 'center', justifyContent: 'space-between',
757
+ border: 'none', background: 'transparent', color: 'inherit',
758
+ font: 'inherit', fontSize: 12, padding: '6px 10px', cursor: 'pointer', minHeight: 32 },
759
+ onClick: () => setOpen((v) => !v),
760
+ },
761
+ React.createElement('span', null, t('dock.title', { n: String(shadowedCount) })),
762
+ React.createElement('span', { style: { opacity: 0.6 } }, open ? '▾' : '▸')),
763
+ open ? React.createElement('div', { style: { maxHeight: 180, overflowY: 'auto' } }, rows.map(rowEl)) : null,
764
+ )
765
+ }
766
+
654
767
  // --- 侧栏行菜单注入(DOM 级,同 session-delete 模式) ------------------------
655
768
 
656
769
  function findOpenSessionRow() {
@@ -778,6 +891,11 @@ window.__ModuleLoader__.load({
778
891
  name: CHAT_ACTIONS_SLOT, id: CHAT_ACTIONS_ID, order: 20,
779
892
  ...(__locale ? { locale: NS } : {}),
780
893
  }, MsgSlotActions))
894
+ // 0.4.0:composer 上方回撤 dock(0.1.x 无此槽自动 no-op)
895
+ ctx.slots.inject(INPUT_DOCK_SLOT, () => ctx.slots.register({
896
+ name: INPUT_DOCK_SLOT, id: INPUT_DOCK_ID, order: 10,
897
+ ...(__locale ? { locale: NS } : {}),
898
+ }, RevertDock))
781
899
  ctx.slots.inject(OVERLAY_SLOT, () => ctx.slots.register({
782
900
  name: OVERLAY_SLOT, id: DIALOG_ID, order: 101,
783
901
  ...(__locale ? { locale: NS } : {}),
@@ -182,6 +182,8 @@ export function listMessages(events) {
182
182
  id: (msg && msg.id) || e.data.id || null,
183
183
  marker: isReplaceMarker,
184
184
  snippet: text.replace(/\s+/g, " ").trim().slice(0, 160),
185
+ // 0.4.0 composer 回填:user 消息携带全文(revert 后 setDraft 原文)
186
+ fullText: e.type === "user/message" ? text : null,
185
187
  time: e.time ?? null,
186
188
  turn: (e.data && e.data.turn) ?? null,
187
189
  });