@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 +24 -0
- package/package.json +1 -1
- package/src/client.js +218 -28
- package/src/ops-core.js +34 -0
- package/src/session-file.js +6 -0
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.
|
|
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.
|
|
92
|
-
'
|
|
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)
|
|
223
|
-
|
|
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'
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/session-file.js
CHANGED
|
@@ -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,
|