@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 +17 -0
- package/package.json +1 -1
- package/src/client.js +207 -38
- package/src/ops-core.js +34 -0
- package/src/session-file.js +6 -0
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.
|
|
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)
|
|
225
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
}
|
|
301
|
-
|
|
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('
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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,
|