@240xu/dsh-plugin-hub 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +203 -2
- package/client/index.js +3551 -0
- package/cordis.patch.yml +43 -0
- package/package.json +62 -4
- package/scripts/build-client.mjs +124 -0
- package/scripts/client-runtime.template.js +121 -0
- package/src/index.js +7 -0
- package/src/shell.js +215 -0
package/client/index.js
ADDED
|
@@ -0,0 +1,3551 @@
|
|
|
1
|
+
/* @240xu/dsh-plugin-hub — generated browser bundle. DO NOT EDIT.
|
|
2
|
+
* Regenerate: node scripts/build-client.mjs
|
|
3
|
+
* Inputs:
|
|
4
|
+
* @240xu/dsh-message-ops@0.11.0 ./src/client.js sha256:c288c1f882ddc388746daaec26b2212ea916677de07e6b86bd8f24f16a56120e
|
|
5
|
+
* @240xu/dsh-websearch@2.8.7 ./lib/client.js sha256:81a02ea8b1bed47e0dc021330129cddb90b4c5619ee36ea884290349a498c0cf
|
|
6
|
+
*/
|
|
7
|
+
window.__ModuleLoader__.load({
|
|
8
|
+
id: "@240xu/dsh-plugin-hub",
|
|
9
|
+
factory: (require) => {
|
|
10
|
+
const CHILDREN = [
|
|
11
|
+
{
|
|
12
|
+
name: "@240xu/dsh-message-ops",
|
|
13
|
+
moduleId: "@240xu/dsh-plugin-hub/client/message-ops",
|
|
14
|
+
capture() {
|
|
15
|
+
let factory;
|
|
16
|
+
const loader = window.__ModuleLoader__;
|
|
17
|
+
const original = loader.load;
|
|
18
|
+
loader.load = (registration) => { factory = registration.factory; };
|
|
19
|
+
try {
|
|
20
|
+
/* global window, document, fetch, navigator */
|
|
21
|
+
/**
|
|
22
|
+
* dsh-message-ops 客户端:会话头部按钮 + 共享操作对话框 + 侧栏行菜单项。
|
|
23
|
+
* 一个对话框承载回滚 / 删除 / 分支三种操作;日志 append-only,遮蔽可恢复。
|
|
24
|
+
* 结构照抄 @huanlin/dsh-plugin-session-delete。事故复盘见 README「历史事故记录」#1。
|
|
25
|
+
*/
|
|
26
|
+
window.__ModuleLoader__.load({
|
|
27
|
+
id: '@240xu/dsh-message-ops',
|
|
28
|
+
factory: (require) => {
|
|
29
|
+
const React = require('react')
|
|
30
|
+
const { useCallback, useEffect, useRef, useState } = React
|
|
31
|
+
const { Modal } = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
32
|
+
|
|
33
|
+
const SLOT = 'conversation.session.header.actions'
|
|
34
|
+
const ROW_ID = 'message-ops'
|
|
35
|
+
const OVERLAY_SLOT = 'shell.overlay'
|
|
36
|
+
const INPUT_DOCK_SLOT = 'conversation.input.dock'
|
|
37
|
+
const INPUT_DOCK_ID = 'message-ops-revert-dock'
|
|
38
|
+
const DIALOG_ID = 'message-ops-dialog'
|
|
39
|
+
const EVENT = 'dsh-message-ops:open'
|
|
40
|
+
// 0.5.2(P2):回滚/恢复成功后广播 → RevertDock 重拉标记(否则 dock 只在切会话时刷新)
|
|
41
|
+
const CHANGED_EVENT = 'dsh-message-ops:changed'
|
|
42
|
+
// 0.9.7(P4):messageId→seq 索引失效(dataSig 变化)—— 已挂载的
|
|
43
|
+
// MsgSlotActions 靠它重跑解析 effect,否则「新 AI 消息旁没有按钮」。
|
|
44
|
+
const SEQ_INDEX_EVENT = 'dsh-message-ops:seq-index'
|
|
45
|
+
// 0.9.7(P0-2 读合并):一次写操作会同时触发 150/700/1600ms 三连 +
|
|
46
|
+
// RevertDock 的 CHANGED 重拉 + openSession 后 MO 调度的 scan(5~6 次 GET)。
|
|
47
|
+
// 同一会话在此窗口内的请求共享同一个 promise;窗口**必须 < 1600ms**,
|
|
48
|
+
// 否则「视图重建后重算隐藏」的 1600ms 兜底会被上一次响应吞掉。
|
|
49
|
+
const MSG_COALESCE_MS = 1000
|
|
50
|
+
var __messagesMemo = { url: null, at: 0, p: null, settled: false }
|
|
51
|
+
var invalidateMessagesMemo = () => { __messagesMemo = { url: null, at: 0, p: null, settled: false } }
|
|
52
|
+
const emitChanged = () => {
|
|
53
|
+
// 数据变了:连同本地缓存一起作废(否则 8s TTL 会让 openSession 后的
|
|
54
|
+
// scan 拿旧 payload 重算隐藏 = 「回滚了但界面没变」)。
|
|
55
|
+
invalidateMessagesMemo()
|
|
56
|
+
if (__peekRowsCache) { const c = __peekRowsCache(); if (c) c.at = 0 }
|
|
57
|
+
try { window.dispatchEvent(new CustomEvent(CHANGED_EVENT)) } catch { /* noop */ }
|
|
58
|
+
}
|
|
59
|
+
const NS = 'dsh-message-ops'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* GET /api/message-ops/messages 的唯一出口(0.9.7 读合并)。
|
|
63
|
+
* - 并发调用共享同一个 promise;
|
|
64
|
+
* - 已结算的响应在 MSG_COALESCE_MS 内复用(一次写操作的 5~6 路重拉 → 1~2 次网络);
|
|
65
|
+
* - 只在 `ok:true` 时才留窗口:失败/非 2xx 立刻作废,免得把后续重试也吃掉;
|
|
66
|
+
* - 1600ms 那档兜底刷新由调用方先 `invalidateMessagesMemo()` 再拉,保证是真网络。
|
|
67
|
+
*/
|
|
68
|
+
var fetchMessagesJson = (rawUrl) => {
|
|
69
|
+
// 归一 sessionId 写法:本文件有的调用方传 `session-xxx`(dock / 对话框),
|
|
70
|
+
// 有的传裸 uuid(fetchRows / /redo)。不归一就是两个缓存槽,合并直接失效。
|
|
71
|
+
// 服务端两种写法都认(index.js "uuid or session-<uuid> form")。
|
|
72
|
+
const url = String(rawUrl).replace(/([?&]sessionId=)session-/, '$1')
|
|
73
|
+
const m = __messagesMemo
|
|
74
|
+
if (m.url === url && m.p && (!m.settled || Date.now() - m.at < MSG_COALESCE_MS)) return m.p
|
|
75
|
+
const entry = { url, at: Date.now(), p: null, settled: false }
|
|
76
|
+
entry.p = fetch(url)
|
|
77
|
+
.then((r) => r.json().catch(() => null).then((d) => ({ r, d })))
|
|
78
|
+
.then(({ r, d }) => {
|
|
79
|
+
if (!r.ok || !d || d.ok !== true) throw new Error((d && d.error) || ('HTTP ' + r.status))
|
|
80
|
+
entry.settled = true
|
|
81
|
+
entry.at = Date.now()
|
|
82
|
+
return d
|
|
83
|
+
})
|
|
84
|
+
.catch((e) => { if (__messagesMemo === entry) invalidateMessagesMemo(); throw e })
|
|
85
|
+
__messagesMemo = entry
|
|
86
|
+
return entry.p
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 0.9.7(P0-4):zh/en 两本字典 key 集合**必须一致且顺序一致**——宿主 locale
|
|
90
|
+
// 的 translate() 按「活跃语言 → en」回退链查表(zh 回退 en,en 无回退),
|
|
91
|
+
// 所以缺 key 不一定看得见:zh 缺 → 落到 en(0.9.x 这批 en 值是中文,
|
|
92
|
+
// zh 用户"碰巧"正常);en 缺 → 直接把 key 当文案显示。
|
|
93
|
+
// 反过来 en 值写成中文时,英文用户会看到中文。测试锁死了两侧 key 序列相等。
|
|
94
|
+
const zhDict = {
|
|
95
|
+
'button.title': '消息操作(回滚/删除/分支)',
|
|
96
|
+
'dialog.title': '消息操作',
|
|
97
|
+
'dialog.cancel': '关闭',
|
|
98
|
+
'dialog.loading': '正在读取消息列表…',
|
|
99
|
+
'dialog.loadFail': '消息列表读取失败:',
|
|
100
|
+
'dialog.empty': '该会话没有可操作的消息。',
|
|
101
|
+
'dialog.session': '会话:',
|
|
102
|
+
'dialog.runningWarn': '⚠ 会话正在运行:请先停止该会话再执行回滚/删除/分支。',
|
|
103
|
+
'dialog.pick': '选择一条消息:',
|
|
104
|
+
'dialog.pickFirst': '第 1 步 · 在下方选择一条消息,第 2 步在这里选择操作。',
|
|
105
|
+
'dialog.pickedAs': '已选 #{seq} · {text}',
|
|
106
|
+
'dialog.invisible': '(已遮蔽,仅日志可见)',
|
|
107
|
+
'dialog.showMore': '显示更多(剩余 {n} 条)',
|
|
108
|
+
'dialog.more': '…(仅显示最近 200 条,共 {n} 条)',
|
|
109
|
+
'op.revert': '回滚到此条(含)之后全部移除',
|
|
110
|
+
'op.revertDesc': '遮蔽所选消息及其后所有可见内容;日志 append-only,可通过重新发送恢复语境。',
|
|
111
|
+
'op.delete': '仅删除这一条',
|
|
112
|
+
'op.deleteDesc': '仅遮蔽所选的一条消息;其余内容保持不变。',
|
|
113
|
+
'op.branch': '从此条分支为新会话',
|
|
114
|
+
'op.branchDesc': '复制所选消息(含)之前的全部事件为新会话(原会话不变,非破坏操作)。',
|
|
115
|
+
'slot.revert': '回滚到此条',
|
|
116
|
+
'slot.revertUnmatched': '在日志里定位不到这条消息的 seq(通常是模型写的摘要与渲染文本偏差过大)——请改用「消息操作」对话框。',
|
|
117
|
+
'toast.openNew': '打开新会话',
|
|
118
|
+
'fork.official': '分支完成:新会话 {id}',
|
|
119
|
+
'fork.unavailable': '官方分支不可用:通常是因为会话正在运行,请先停止会话后重试。分支功能由 dsh 内置的 session/fork 提供。',
|
|
120
|
+
'ack.revert': '我已了解:回滚会遮蔽所选消息及其后的全部内容',
|
|
121
|
+
'ack.delete': '我已了解:将遮蔽这一条消息(日志中仍保留)',
|
|
122
|
+
'confirm.revert': '回滚',
|
|
123
|
+
'confirm.delete': '删除',
|
|
124
|
+
'confirm.branch': '分支',
|
|
125
|
+
'busy.revert': '回滚中…',
|
|
126
|
+
'busy.delete': '删除中…',
|
|
127
|
+
'busy.branch': '分支中…',
|
|
128
|
+
'done.restore': '恢复完成:重放 {n} 条,跳过不可重放 {s} 条',
|
|
129
|
+
'done.revert': '回滚完成,刷新页面后生效',
|
|
130
|
+
'done.delete': '删除完成,刷新页面后生效',
|
|
131
|
+
'action.reload': '刷新页面',
|
|
132
|
+
'errorPrefix': '操作失败:',
|
|
133
|
+
'dock.title': '已回撤 {n} 条消息',
|
|
134
|
+
'dock.expand': '展开回撤列表',
|
|
135
|
+
'dock.collapse': '折叠回撤列表',
|
|
136
|
+
'dock.shadowedN': '{n} 条已遮蔽',
|
|
137
|
+
'dock.restore': '恢复',
|
|
138
|
+
'dock.restoreHint': '恢复为重放:消息将出现在会话末尾,不会回到原位',
|
|
139
|
+
'dock.restoring': '恢复中…',
|
|
140
|
+
'slot.quote': '引用到输入框',
|
|
141
|
+
'quote.done': '已引用到输入框',
|
|
142
|
+
'quote.unavailable': '引用不可用(输入框不可写或无全文)',
|
|
143
|
+
'undo.label': '回滚上一条消息',
|
|
144
|
+
'undo.desc': '移除最后一条用户消息,原文回填输入框',
|
|
145
|
+
'undo.done.undo': '已回滚上一条消息',
|
|
146
|
+
'undo.done.redo': '已恢复上一条被回滚的消息',
|
|
147
|
+
'undo.none': '没有可回滚的消息',
|
|
148
|
+
'undo.none.redo': '没有可恢复的回滚',
|
|
149
|
+
'redo.label': '恢复上一条被回滚的消息',
|
|
150
|
+
'redo.desc': '把回滚掉的消息重新放回对话',
|
|
151
|
+
'panel.messages': '消息',
|
|
152
|
+
'panel.trash': '回收站',
|
|
153
|
+
'panel.search': '搜索当前会话',
|
|
154
|
+
'panel.searchHint': '搜索全文,不只是摘要',
|
|
155
|
+
'panel.all': '全部',
|
|
156
|
+
'panel.user': '用户',
|
|
157
|
+
'panel.assistant': '助手',
|
|
158
|
+
'panel.tool': '工具',
|
|
159
|
+
'panel.includeShadowed': '含已回滚',
|
|
160
|
+
'panel.loading': '正在读取…',
|
|
161
|
+
'panel.loadFail': '读取失败:',
|
|
162
|
+
'panel.searchFail': '搜索失败:',
|
|
163
|
+
'panel.empty': '没有可显示的消息。',
|
|
164
|
+
'panel.noMatch': '没有匹配结果。',
|
|
165
|
+
'panel.selectHint': '选择一条消息,查看全文和操作。',
|
|
166
|
+
'panel.detailLoading': '正在读取全文…',
|
|
167
|
+
'panel.shadowed': '已回滚',
|
|
168
|
+
'panel.running': '会话正在运行:回滚、删除、分支需先停止。',
|
|
169
|
+
'panel.revert': '回滚到此条',
|
|
170
|
+
'panel.revertHint': '包含此条及其后的可见消息',
|
|
171
|
+
'panel.delete': '仅删除此条',
|
|
172
|
+
'panel.deleteHint': '只遮蔽这一条,其他消息不动',
|
|
173
|
+
'panel.branch': '从此条分支',
|
|
174
|
+
'panel.branchHint': '复制到此条为止的内容为新会话',
|
|
175
|
+
'panel.branchDone': '分支已创建:{id}',
|
|
176
|
+
'panel.openNew': '打开新会话',
|
|
177
|
+
'panel.revertDone': '回滚已生效',
|
|
178
|
+
'panel.deleteDone': '删除已生效',
|
|
179
|
+
'panel.count': '共 {n} 条 · 可见 {v} · 已回滚 {s}',
|
|
180
|
+
'panel.deleteSession': '删除整条会话',
|
|
181
|
+
'panel.deleteSessionHint': '移入回收站,7 天内可恢复。请输入标题确认:',
|
|
182
|
+
'panel.cancel': '取消',
|
|
183
|
+
'panel.confirmDelete': '确认删除',
|
|
184
|
+
'panel.deleting': '删除中…',
|
|
185
|
+
'panel.deleted': '会话已移入回收站',
|
|
186
|
+
'panel.trashEmpty': '回收站是空的。',
|
|
187
|
+
'panel.untitled': '未命名会话',
|
|
188
|
+
'panel.restore': '恢复',
|
|
189
|
+
'panel.purge': '永久删除',
|
|
190
|
+
'panel.purgeConfirm': '再点一次永久删除',
|
|
191
|
+
'panel.restored': '会话已恢复',
|
|
192
|
+
'panel.purged': '回收站条目已永久删除',
|
|
193
|
+
'panel.expires': '到期',
|
|
194
|
+
'panel.busy': '处理中…',
|
|
195
|
+
'menu.ops': '消息操作',
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const enDict = {
|
|
199
|
+
'button.title': 'Message ops (revert / delete / branch)',
|
|
200
|
+
'dialog.title': 'Message ops',
|
|
201
|
+
'dialog.cancel': 'Close',
|
|
202
|
+
'dialog.loading': 'Loading messages…',
|
|
203
|
+
'dialog.loadFail': 'Failed to load messages: ',
|
|
204
|
+
'dialog.empty': 'No operable messages in this session.',
|
|
205
|
+
'dialog.session': 'Session: ',
|
|
206
|
+
'dialog.runningWarn': '⚠ Session is running: stop it before revert / delete / branch.',
|
|
207
|
+
'dialog.pick': 'Pick a message:',
|
|
208
|
+
'dialog.pickFirst': 'Step 1 · pick a message below, then choose an operation here.',
|
|
209
|
+
'dialog.pickedAs': 'Selected #{seq} · {text}',
|
|
210
|
+
'dialog.invisible': ' (shadowed, log-only)',
|
|
211
|
+
'dialog.showMore': 'Show more ({n} older)',
|
|
212
|
+
'dialog.more': '…(showing latest 200 of {n})',
|
|
213
|
+
'op.revert': 'Revert: remove this message and everything after',
|
|
214
|
+
'op.revertDesc': 'Shadows the picked message and all later visible content; the log stays append-only so context can be restored by re-sending.',
|
|
215
|
+
'op.delete': 'Delete only this message',
|
|
216
|
+
'op.deleteDesc': 'Shadows only the picked message; everything else stays.',
|
|
217
|
+
'op.branch': 'Branch into a new session from here',
|
|
218
|
+
'op.branchDesc': 'Copies everything up to and including the picked message into a new session (original untouched, non-destructive).',
|
|
219
|
+
'slot.revert': 'Revert to here',
|
|
220
|
+
'slot.revertUnmatched': 'Cannot locate this message seq in the log (usually the model-written summary drifted too far from the rendered text) — use the Message ops dialog instead.',
|
|
221
|
+
'toast.openNew': 'Open new session',
|
|
222
|
+
'fork.official': 'Branched (official fork): new session {id}',
|
|
223
|
+
'fork.unavailable': 'Built-in branch unavailable — usually because the session is running. Stop it and retry. Branching is provided by the dsh built-in session/fork.',
|
|
224
|
+
'ack.revert': 'I understand: revert shadows the picked message and everything after it',
|
|
225
|
+
'ack.delete': 'I understand: this message will be shadowed (kept in the log)',
|
|
226
|
+
'confirm.revert': 'Revert',
|
|
227
|
+
'confirm.delete': 'Delete',
|
|
228
|
+
'confirm.branch': 'Branch',
|
|
229
|
+
'busy.revert': 'Reverting…',
|
|
230
|
+
'busy.delete': 'Deleting…',
|
|
231
|
+
'busy.branch': 'Branching…',
|
|
232
|
+
'done.restore': 'Restored: replayed {n}, skipped non-replayable {s}',
|
|
233
|
+
'done.revert': 'Reverted; reload to apply',
|
|
234
|
+
'done.delete': 'Deleted; reload to apply',
|
|
235
|
+
'action.reload': 'Reload page',
|
|
236
|
+
'errorPrefix': 'Operation failed: ',
|
|
237
|
+
'dock.title': '{n} rolled back messages',
|
|
238
|
+
'dock.expand': 'Expand revert list',
|
|
239
|
+
'dock.collapse': 'Collapse revert list',
|
|
240
|
+
'dock.shadowedN': '{n} shadowed',
|
|
241
|
+
'dock.restore': 'Restore',
|
|
242
|
+
'dock.restoreHint': 'Restores by replay: messages appear at the end of the session, not in their original place',
|
|
243
|
+
'dock.restoring': 'Restoring…',
|
|
244
|
+
'slot.quote': 'Quote to composer',
|
|
245
|
+
'quote.done': 'Quoted into composer',
|
|
246
|
+
'quote.unavailable': 'Quote unavailable (composer locked or no full text)',
|
|
247
|
+
'undo.label': 'Undo last message',
|
|
248
|
+
'undo.desc': 'Remove the last user message and put its original text back into the composer',
|
|
249
|
+
'undo.done.undo': 'Undid the last message',
|
|
250
|
+
'undo.done.redo': 'Restored the last reverted message',
|
|
251
|
+
'undo.none': 'No message to undo',
|
|
252
|
+
'undo.none.redo': 'No revert to redo',
|
|
253
|
+
'redo.label': 'Restore the last reverted message',
|
|
254
|
+
'redo.desc': 'Put the reverted message back into the conversation',
|
|
255
|
+
'panel.messages': 'Messages',
|
|
256
|
+
'panel.trash': 'Trash',
|
|
257
|
+
'panel.search': 'Search this session',
|
|
258
|
+
'panel.searchHint': 'Search full text, not just snippets',
|
|
259
|
+
'panel.all': 'All',
|
|
260
|
+
'panel.user': 'User',
|
|
261
|
+
'panel.assistant': 'Assistant',
|
|
262
|
+
'panel.tool': 'Tool',
|
|
263
|
+
'panel.includeShadowed': 'Include shadowed',
|
|
264
|
+
'panel.loading': 'Loading…',
|
|
265
|
+
'panel.loadFail': 'Failed to load: ',
|
|
266
|
+
'panel.searchFail': 'Search failed: ',
|
|
267
|
+
'panel.empty': 'No messages to show.',
|
|
268
|
+
'panel.noMatch': 'No matches.',
|
|
269
|
+
'panel.selectHint': 'Pick a message to see its full text and actions.',
|
|
270
|
+
'panel.detailLoading': 'Loading full text…',
|
|
271
|
+
'panel.shadowed': 'reverted',
|
|
272
|
+
'panel.running': 'Session is running: stop it before revert, delete, or branch.',
|
|
273
|
+
'panel.revert': 'Revert to here',
|
|
274
|
+
'panel.revertHint': 'Includes this message and all visible messages after it',
|
|
275
|
+
'panel.delete': 'Delete only this',
|
|
276
|
+
'panel.deleteHint': 'Shadows only this message',
|
|
277
|
+
'panel.branch': 'Branch from here',
|
|
278
|
+
'panel.branchHint': 'Copy everything through this message into a new session',
|
|
279
|
+
'panel.branchDone': 'Branched: {id}',
|
|
280
|
+
'panel.openNew': 'Open new session',
|
|
281
|
+
'panel.revertDone': 'Revert applied',
|
|
282
|
+
'panel.deleteDone': 'Delete applied',
|
|
283
|
+
'panel.count': 'Total {n} · visible {v} · shadowed {s}',
|
|
284
|
+
'panel.deleteSession': 'Delete whole session',
|
|
285
|
+
'panel.deleteSessionHint': 'Moves it to trash for 7 days. Type the title to confirm:',
|
|
286
|
+
'panel.cancel': 'Cancel',
|
|
287
|
+
'panel.confirmDelete': 'Delete',
|
|
288
|
+
'panel.deleting': 'Deleting…',
|
|
289
|
+
'panel.deleted': 'Session moved to trash',
|
|
290
|
+
'panel.trashEmpty': 'Trash is empty.',
|
|
291
|
+
'panel.untitled': 'Untitled session',
|
|
292
|
+
'panel.restore': 'Restore',
|
|
293
|
+
'panel.purge': 'Delete forever',
|
|
294
|
+
'panel.purgeConfirm': 'Click again to delete forever',
|
|
295
|
+
'panel.restored': 'Session restored',
|
|
296
|
+
'panel.purged': 'Trash entry permanently deleted',
|
|
297
|
+
'panel.expires': 'expires',
|
|
298
|
+
'panel.busy': 'Working…',
|
|
299
|
+
'menu.ops': 'Message ops',
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
var __locale = null
|
|
303
|
+
var __sessionsSvc = null
|
|
304
|
+
var __inputActions = null // 0.4.0: 从 session 槽捕获(InputActions.setDraft → composer 回填)
|
|
305
|
+
var __uiWorkspace = null
|
|
306
|
+
var __currentSessionId = null
|
|
307
|
+
// 0.9.1:视图重建后刷新 transcript 可见性的钩子。
|
|
308
|
+
// 之前 restore 路径里直接调 scan(),但 scan 定义在注入器的闭包里,
|
|
309
|
+
// dock 组件根本取不到 —— `typeof scan === 'function'` 静默跳过了,
|
|
310
|
+
// 结果「恢复后消息回来了但仍被藏 / dock 计数不变」,必须重载页面才对。
|
|
311
|
+
var __refreshSurface = null
|
|
312
|
+
// 0.9.3:注入器闭包内 rowsCache 的只读出口。
|
|
313
|
+
// rowsCache 声明在 installUserRevertInjector() 内(闭包私有),而 /undo 的
|
|
314
|
+
// onPick 在模块级作用域 —— 直接引用会 ReferenceError,表现为点菜单毫无反应。
|
|
315
|
+
var __peekRowsCache = null // 0.6.0:会话视图会话 id(RevertDock/MsgSlotActions 刷新)
|
|
316
|
+
// messageId→seq 索引缓存(每会话一次拉取;0.3.0 assistant-actions 槽用)
|
|
317
|
+
var __seqIndexCache = new Map()
|
|
318
|
+
// messageId→全文缓存(0.5.0 消息引用用)
|
|
319
|
+
var __fullTextCache = new Map()
|
|
320
|
+
|
|
321
|
+
function localeFallbackLang() {
|
|
322
|
+
if (typeof navigator === 'undefined') return 'zh'
|
|
323
|
+
for (const tag of (navigator.languages || []).concat([navigator.language])) {
|
|
324
|
+
const primary = String(tag || '').toLowerCase().split('-')[0]
|
|
325
|
+
if (primary === 'zh' || primary === 'en') return primary
|
|
326
|
+
}
|
|
327
|
+
return 'zh'
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function __t(key, vars) {
|
|
331
|
+
let text = key
|
|
332
|
+
if (__locale && typeof __locale.translate === 'function') {
|
|
333
|
+
const translated = __locale.translate(NS, key)
|
|
334
|
+
if (typeof translated === 'string' && translated !== key) text = translated
|
|
335
|
+
}
|
|
336
|
+
if (text === key) text = (localeFallbackLang() === 'en' ? enDict : zhDict)[key] || key
|
|
337
|
+
if (vars) {
|
|
338
|
+
for (const k of Object.keys(vars)) text = text.split('{' + k + '}').join(String(vars[k]))
|
|
339
|
+
}
|
|
340
|
+
return text
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function useLocaleRevision() {
|
|
344
|
+
const [, setRev] = useState(0)
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
if (!__locale || typeof __locale.subscribe !== 'function') return undefined
|
|
347
|
+
return __locale.subscribe(() => setRev((v) => v + 1))
|
|
348
|
+
}, [])
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// --- styles(仅 --dsw-* 主题令牌) ----------------------------------------
|
|
352
|
+
const btnStyle = {
|
|
353
|
+
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
354
|
+
width: 28, height: 28, padding: 0, border: 'none', borderRadius: 6,
|
|
355
|
+
background: 'transparent',
|
|
356
|
+
color: 'var(--dsw-alias-label-tertiary, #8a8a8e)', cursor: 'pointer', flex: 'none',
|
|
357
|
+
}
|
|
358
|
+
const metaStyle = {
|
|
359
|
+
color: 'var(--dsw-alias-label-secondary, #8a8a8e)', fontSize: 13,
|
|
360
|
+
lineHeight: '20px', margin: '0 0 8px', overflow: 'hidden', textOverflow: 'ellipsis',
|
|
361
|
+
}
|
|
362
|
+
const warnStyle = {
|
|
363
|
+
color: 'var(--dsw-alias-state-warn-primary, #f5a524)', fontSize: 13,
|
|
364
|
+
lineHeight: '20px', margin: '0 0 10px',
|
|
365
|
+
}
|
|
366
|
+
const errStyle = {
|
|
367
|
+
color: 'var(--dsw-alias-state-error-primary, #e5484d)', fontSize: 12,
|
|
368
|
+
lineHeight: '16px', marginTop: 8,
|
|
369
|
+
}
|
|
370
|
+
const statusStyle = {
|
|
371
|
+
color: 'var(--dsw-alias-label-secondary, #8a8a8e)', fontSize: 12,
|
|
372
|
+
lineHeight: '16px', marginTop: 8,
|
|
373
|
+
}
|
|
374
|
+
const listStyle = {
|
|
375
|
+
maxHeight: 260, overflowY: 'auto', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.35))',
|
|
376
|
+
borderRadius: 8, margin: '8px 0 4px', padding: '4px 0',
|
|
377
|
+
}
|
|
378
|
+
const rowStyle = {
|
|
379
|
+
display: 'flex', alignItems: 'flex-start', gap: 8,
|
|
380
|
+
padding: '4px 10px', fontSize: 13, lineHeight: '18px', cursor: 'pointer',
|
|
381
|
+
}
|
|
382
|
+
const descStyle = {
|
|
383
|
+
color: 'var(--dsw-alias-label-secondary, #8a8a8e)', fontSize: 12,
|
|
384
|
+
lineHeight: '17px', margin: '6px 0 0',
|
|
385
|
+
}
|
|
386
|
+
const cancelBtnStyle = {
|
|
387
|
+
padding: '6px 14px', borderRadius: 8,
|
|
388
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4))',
|
|
389
|
+
background: 'transparent', color: 'var(--dsw-alias-label-primary, inherit)',
|
|
390
|
+
fontSize: 13, cursor: 'pointer', marginRight: 8,
|
|
391
|
+
}
|
|
392
|
+
const primaryBtnStyle = {
|
|
393
|
+
padding: '6px 14px', borderRadius: 8, border: 'none',
|
|
394
|
+
background: 'var(--dsw-alias-brand-primary, #4d6bfe)', color: '#fff',
|
|
395
|
+
fontSize: 13, cursor: 'pointer',
|
|
396
|
+
}
|
|
397
|
+
const dangerBtnStyle = {
|
|
398
|
+
padding: '6px 14px', borderRadius: 8,
|
|
399
|
+
border: '1px solid var(--dsw-alias-state-error-primary, #e5484d)',
|
|
400
|
+
background: 'var(--dsw-alias-state-error-primary, #e5484d)',
|
|
401
|
+
color: '#fff', fontSize: 13, cursor: 'pointer',
|
|
402
|
+
}
|
|
403
|
+
const optStyle = {
|
|
404
|
+
display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, lineHeight: '20px',
|
|
405
|
+
color: 'var(--dsw-alias-label-primary, inherit)', marginTop: 10,
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 头部按钮图标:三叉分支(git-branch 风格,16x16)。
|
|
409
|
+
const BRANCH_PATH = 'M5 2.5a2 2 0 1 1-.9 3.78v3.44a2 2 0 1 1-1.2 0V6.28A2 2 0 1 1 5 2.5Zm0 1.2a.8.8 0 1 0 0 1.6.8.8 0 0 0 0-1.6ZM3.5 11.5a.8.8 0 1 0 1.6 0 .8.8 0 0 0-1.6 0ZM12.5 2.5a2 2 0 0 1 .6 3.9v.85c0 1.9-1.54 3.44-3.44 3.44H8.03a2 2 0 1 1 0-1.2h1.63a2.24 2.24 0 0 0 2.24-2.24V6.4a2 2 0 0 1 .6-3.9Zm0 1.2a.8.8 0 1 0 0 1.6.8.8 0 0 0 0-1.6Z'
|
|
410
|
+
|
|
411
|
+
function BranchIcon() {
|
|
412
|
+
return React.createElement('svg', {
|
|
413
|
+
width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
|
|
414
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
415
|
+
}, React.createElement('path', { d: BRANCH_PATH, fill: 'currentColor' }))
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// --- 共享对话框 -------------------------------------------------------------
|
|
419
|
+
// 模块级缓存最近一次 target,事件驱动打开。
|
|
420
|
+
const MAX_RENDER = 200
|
|
421
|
+
const PAGE_SIZE = 50 // 低成本分批渲染:先渲 50 条,按钮渐进展开
|
|
422
|
+
|
|
423
|
+
function OpsDialog(props) {
|
|
424
|
+
const t = (props && props.t) || __t
|
|
425
|
+
useLocaleRevision()
|
|
426
|
+
const [target, setTarget] = useState(null) // {sessionId, title, running}
|
|
427
|
+
const [state, setState] = useState('idle') // idle|loading|ready|busy|done
|
|
428
|
+
const [messages, setMessages] = useState([])
|
|
429
|
+
const [sessionMeta, setSessionMeta] = useState(null)
|
|
430
|
+
const [picked, setPicked] = useState(null) // seq
|
|
431
|
+
const [mode, setMode] = useState(null) // revert|delete|branch|restore
|
|
432
|
+
const [childId, setChildId] = useState(null) // 官方 fork 成功后的子会话 id
|
|
433
|
+
const [acknowledged, setAcknowledged] = useState(false)
|
|
434
|
+
const [busyMsg, setBusyMsg] = useState('')
|
|
435
|
+
const [renderLimit, setRenderLimit] = useState(PAGE_SIZE) // 已展开的渲染条数
|
|
436
|
+
const [doneMsg, setDoneMsg] = useState('')
|
|
437
|
+
const [error, setError] = useState(null)
|
|
438
|
+
|
|
439
|
+
useEffect(() => {
|
|
440
|
+
const handler = (e) => {
|
|
441
|
+
const d = e && e.detail ? e.detail : {}
|
|
442
|
+
setTarget({ sessionId: d.sessionId || null, title: d.title || null, running: d.running === true })
|
|
443
|
+
setState('loading')
|
|
444
|
+
setMessages([]); setSessionMeta(null)
|
|
445
|
+
// 槽按钮预置:携带 seq(picked)与 mode 直接进入确认态
|
|
446
|
+
setPicked(typeof d.seq === 'number' ? d.seq : null)
|
|
447
|
+
setMode(typeof d.mode === 'string' ? d.mode : null)
|
|
448
|
+
setAcknowledged(false); setError(null); setDoneMsg('')
|
|
449
|
+
setRenderLimit(PAGE_SIZE)
|
|
450
|
+
}
|
|
451
|
+
window.addEventListener(EVENT, handler)
|
|
452
|
+
return () => window.removeEventListener(EVENT, handler)
|
|
453
|
+
}, [])
|
|
454
|
+
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
if (state !== 'loading' || !target || !target.sessionId) return
|
|
457
|
+
let cancelled = false
|
|
458
|
+
fetchMessagesJson('/api/message-ops/messages?sessionId=' + encodeURIComponent(target.sessionId))
|
|
459
|
+
.then((data) => {
|
|
460
|
+
if (cancelled) return
|
|
461
|
+
if (!data || !data.ok) throw new Error(data && data.error ? data.error : `HTTP ${data && data.status}`)
|
|
462
|
+
setMessages(data.messages || [])
|
|
463
|
+
setSessionMeta(data.session || null)
|
|
464
|
+
if (data.running === true) setTarget((prev) => ({ ...(prev || {}), running: true }))
|
|
465
|
+
setState('ready')
|
|
466
|
+
})
|
|
467
|
+
.catch((reason) => {
|
|
468
|
+
if (cancelled) return
|
|
469
|
+
setError(t('dialog.loadFail') + (reason && reason.message ? reason.message : String(reason)))
|
|
470
|
+
setState('ready')
|
|
471
|
+
})
|
|
472
|
+
return () => { cancelled = true }
|
|
473
|
+
}, [state, target, t])
|
|
474
|
+
|
|
475
|
+
const close = useCallback(() => {
|
|
476
|
+
if (state === 'busy') return
|
|
477
|
+
setTarget(null); setError(null)
|
|
478
|
+
}, [state])
|
|
479
|
+
|
|
480
|
+
const pick = useCallback((m) => {
|
|
481
|
+
if (state === 'busy') return
|
|
482
|
+
setPicked(m.seq)
|
|
483
|
+
setMode(null); setAcknowledged(false); setError(null)
|
|
484
|
+
}, [state])
|
|
485
|
+
|
|
486
|
+
const chooseMode = useCallback((m) => {
|
|
487
|
+
if (picked == null || state === 'busy') return
|
|
488
|
+
setMode(m); setAcknowledged(false); setError(null)
|
|
489
|
+
}, [picked, state])
|
|
490
|
+
|
|
491
|
+
const run = useCallback(() => {
|
|
492
|
+
if (state === 'busy' || picked == null || !mode) return
|
|
493
|
+
if (mode !== 'branch' && !acknowledged) return
|
|
494
|
+
setBusyMsg(t('busy.' + (mode === 'revert' || mode === 'delete' ? mode : 'branch')))
|
|
495
|
+
setState('busy'); setError(null)
|
|
496
|
+
if (mode === 'branch') {
|
|
497
|
+
// 0.9.0:分支**只走官方** sessions.fork({atSeq});失败即拒绝(源会话多半在
|
|
498
|
+
// 运行),不再回落写盘 —— 被删的磁盘 applyBranch 语义复盘见 README「为什么不重复造轮子」。
|
|
499
|
+
const forkPath = pickForkPath(__sessionsSvc)
|
|
500
|
+
if (forkPath.kind !== 'official') {
|
|
501
|
+
setError(t('fork.unavailable'))
|
|
502
|
+
setBusyMsg('')
|
|
503
|
+
setState('error')
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
forkPath.fork({ sessionId: target.sessionId, atSeq: picked, increaseTitle: true })
|
|
507
|
+
.then((childId) => {
|
|
508
|
+
setDoneMsg(t('fork.official', { id: String(childId || '') }))
|
|
509
|
+
setBusyMsg('')
|
|
510
|
+
setChildId(childId || null)
|
|
511
|
+
setState('done')
|
|
512
|
+
notifyDone(t('fork.official', { id: String(childId || '') }))
|
|
513
|
+
})
|
|
514
|
+
.catch((reason) => {
|
|
515
|
+
setError(String((reason && reason.message) || reason || t('fork.unavailable')))
|
|
516
|
+
setBusyMsg('')
|
|
517
|
+
setState('error')
|
|
518
|
+
})
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
const path = mode
|
|
522
|
+
const body = { sessionId: target.sessionId, seq: picked }
|
|
523
|
+
Promise.resolve()
|
|
524
|
+
.then(() => fetch('/api/message-ops/' + path, {
|
|
525
|
+
method: 'POST',
|
|
526
|
+
headers: { 'content-type': 'application/json' },
|
|
527
|
+
body: JSON.stringify(body),
|
|
528
|
+
}))
|
|
529
|
+
.then(async (res) => {
|
|
530
|
+
let data = {}
|
|
531
|
+
try { data = await res.json() } catch { /* keep {} */ }
|
|
532
|
+
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
533
|
+
// S6 修复(G-M1):不再 900ms 裸 location.reload。成功走 devkit
|
|
534
|
+
// 标准 toast(无 devkit 时降级为对话框内 doneMsg),并提供手动
|
|
535
|
+
// 「刷新页面」按钮。
|
|
536
|
+
// 0.9.7:补回 ec862d6(0.9.0) 误删的 `const okMsg =`——留下悬挂的
|
|
537
|
+
// `? :` 会让成功路径抛 ReferenceError 落进 .catch(用户看到「操作失败」,
|
|
538
|
+
// notifyDone/emitChanged/openSession/回填全不执行),失败路径则恒走
|
|
539
|
+
// `?` 分支把成功文案当错误抛出。对话框 mode 只有 revert|delete|branch,
|
|
540
|
+
// branch 在上面已 return,故 done.restore 分支在此不可达,一并删掉。
|
|
541
|
+
const okMsg = mode === 'revert' ? t('done.revert') : t('done.delete')
|
|
542
|
+
// 0.4.0 composer 回填(opencode 式):回滚用户消息 → 原文回填输入框,「编辑重发」零按钮
|
|
543
|
+
if (mode === 'revert' && pickedMsg && pickedMsg.role === 'user' && pickedMsg.fullText
|
|
544
|
+
&& __inputActions && typeof __inputActions.setDraft === 'function') {
|
|
545
|
+
try { __inputActions.setDraft(pickedMsg.fullText) } catch { /* 回填失败不阻断成功反馈 */ }
|
|
546
|
+
}
|
|
547
|
+
setDoneMsg(okMsg)
|
|
548
|
+
setBusyMsg('')
|
|
549
|
+
setState('done')
|
|
550
|
+
notifyDone(okMsg)
|
|
551
|
+
emitChanged()
|
|
552
|
+
// 0.5.7:store-miss 的磁盘追加没有 live 投影事件 —— 成功后强制
|
|
553
|
+
// 重建视图(与 restoreRow 同款 openSession 模式),遮蔽即刻可见。
|
|
554
|
+
try {
|
|
555
|
+
if (__uiWorkspace && typeof __uiWorkspace.openSession === 'function') __uiWorkspace.openSession(target.sessionId)
|
|
556
|
+
} catch { /* 视图重建失败不阻断成功反馈 */ }
|
|
557
|
+
})
|
|
558
|
+
.catch((reason) => {
|
|
559
|
+
setBusyMsg('')
|
|
560
|
+
setState('ready')
|
|
561
|
+
setError(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)))
|
|
562
|
+
})
|
|
563
|
+
}, [state, picked, mode, acknowledged, target, t])
|
|
564
|
+
|
|
565
|
+
if (!target) return null
|
|
566
|
+
|
|
567
|
+
// 0.6.0:system prompt 行不可作为回滚/删除/分支目标(引擎拒之以 node0 守卫,
|
|
568
|
+
// 但不该给用户一个必然500的选项)——列表直接排除 role=system。
|
|
569
|
+
const visible = messages.filter((m) => m.visible !== false && m.role !== 'system')
|
|
570
|
+
const shownBase = visible.slice(-MAX_RENDER)
|
|
571
|
+
const hiddenCount = visible.length - shownBase.length
|
|
572
|
+
// 分批渲染(评审 M1):只渲染 renderLimit 条,更早的留给「显示更多」
|
|
573
|
+
// 渐进展开,避免 200 行 radio 列表一次进 DOM。
|
|
574
|
+
const shown = shownBase.slice(-renderLimit)
|
|
575
|
+
const pagedCount = shownBase.length - shown.length
|
|
576
|
+
const pickedMsg = picked != null ? messages.find((m) => m.seq === picked) : null
|
|
577
|
+
|
|
578
|
+
// 0.9.0 可发现性:操作区固定在**消息列表之前**。
|
|
579
|
+
// 原先它在列表之后,而列表是 260px 滚动 + 分批渲染 50 条/页 ——
|
|
580
|
+
// 未选中消息时三个 radio 完全不在视野内,用户以为「只有列表、没有操作」。
|
|
581
|
+
// 未选中时给明确指引,而不是空着。
|
|
582
|
+
const opsPanel = pickedMsg
|
|
583
|
+
? React.createElement('div', { key: 'ops', style: { margin: '8px 0 0' } },
|
|
584
|
+
React.createElement('div', { style: { ...metaStyle, margin: '0 0 4px' } },
|
|
585
|
+
t('dialog.pickedAs', { seq: String(pickedMsg.seq), text: (pickedMsg.snippet || '').slice(0, 60) })),
|
|
586
|
+
['revert', 'delete', 'branch'].map((m, i) => React.createElement('div', { key: m, style: { marginTop: i === 0 ? 6 : 4 } },
|
|
587
|
+
React.createElement('label', { style: optStyle },
|
|
588
|
+
React.createElement('input', {
|
|
589
|
+
type: 'radio', name: 'dsh-message-ops-mode', checked: mode === m,
|
|
590
|
+
onChange: () => chooseMode(m), disabled: state === 'busy',
|
|
591
|
+
}),
|
|
592
|
+
t('op.' + m)),
|
|
593
|
+
mode === m ? React.createElement('div', { style: descStyle }, t('op.' + m + 'Desc')) : null,
|
|
594
|
+
)),
|
|
595
|
+
mode && mode !== 'branch'
|
|
596
|
+
? React.createElement('label', { key: 'ack', style: optStyle },
|
|
597
|
+
React.createElement('input', {
|
|
598
|
+
type: 'checkbox', checked: acknowledged, disabled: state === 'busy',
|
|
599
|
+
onChange: (e) => setAcknowledged(e.target.checked),
|
|
600
|
+
}),
|
|
601
|
+
t(mode === 'revert' ? 'ack.revert' : 'ack.delete'))
|
|
602
|
+
: null,
|
|
603
|
+
)
|
|
604
|
+
: React.createElement('div', { key: 'opshint', style: { ...metaStyle, margin: '8px 0 0', padding: '6px 8px', borderRadius: 6, background: 'var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.1))' } },
|
|
605
|
+
t('dialog.pickFirst'))
|
|
606
|
+
|
|
607
|
+
let body
|
|
608
|
+
if (state === 'loading') {
|
|
609
|
+
body = React.createElement('div', { key: 'load', style: statusStyle }, t('dialog.loading'))
|
|
610
|
+
} else if (!messages.length) {
|
|
611
|
+
body = React.createElement('div', { key: 'empty', style: statusStyle }, t('dialog.empty'))
|
|
612
|
+
} else {
|
|
613
|
+
body = React.createElement(React.Fragment, null, [
|
|
614
|
+
target.running ? React.createElement('div', { key: 'warn', style: warnStyle }, t('dialog.runningWarn')) : null,
|
|
615
|
+
opsPanel,
|
|
616
|
+
React.createElement('div', { key: 'pick', style: metaStyle }, t('dialog.pick')),
|
|
617
|
+
React.createElement('div', { key: 'list', style: listStyle },
|
|
618
|
+
hiddenCount > 0
|
|
619
|
+
? React.createElement('div', { key: 'more', style: { ...rowStyle, cursor: 'default', color: 'var(--dsw-alias-label-secondary,#8a8a8e)' } },
|
|
620
|
+
t('dialog.more', { n: String(visible.length) }))
|
|
621
|
+
: null,
|
|
622
|
+
pagedCount > 0
|
|
623
|
+
? React.createElement('button', {
|
|
624
|
+
key: 'show-more', type: 'button',
|
|
625
|
+
style: { ...rowStyle, border: 'none', width: '100%', color: 'var(--dsw-alias-label-secondary,#8a8a8e)', cursor: 'pointer' },
|
|
626
|
+
onClick: () => setRenderLimit((l) => Math.min(l + PAGE_SIZE, MAX_RENDER)),
|
|
627
|
+
}, t('dialog.showMore', { n: String(pagedCount) }))
|
|
628
|
+
: null,
|
|
629
|
+
shown.map((m, i) => React.createElement('label', {
|
|
630
|
+
key: m.seq + '-' + i, style: {
|
|
631
|
+
...rowStyle,
|
|
632
|
+
background: picked === m.seq ? 'var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14))' : 'transparent',
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
React.createElement('input', {
|
|
636
|
+
type: 'radio', name: 'dsh-message-ops-pick', checked: picked === m.seq,
|
|
637
|
+
onChange: () => pick(m), disabled: state === 'busy',
|
|
638
|
+
}),
|
|
639
|
+
React.createElement('span', {
|
|
640
|
+
style: {
|
|
641
|
+
flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
642
|
+
color: m.visible === false ? 'var(--dsw-alias-label-tertiary,#b0b0b4)' : 'inherit',
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
'#' + m.seq + ' ' + (m.role || '') + ' · ' + (m.snippet || '') + (m.visible === false ? t('dialog.invisible') : '')),
|
|
646
|
+
)),
|
|
647
|
+
),
|
|
648
|
+
// 0.9.7:删掉列表**之后**的旧 opsPanel——0.9.0 把操作区挪到列表之前时
|
|
649
|
+
// 没删旧位置,两组同名 radio(name='dsh-message-ops-mode')互抢,
|
|
650
|
+
// checked 状态在两份 DOM 之间互相顶掉。
|
|
651
|
+
])
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const canRun = state !== 'busy' && state !== 'loading' && picked != null && mode != null
|
|
655
|
+
&& (mode === 'branch' || acknowledged)
|
|
656
|
+
const confirmLabel = busyMsg || (mode ? t('confirm.' + mode) : t('dialog.cancel'))
|
|
657
|
+
|
|
658
|
+
return React.createElement(Modal, {
|
|
659
|
+
open: true,
|
|
660
|
+
onClose: close,
|
|
661
|
+
title: t('dialog.title'),
|
|
662
|
+
closeLabel: t('dialog.cancel'),
|
|
663
|
+
description: t('dialog.session') + (target.title || target.sessionId || ''),
|
|
664
|
+
footer: [
|
|
665
|
+
React.createElement('button', {
|
|
666
|
+
key: 'cancel', type: 'button', disabled: state === 'busy',
|
|
667
|
+
onClick: close, style: { ...cancelBtnStyle, ...(state === 'busy' ? { opacity: 0.5, cursor: 'default' } : {}) },
|
|
668
|
+
}, t('dialog.cancel')),
|
|
669
|
+
React.createElement('button', {
|
|
670
|
+
key: 'confirm', type: 'button', disabled: !canRun,
|
|
671
|
+
onClick: run,
|
|
672
|
+
style: {
|
|
673
|
+
...(mode === 'delete' || mode === 'revert' ? dangerBtnStyle : primaryBtnStyle),
|
|
674
|
+
...(!canRun ? { opacity: 0.5, cursor: 'default' } : {}),
|
|
675
|
+
},
|
|
676
|
+
}, confirmLabel),
|
|
677
|
+
...(state === 'done' && (mode === 'revert' || mode === 'delete') ? [React.createElement('button', {
|
|
678
|
+
key: 'reload', type: 'button',
|
|
679
|
+
onClick: () => { try { window.location.reload() } catch { /* non-browser guard */ } },
|
|
680
|
+
style: primaryBtnStyle,
|
|
681
|
+
}, t('action.reload'))] : []),
|
|
682
|
+
...(state === 'done' && mode === 'branch' && childId ? [React.createElement('button', {
|
|
683
|
+
key: 'open-new', type: 'button',
|
|
684
|
+
onClick: () => {
|
|
685
|
+
try {
|
|
686
|
+
// 0.2.0+:uiWorkspace.openSession 是宿主唯一打开通道
|
|
687
|
+
if (__uiWorkspace && typeof __uiWorkspace.openSession === 'function') __uiWorkspace.openSession(childId)
|
|
688
|
+
} catch { /* 打开失败不阻断 */ }
|
|
689
|
+
},
|
|
690
|
+
style: primaryBtnStyle,
|
|
691
|
+
}, t('toast.openNew'))] : []),
|
|
692
|
+
],
|
|
693
|
+
}, [
|
|
694
|
+
React.createElement('div', { key: 'meta', style: metaStyle },
|
|
695
|
+
t('dialog.session'), target.sessionId || ''),
|
|
696
|
+
body,
|
|
697
|
+
doneMsg ? React.createElement('div', { key: 'done', style: statusStyle }, doneMsg) : null,
|
|
698
|
+
error ? React.createElement('div', { key: 'err', style: errStyle, role: 'alert' }, error) : null,
|
|
699
|
+
])
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// --- A · 功能主义抽屉 -------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
const PANEL_ROW_H = 46
|
|
705
|
+
const PANEL_SEARCH_LIMIT = 100
|
|
706
|
+
|
|
707
|
+
function fmtClock(value) {
|
|
708
|
+
if (typeof value !== 'number') return ''
|
|
709
|
+
try {
|
|
710
|
+
return new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
711
|
+
} catch { return '' }
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function roleCode(role) {
|
|
715
|
+
if (role === 'user') return 'U'
|
|
716
|
+
if (role === 'assistant') return 'A'
|
|
717
|
+
if (role === 'tool') return 'T'
|
|
718
|
+
return 'S'
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function ensurePanelStyle() {
|
|
722
|
+
try {
|
|
723
|
+
if (document.getElementById('dsh-message-ops-panel-style')) return
|
|
724
|
+
const tag = document.createElement('style')
|
|
725
|
+
tag.id = 'dsh-message-ops-panel-style'
|
|
726
|
+
tag.textContent = [
|
|
727
|
+
'.mopsPanelScrim{position:fixed;inset:0;z-index:1200;background:rgba(15,17,21,.18);display:flex;justify-content:flex-end}',
|
|
728
|
+
'.mopsPanel{width:560px;max-width:100vw;height:100%;display:flex;flex-direction:column;background:var(--dsw-alias-bg-primary,#fff);color:var(--dsw-alias-label-primary,#0f1115);border-left:1px solid var(--dsw-alias-border-l2,#e6e6e6);box-shadow:-16px 0 48px rgba(15,17,21,.14);font-family:inherit;font-size:13px;line-height:1.45}',
|
|
729
|
+
'.mopsPanel *{box-sizing:border-box}',
|
|
730
|
+
'.mopsPanel button{font:inherit;color:inherit;border-radius:0}',
|
|
731
|
+
'.mopsPanelHead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:16px 18px 12px;border-bottom:1px solid var(--dsw-alias-border-l2,#e6e6e6)}',
|
|
732
|
+
'.mopsPanelKicker{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary,#8a8a8e)}',
|
|
733
|
+
'.mopsPanelTitle{font-size:15px;font-weight:600;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:420px}',
|
|
734
|
+
'.mopsPanelSub{font-size:11px;color:var(--dsw-alias-label-tertiary,#8a8a8e);margin-top:2px}',
|
|
735
|
+
'.mopsPanelClose{width:30px;height:30px;border:1px solid var(--dsw-alias-border-l2,#e6e6e6);background:transparent;cursor:pointer;font-size:16px;line-height:1;flex:none}',
|
|
736
|
+
'.mopsPanelClose:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(15,17,21,.06))}',
|
|
737
|
+
'.mopsTabs{display:flex;border-bottom:1px solid var(--dsw-alias-border-l2,#e6e6e6)}',
|
|
738
|
+
'.mopsTab{flex:1;padding:9px 12px;border:0;border-bottom:2px solid transparent;background:transparent;cursor:pointer;font-size:12px;color:var(--dsw-alias-label-secondary,#6b6b70)}',
|
|
739
|
+
'.mopsTab.on{border-bottom-color:var(--dsw-alias-brand-primary,#4d6bfe);color:var(--dsw-alias-label-primary,#0f1115);font-weight:600}',
|
|
740
|
+
'.mopsSearchRow{display:flex;gap:8px;padding:12px 18px 8px}',
|
|
741
|
+
'.mopsSearch{flex:1;min-width:0;height:32px;border:1px solid var(--dsw-alias-border-l2,#d8d8d8);background:transparent;color:inherit;padding:0 9px;font:inherit;font-size:12px;outline:none}',
|
|
742
|
+
'.mopsSearch:focus{border-color:var(--dsw-alias-brand-primary,#4d6bfe)}',
|
|
743
|
+
'.mopsChips{display:flex;gap:6px;padding:0 18px 10px;flex-wrap:wrap;align-items:center}',
|
|
744
|
+
'.mopsChip{height:26px;padding:0 9px;border:1px solid var(--dsw-alias-border-l2,#d8d8d8);background:transparent;cursor:pointer;font-size:11px;color:var(--dsw-alias-label-secondary,#6b6b70)}',
|
|
745
|
+
'.mopsChip.on{border-color:var(--dsw-alias-label-primary,#0f1115);background:var(--dsw-alias-label-primary,#0f1115);color:var(--dsw-alias-bg-primary,#fff)}',
|
|
746
|
+
'.mopsToggle{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--dsw-alias-label-secondary,#6b6b70);margin-left:auto;cursor:pointer}',
|
|
747
|
+
'.mopsToggle input{accent-color:var(--dsw-alias-brand-primary,#4d6bfe)}',
|
|
748
|
+
'.mopsList{flex:1;min-height:0;overflow-y:auto;position:relative;border-top:1px solid var(--dsw-alias-border-l1,#efefef)}',
|
|
749
|
+
'.mopsRow{position:absolute;left:0;right:0;display:grid;grid-template-columns:44px 26px minmax(0,1fr) 48px;align-items:center;gap:8px;width:100%;border:0;border-bottom:1px solid var(--dsw-alias-border-l1,#f0f0f0);background:transparent;text-align:left;cursor:pointer;padding:0 12px 0 18px;color:inherit}',
|
|
750
|
+
'.mopsRow:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(15,17,21,.045))}',
|
|
751
|
+
'.mopsRow.on{background:rgba(77,107,254,.08);box-shadow:inset 2px 0 0 var(--dsw-alias-brand-primary,#4d6bfe)}',
|
|
752
|
+
'.mopsRow.shadowed{opacity:.42}',
|
|
753
|
+
'.mopsSeq{font-variant-numeric:tabular-nums;font-size:10px;color:var(--dsw-alias-label-tertiary,#9a9aa0)}',
|
|
754
|
+
'.mopsRole{width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l2,#d8d8d8);font-size:9px;font-weight:600;color:var(--dsw-alias-label-secondary,#6b6b70)}',
|
|
755
|
+
'.mopsSnippet{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}',
|
|
756
|
+
'.mopsClock{font-size:10px;color:var(--dsw-alias-label-tertiary,#9a9aa0);text-align:right;font-variant-numeric:tabular-nums}',
|
|
757
|
+
'.mopsEmpty{padding:28px 18px;color:var(--dsw-alias-label-tertiary,#8a8a8e);font-size:12px}',
|
|
758
|
+
'.mopsDetail{flex:none;max-height:37%;overflow-y:auto;border-top:1px solid var(--dsw-alias-border-l2,#e6e6e6);padding:12px 18px 10px;background:var(--dsw-alias-bg-secondary,#fafafa)}',
|
|
759
|
+
'.mopsDetailMeta{display:flex;gap:8px;align-items:center;font-size:10px;color:var(--dsw-alias-label-tertiary,#8a8a8e);margin-bottom:6px}',
|
|
760
|
+
'.mopsDetailText{margin:0;white-space:pre-wrap;word-break:break-word;max-height:108px;overflow:auto;font-family:inherit;font-size:12px;line-height:1.55;color:var(--dsw-alias-label-primary,#0f1115)}',
|
|
761
|
+
'.mopsDetailHint{padding:10px 0 14px;color:var(--dsw-alias-label-tertiary,#8a8a8e);font-size:12px}',
|
|
762
|
+
'.mopsActions{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}',
|
|
763
|
+
'.mopsAction{height:30px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2,#cfcfcf);background:transparent;cursor:pointer;font-size:11px}',
|
|
764
|
+
'.mopsAction:hover:not(:disabled){border-color:var(--dsw-alias-label-primary,#0f1115)}',
|
|
765
|
+
'.mopsAction.primary{border-color:var(--dsw-alias-label-primary,#0f1115);background:var(--dsw-alias-label-primary,#0f1115);color:var(--dsw-alias-bg-primary,#fff)}',
|
|
766
|
+
'.mopsAction.danger{border-color:var(--dsw-alias-state-error-primary,#c63b32);color:var(--dsw-alias-state-error-primary,#c63b32)}',
|
|
767
|
+
'.mopsAction:disabled{opacity:.35;cursor:default}',
|
|
768
|
+
'.mopsPanelFoot{flex:none;display:flex;align-items:center;gap:10px;justify-content:space-between;padding:10px 18px;border-top:1px solid var(--dsw-alias-border-l2,#e6e6e6);background:var(--dsw-alias-bg-primary,#fff)}',
|
|
769
|
+
'.mopsCount{font-size:10px;color:var(--dsw-alias-label-tertiary,#8a8a8e)}',
|
|
770
|
+
'.mopsDanger{border:1px solid var(--dsw-alias-state-error-primary,#c63b32);background:transparent;color:var(--dsw-alias-state-error-primary,#c63b32);height:28px;padding:0 9px;cursor:pointer;font-size:11px}',
|
|
771
|
+
'.mopsDanger:hover{background:rgba(198,59,50,.06)}',
|
|
772
|
+
'.mopsBanner{padding:7px 18px;font-size:11px;border-bottom:1px solid var(--dsw-alias-border-l1,#efefef)}',
|
|
773
|
+
'.mopsBanner.warn{color:var(--dsw-alias-state-warn-primary,#9a6500);background:rgba(154,101,0,.06)}',
|
|
774
|
+
'.mopsBanner.err{color:var(--dsw-alias-state-error-primary,#c63b32);background:rgba(198,59,50,.06)}',
|
|
775
|
+
'.mopsBanner.ok{color:var(--dsw-alias-state-success-primary,#2f7d4a);background:rgba(47,125,74,.06)}',
|
|
776
|
+
'.mopsConfirm{position:absolute;inset:0;z-index:2;background:var(--dsw-alias-bg-primary,#fff);display:flex;flex-direction:column;justify-content:center;padding:28px 24px;gap:10px}',
|
|
777
|
+
'.mopsConfirm h3{margin:0;font-size:15px}',
|
|
778
|
+
'.mopsConfirm p{margin:0;font-size:12px;color:var(--dsw-alias-label-secondary,#6b6b70)}',
|
|
779
|
+
'.mopsConfirm input{height:34px;border:1px solid var(--dsw-alias-border-l2,#d8d8d8);background:transparent;color:inherit;padding:0 9px;font:inherit;font-size:13px}',
|
|
780
|
+
'.mopsConfirmRow{display:flex;gap:8px;margin-top:4px}',
|
|
781
|
+
'.mopsTrashItem{display:flex;align-items:center;gap:10px;padding:11px 18px;border-bottom:1px solid var(--dsw-alias-border-l1,#f0f0f0)}',
|
|
782
|
+
'.mopsTrashMain{flex:1;min-width:0}',
|
|
783
|
+
'.mopsTrashTitle{font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
784
|
+
'.mopsTrashMeta{font-size:10px;color:var(--dsw-alias-label-tertiary,#8a8a8e);margin-top:2px}',
|
|
785
|
+
'@media(max-width:760px){.mopsPanel{width:100vw;border-left:0}.mopsDetail{max-height:42%}}',
|
|
786
|
+
].join('')
|
|
787
|
+
document.head.appendChild(tag)
|
|
788
|
+
} catch { /* style failure must not break the panel */ }
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function OpsPanel(props) {
|
|
792
|
+
const t = (props && props.t) || __t
|
|
793
|
+
useLocaleRevision()
|
|
794
|
+
const [open, setOpen] = useState(false)
|
|
795
|
+
const [target, setTarget] = useState(null)
|
|
796
|
+
const [state, setState] = useState('idle')
|
|
797
|
+
const [messages, setMessages] = useState([])
|
|
798
|
+
const [query, setQuery] = useState('')
|
|
799
|
+
const [role, setRole] = useState('all')
|
|
800
|
+
const [includeShadowed, setIncludeShadowed] = useState(false)
|
|
801
|
+
const [picked, setPicked] = useState(null)
|
|
802
|
+
const [detail, setDetail] = useState('')
|
|
803
|
+
const [detailState, setDetailState] = useState('idle')
|
|
804
|
+
const [tab, setTab] = useState('messages')
|
|
805
|
+
const [hits, setHits] = useState(null)
|
|
806
|
+
const [searching, setSearching] = useState(false)
|
|
807
|
+
const [trash, setTrash] = useState([])
|
|
808
|
+
const [trashState, setTrashState] = useState('idle')
|
|
809
|
+
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
810
|
+
const [confirmTitle, setConfirmTitle] = useState('')
|
|
811
|
+
const [purgeId, setPurgeId] = useState(null)
|
|
812
|
+
const [childId, setChildId] = useState(null)
|
|
813
|
+
const [busy, setBusy] = useState('')
|
|
814
|
+
const [error, setError] = useState(null)
|
|
815
|
+
const [done, setDone] = useState('')
|
|
816
|
+
const [scrollTop, setScrollTop] = useState(0)
|
|
817
|
+
const [viewport, setViewport] = useState(480)
|
|
818
|
+
const listRef = useRef(null)
|
|
819
|
+
const searchRef = useRef(null)
|
|
820
|
+
const detailReq = useRef(0)
|
|
821
|
+
|
|
822
|
+
useEffect(() => {
|
|
823
|
+
ensurePanelStyle()
|
|
824
|
+
const handler = (e) => {
|
|
825
|
+
const d = e && e.detail ? e.detail : {}
|
|
826
|
+
if (!d.sessionId) return
|
|
827
|
+
setTarget({ sessionId: d.sessionId, title: d.title || null, running: d.running === true })
|
|
828
|
+
setOpen(true)
|
|
829
|
+
setState('loading')
|
|
830
|
+
setMessages([])
|
|
831
|
+
setHits(null)
|
|
832
|
+
setQuery('')
|
|
833
|
+
setRole('all')
|
|
834
|
+
setIncludeShadowed(false)
|
|
835
|
+
setPicked(null)
|
|
836
|
+
setDetail('')
|
|
837
|
+
setTab('messages')
|
|
838
|
+
setTrash([])
|
|
839
|
+
setConfirmDelete(false)
|
|
840
|
+
setConfirmTitle('')
|
|
841
|
+
setPurgeId(null)
|
|
842
|
+
setChildId(null)
|
|
843
|
+
setError(null)
|
|
844
|
+
setDone('')
|
|
845
|
+
setScrollTop(0)
|
|
846
|
+
}
|
|
847
|
+
window.addEventListener(EVENT, handler)
|
|
848
|
+
return () => window.removeEventListener(EVENT, handler)
|
|
849
|
+
}, [])
|
|
850
|
+
|
|
851
|
+
const sid = target && target.sessionId
|
|
852
|
+
|
|
853
|
+
const loadMessages = useCallback(async (quiet) => {
|
|
854
|
+
if (!sid) return
|
|
855
|
+
if (!quiet) setState('loading')
|
|
856
|
+
try {
|
|
857
|
+
const data = await fetchMessagesJson('/api/message-ops/messages?sessionId=' + encodeURIComponent(sid) + '&view=compact')
|
|
858
|
+
setMessages(Array.isArray(data.messages) ? data.messages : [])
|
|
859
|
+
setTarget((prev) => prev ? {
|
|
860
|
+
...prev,
|
|
861
|
+
running: data.running === true,
|
|
862
|
+
title: prev.title || (data.session && data.session.title) || null,
|
|
863
|
+
expectedTitle: (data.session && data.session.confirmTitle) || prev.expectedTitle || prev.title || prev.sessionId,
|
|
864
|
+
} : prev)
|
|
865
|
+
setState('ready')
|
|
866
|
+
} catch (reason) {
|
|
867
|
+
setError(t('panel.loadFail') + (reason && reason.message ? reason.message : String(reason)))
|
|
868
|
+
setState('ready')
|
|
869
|
+
}
|
|
870
|
+
}, [sid, t])
|
|
871
|
+
|
|
872
|
+
useEffect(() => {
|
|
873
|
+
if (!open || !sid) return
|
|
874
|
+
loadMessages(false)
|
|
875
|
+
}, [open, sid, loadMessages])
|
|
876
|
+
|
|
877
|
+
useEffect(() => {
|
|
878
|
+
if (!open || tab !== 'messages') return
|
|
879
|
+
const q = query.trim()
|
|
880
|
+
if (!q) { setHits(null); setSearching(false); return }
|
|
881
|
+
let cancelled = false
|
|
882
|
+
const timer = setTimeout(() => {
|
|
883
|
+
setSearching(true)
|
|
884
|
+
const url = '/api/message-ops/search?sessionId=' + encodeURIComponent(sid)
|
|
885
|
+
+ '&q=' + encodeURIComponent(q)
|
|
886
|
+
+ (role !== 'all' ? '&role=' + encodeURIComponent(role) : '')
|
|
887
|
+
+ '&limit=' + PANEL_SEARCH_LIMIT
|
|
888
|
+
fetch(url)
|
|
889
|
+
.then((res) => res.json().catch(() => null).then((data) => ({ res, data })))
|
|
890
|
+
.then(({ res, data }) => {
|
|
891
|
+
if (cancelled) return
|
|
892
|
+
if (!res.ok || !data || data.ok !== true) throw new Error((data && data.error) || ('HTTP ' + res.status))
|
|
893
|
+
setHits(Array.isArray(data.results) ? data.results : [])
|
|
894
|
+
})
|
|
895
|
+
.catch((reason) => {
|
|
896
|
+
if (!cancelled) setError(t('panel.searchFail') + (reason && reason.message ? reason.message : String(reason)))
|
|
897
|
+
})
|
|
898
|
+
.finally(() => { if (!cancelled) setSearching(false) })
|
|
899
|
+
}, 200)
|
|
900
|
+
return () => { cancelled = true; clearTimeout(timer) }
|
|
901
|
+
}, [open, tab, sid, query, role, t])
|
|
902
|
+
|
|
903
|
+
const loadTrash = useCallback(async () => {
|
|
904
|
+
setTrashState('loading')
|
|
905
|
+
try {
|
|
906
|
+
const res = await fetch('/api/message-ops/session/trash')
|
|
907
|
+
const data = await res.json().catch(() => null)
|
|
908
|
+
if (!res.ok || !data || data.ok !== true) throw new Error((data && data.error) || ('HTTP ' + res.status))
|
|
909
|
+
setTrash(Array.isArray(data.entries) ? data.entries : [])
|
|
910
|
+
setTrashState('ready')
|
|
911
|
+
} catch (reason) {
|
|
912
|
+
setError(t('panel.loadFail') + (reason && reason.message ? reason.message : String(reason)))
|
|
913
|
+
setTrashState('ready')
|
|
914
|
+
}
|
|
915
|
+
}, [t])
|
|
916
|
+
|
|
917
|
+
useEffect(() => {
|
|
918
|
+
if (open && tab === 'trash') loadTrash()
|
|
919
|
+
}, [open, tab, loadTrash])
|
|
920
|
+
|
|
921
|
+
const close = useCallback(() => {
|
|
922
|
+
if (busy) return
|
|
923
|
+
setOpen(false)
|
|
924
|
+
setTarget(null)
|
|
925
|
+
setPicked(null)
|
|
926
|
+
setConfirmDelete(false)
|
|
927
|
+
}, [busy])
|
|
928
|
+
|
|
929
|
+
useEffect(() => {
|
|
930
|
+
if (!open) return undefined
|
|
931
|
+
const onKey = (e) => {
|
|
932
|
+
if (e.key === 'Escape') {
|
|
933
|
+
if (busy) return
|
|
934
|
+
if (confirmDelete) setConfirmDelete(false)
|
|
935
|
+
else close()
|
|
936
|
+
} else if (e.key === '/' && e.target && e.target.tagName !== 'INPUT') {
|
|
937
|
+
e.preventDefault()
|
|
938
|
+
if (searchRef.current) searchRef.current.focus()
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
window.addEventListener('keydown', onKey)
|
|
942
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
943
|
+
}, [open, busy, confirmDelete, close])
|
|
944
|
+
|
|
945
|
+
const baseRows = query.trim() ? (hits || []) : messages
|
|
946
|
+
const rows = baseRows
|
|
947
|
+
.filter((m) => m && m.role !== 'system'
|
|
948
|
+
&& (role === 'all' || m.role === role)
|
|
949
|
+
&& (includeShadowed || !m.shadowed))
|
|
950
|
+
.sort((a, b) => b.seq - a.seq)
|
|
951
|
+
|
|
952
|
+
useEffect(() => {
|
|
953
|
+
if (!open || tab !== 'messages') return undefined
|
|
954
|
+
const el = listRef.current
|
|
955
|
+
if (!el) return undefined
|
|
956
|
+
const measure = () => setViewport(el.clientHeight || 480)
|
|
957
|
+
measure()
|
|
958
|
+
if (typeof ResizeObserver === 'undefined') return undefined
|
|
959
|
+
const ro = new ResizeObserver(measure)
|
|
960
|
+
ro.observe(el)
|
|
961
|
+
return () => ro.disconnect()
|
|
962
|
+
}, [open, tab, rows.length])
|
|
963
|
+
|
|
964
|
+
const pick = useCallback((m) => {
|
|
965
|
+
if (!m || busy) return
|
|
966
|
+
setPicked(m)
|
|
967
|
+
setDetail(m.snippet || '')
|
|
968
|
+
setDetailState('loading')
|
|
969
|
+
setDone('')
|
|
970
|
+
const req = ++detailReq.current
|
|
971
|
+
fetch('/api/message-ops/text?sessionId=' + encodeURIComponent(sid) + '&seq=' + encodeURIComponent(m.seq))
|
|
972
|
+
.then((res) => res.json().catch(() => null).then((data) => ({ res, data })))
|
|
973
|
+
.then(({ res, data }) => {
|
|
974
|
+
if (req !== detailReq.current) return
|
|
975
|
+
if (!res.ok || !data || data.ok !== true) throw new Error((data && data.error) || ('HTTP ' + res.status))
|
|
976
|
+
setDetail(typeof data.text === 'string' && data.text ? data.text : (m.snippet || ''))
|
|
977
|
+
setDetailState('ready')
|
|
978
|
+
})
|
|
979
|
+
.catch(() => {
|
|
980
|
+
if (req !== detailReq.current) return
|
|
981
|
+
setDetail(m.snippet || '')
|
|
982
|
+
setDetailState('ready')
|
|
983
|
+
})
|
|
984
|
+
}, [busy, sid])
|
|
985
|
+
|
|
986
|
+
const runAction = useCallback(async (mode) => {
|
|
987
|
+
if (!target || !picked || busy) return
|
|
988
|
+
if (target.running && mode !== 'branch') return
|
|
989
|
+
setBusy(mode)
|
|
990
|
+
setError(null)
|
|
991
|
+
setDone('')
|
|
992
|
+
try {
|
|
993
|
+
if (mode === 'branch') {
|
|
994
|
+
const forkPath = pickForkPath(__sessionsSvc)
|
|
995
|
+
if (forkPath.kind !== 'official') throw new Error(t('fork.unavailable'))
|
|
996
|
+
const created = await forkPath.fork({ sessionId: target.sessionId, atSeq: picked.seq, increaseTitle: true })
|
|
997
|
+
setChildId(created || null)
|
|
998
|
+
setDone(t('panel.branchDone', { id: String(created || '') }))
|
|
999
|
+
notifyDone(t('panel.branchDone', { id: String(created || '') }))
|
|
1000
|
+
return
|
|
1001
|
+
}
|
|
1002
|
+
const res = await fetch('/api/message-ops/' + mode, {
|
|
1003
|
+
method: 'POST',
|
|
1004
|
+
headers: { 'content-type': 'application/json' },
|
|
1005
|
+
body: JSON.stringify({ sessionId: target.sessionId, seq: picked.seq }),
|
|
1006
|
+
})
|
|
1007
|
+
const data = await res.json().catch(() => null)
|
|
1008
|
+
if (!res.ok || !data || data.ok !== true) throw new Error((data && data.error) || ('HTTP ' + res.status))
|
|
1009
|
+
if (mode === 'revert') {
|
|
1010
|
+
setMessages((prev) => prev.map((m) => (m.seq >= picked.seq ? { ...m, shadowed: true, visible: false } : m)))
|
|
1011
|
+
if (picked.role === 'user') {
|
|
1012
|
+
try { refillComposer(target.sessionId, picked.seq) } catch { /* non-blocking */ }
|
|
1013
|
+
}
|
|
1014
|
+
} else {
|
|
1015
|
+
setMessages((prev) => prev.map((m) => (m.seq === picked.seq ? { ...m, shadowed: true, visible: false } : m)))
|
|
1016
|
+
}
|
|
1017
|
+
const msg = t(mode === 'revert' ? 'panel.revertDone' : 'panel.deleteDone')
|
|
1018
|
+
setDone(msg)
|
|
1019
|
+
notifyDone(msg)
|
|
1020
|
+
emitChanged()
|
|
1021
|
+
try {
|
|
1022
|
+
if (__uiWorkspace && typeof __uiWorkspace.openSession === 'function') __uiWorkspace.openSession(target.sessionId)
|
|
1023
|
+
} catch { /* view rebuild is best effort */ }
|
|
1024
|
+
setTimeout(() => { loadMessages(true).catch(() => {}) }, 650)
|
|
1025
|
+
} catch (reason) {
|
|
1026
|
+
setError(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)))
|
|
1027
|
+
} finally {
|
|
1028
|
+
setBusy('')
|
|
1029
|
+
}
|
|
1030
|
+
}, [target, picked, busy, t, loadMessages])
|
|
1031
|
+
|
|
1032
|
+
const deleteSession = useCallback(async () => {
|
|
1033
|
+
if (!target || busy) return
|
|
1034
|
+
setBusy('session-delete')
|
|
1035
|
+
setError(null)
|
|
1036
|
+
setDone('')
|
|
1037
|
+
try {
|
|
1038
|
+
const res = await fetch('/api/message-ops/session/delete', {
|
|
1039
|
+
method: 'POST',
|
|
1040
|
+
headers: { 'content-type': 'application/json' },
|
|
1041
|
+
body: JSON.stringify({ sessionId: target.sessionId, confirmTitle: confirmTitle }),
|
|
1042
|
+
})
|
|
1043
|
+
const data = await res.json().catch(() => null)
|
|
1044
|
+
if (!res.ok || !data || data.ok !== true) throw new Error((data && data.error) || ('HTTP ' + res.status))
|
|
1045
|
+
setDone(t('panel.deleted'))
|
|
1046
|
+
notifyDone(t('panel.deleted'))
|
|
1047
|
+
emitChanged()
|
|
1048
|
+
setTimeout(() => { try { window.location.reload() } catch { /* non-browser guard */ } }, 700)
|
|
1049
|
+
} catch (reason) {
|
|
1050
|
+
setError(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)))
|
|
1051
|
+
} finally {
|
|
1052
|
+
setBusy('')
|
|
1053
|
+
}
|
|
1054
|
+
}, [target, busy, confirmTitle, t])
|
|
1055
|
+
|
|
1056
|
+
const restoreEntry = useCallback(async (trashId) => {
|
|
1057
|
+
if (busy) return
|
|
1058
|
+
setBusy('restore:' + trashId)
|
|
1059
|
+
setError(null)
|
|
1060
|
+
setDone('')
|
|
1061
|
+
try {
|
|
1062
|
+
const res = await fetch('/api/message-ops/session/restore', {
|
|
1063
|
+
method: 'POST',
|
|
1064
|
+
headers: { 'content-type': 'application/json' },
|
|
1065
|
+
body: JSON.stringify({ trashId }),
|
|
1066
|
+
})
|
|
1067
|
+
const data = await res.json().catch(() => null)
|
|
1068
|
+
if (!res.ok || !data || data.ok !== true) throw new Error((data && data.error) || ('HTTP ' + res.status))
|
|
1069
|
+
setTrash((prev) => prev.filter((x) => x.trashId !== trashId))
|
|
1070
|
+
setDone(t('panel.restored'))
|
|
1071
|
+
notifyDone(t('panel.restored'))
|
|
1072
|
+
emitChanged()
|
|
1073
|
+
setTimeout(() => { try { window.location.reload() } catch { /* non-browser guard */ } }, 700)
|
|
1074
|
+
} catch (reason) {
|
|
1075
|
+
setError(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)))
|
|
1076
|
+
} finally {
|
|
1077
|
+
setBusy('')
|
|
1078
|
+
}
|
|
1079
|
+
}, [busy, t])
|
|
1080
|
+
|
|
1081
|
+
const purgeEntry = useCallback(async (trashId) => {
|
|
1082
|
+
if (busy) return
|
|
1083
|
+
if (purgeId !== trashId) { setPurgeId(trashId); return }
|
|
1084
|
+
setBusy('purge:' + trashId)
|
|
1085
|
+
setError(null)
|
|
1086
|
+
setDone('')
|
|
1087
|
+
try {
|
|
1088
|
+
const res = await fetch('/api/message-ops/session/purge', {
|
|
1089
|
+
method: 'POST',
|
|
1090
|
+
headers: { 'content-type': 'application/json' },
|
|
1091
|
+
body: JSON.stringify({ trashId }),
|
|
1092
|
+
})
|
|
1093
|
+
const data = await res.json().catch(() => null)
|
|
1094
|
+
if (!res.ok || !data || data.ok !== true) throw new Error((data && data.error) || ('HTTP ' + res.status))
|
|
1095
|
+
setTrash((prev) => prev.filter((x) => x.trashId !== trashId))
|
|
1096
|
+
setPurgeId(null)
|
|
1097
|
+
setDone(t('panel.purged'))
|
|
1098
|
+
notifyDone(t('panel.purged'))
|
|
1099
|
+
} catch (reason) {
|
|
1100
|
+
setError(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)))
|
|
1101
|
+
} finally {
|
|
1102
|
+
setBusy('')
|
|
1103
|
+
}
|
|
1104
|
+
}, [busy, purgeId, t])
|
|
1105
|
+
|
|
1106
|
+
if (!open) return null
|
|
1107
|
+
|
|
1108
|
+
const pickedLive = picked ? (messages.find((m) => m.seq === picked.seq) || picked) : null
|
|
1109
|
+
const canAct = !busy && !target.running && pickedLive && !pickedLive.shadowed
|
|
1110
|
+
const visibleCount = messages.filter((m) => m.role !== 'system' && m.visible && !m.shadowed).length
|
|
1111
|
+
const shadowedCount = messages.filter((m) => m.role !== 'system' && m.shadowed).length
|
|
1112
|
+
const totalCount = messages.filter((m) => m.role !== 'system').length
|
|
1113
|
+
const revertCount = pickedLive
|
|
1114
|
+
? messages.filter((m) => m.role !== 'system' && !m.shadowed && m.seq >= pickedLive.seq).length
|
|
1115
|
+
: 0
|
|
1116
|
+
const start = Math.max(0, Math.floor(scrollTop / PANEL_ROW_H) - 6)
|
|
1117
|
+
const end = Math.min(rows.length, start + Math.ceil(viewport / PANEL_ROW_H) + 12)
|
|
1118
|
+
const windowRows = rows.slice(start, end)
|
|
1119
|
+
const showSearchLoading = searching && query.trim().length > 0
|
|
1120
|
+
|
|
1121
|
+
return React.createElement('div', {
|
|
1122
|
+
className: 'mopsPanelScrim', role: 'presentation',
|
|
1123
|
+
onMouseDown: (e) => { if (e.target === e.currentTarget) close() },
|
|
1124
|
+
},
|
|
1125
|
+
React.createElement('section', {
|
|
1126
|
+
className: 'mopsPanel', role: 'dialog', 'aria-modal': 'true', 'aria-label': t('dialog.title'),
|
|
1127
|
+
style: { position: 'relative' },
|
|
1128
|
+
},
|
|
1129
|
+
React.createElement('header', { className: 'mopsPanelHead' },
|
|
1130
|
+
React.createElement('div', { style: { minWidth: 0 } },
|
|
1131
|
+
React.createElement('div', { className: 'mopsPanelKicker' }, t('dialog.title')),
|
|
1132
|
+
React.createElement('div', { className: 'mopsPanelTitle' }, target.title || target.expectedTitle || target.sessionId),
|
|
1133
|
+
React.createElement('div', { className: 'mopsPanelSub' }, target.sessionId)),
|
|
1134
|
+
React.createElement('button', {
|
|
1135
|
+
type: 'button', className: 'mopsPanelClose', title: t('dialog.cancel'),
|
|
1136
|
+
onClick: close, disabled: !!busy,
|
|
1137
|
+
}, '×')),
|
|
1138
|
+
React.createElement('div', { className: 'mopsTabs' },
|
|
1139
|
+
React.createElement('button', {
|
|
1140
|
+
type: 'button', className: 'mopsTab' + (tab === 'messages' ? ' on' : ''),
|
|
1141
|
+
onClick: () => { setTab('messages'); setConfirmDelete(false) },
|
|
1142
|
+
}, t('panel.messages') + ' ' + totalCount),
|
|
1143
|
+
React.createElement('button', {
|
|
1144
|
+
type: 'button', className: 'mopsTab' + (tab === 'trash' ? ' on' : ''),
|
|
1145
|
+
onClick: () => { setTab('trash'); setConfirmDelete(false) },
|
|
1146
|
+
}, t('panel.trash') + (trash.length ? ' ' + trash.length : ''))),
|
|
1147
|
+
target.running && tab === 'messages'
|
|
1148
|
+
? React.createElement('div', { className: 'mopsBanner warn' }, t('panel.running')) : null,
|
|
1149
|
+
error ? React.createElement('div', { className: 'mopsBanner err', role: 'alert' }, error) : null,
|
|
1150
|
+
done ? React.createElement('div', { className: 'mopsBanner ok' }, done,
|
|
1151
|
+
childId ? React.createElement('button', {
|
|
1152
|
+
type: 'button', className: 'mopsAction primary', style: { marginLeft: 10 },
|
|
1153
|
+
onClick: () => { try { if (__uiWorkspace && typeof __uiWorkspace.openSession === 'function') __uiWorkspace.openSession(childId) } catch { /* best effort */ } },
|
|
1154
|
+
}, t('panel.openNew')) : null) : null,
|
|
1155
|
+
tab === 'messages'
|
|
1156
|
+
? React.createElement(React.Fragment, null,
|
|
1157
|
+
React.createElement('div', { className: 'mopsSearchRow' },
|
|
1158
|
+
React.createElement('input', {
|
|
1159
|
+
ref: searchRef, className: 'mopsSearch', value: query,
|
|
1160
|
+
placeholder: t('panel.searchHint'), 'aria-label': t('panel.search'),
|
|
1161
|
+
onChange: (e) => { setQuery(e.target.value); setScrollTop(0); if (listRef.current) listRef.current.scrollTop = 0 },
|
|
1162
|
+
})),
|
|
1163
|
+
React.createElement('div', { className: 'mopsChips' },
|
|
1164
|
+
['all', 'user', 'assistant', 'tool'].map((key) => React.createElement('button', {
|
|
1165
|
+
key, type: 'button', className: 'mopsChip' + (role === key ? ' on' : ''),
|
|
1166
|
+
onClick: () => { setRole(key); setScrollTop(0); if (listRef.current) listRef.current.scrollTop = 0 },
|
|
1167
|
+
}, t('panel.' + key))),
|
|
1168
|
+
React.createElement('label', { className: 'mopsToggle' },
|
|
1169
|
+
React.createElement('input', {
|
|
1170
|
+
type: 'checkbox', checked: includeShadowed,
|
|
1171
|
+
onChange: (e) => { setIncludeShadowed(e.target.checked); setScrollTop(0); if (listRef.current) listRef.current.scrollTop = 0 },
|
|
1172
|
+
}), t('panel.includeShadowed'))),
|
|
1173
|
+
React.createElement('div', {
|
|
1174
|
+
className: 'mopsList', ref: listRef,
|
|
1175
|
+
onScroll: (e) => setScrollTop(e.currentTarget.scrollTop),
|
|
1176
|
+
},
|
|
1177
|
+
rows.length === 0
|
|
1178
|
+
? React.createElement('div', { className: 'mopsEmpty' }, showSearchLoading ? t('panel.loading') : (query.trim() ? t('panel.noMatch') : t('panel.empty')))
|
|
1179
|
+
: React.createElement('div', { style: { height: rows.length * PANEL_ROW_H, position: 'relative' } },
|
|
1180
|
+
windowRows.map((m, i) => React.createElement('button', {
|
|
1181
|
+
key: m.seq, type: 'button',
|
|
1182
|
+
className: 'mopsRow' + (pickedLive && pickedLive.seq === m.seq ? ' on' : '') + (m.shadowed ? ' shadowed' : ''),
|
|
1183
|
+
style: { top: (start + i) * PANEL_ROW_H, height: PANEL_ROW_H },
|
|
1184
|
+
onClick: () => pick(m),
|
|
1185
|
+
title: '#' + m.seq + ' ' + (m.role || '') + ' · ' + (m.snippet || ''),
|
|
1186
|
+
},
|
|
1187
|
+
React.createElement('span', { className: 'mopsSeq' }, '#' + m.seq),
|
|
1188
|
+
React.createElement('span', { className: 'mopsRole' }, roleCode(m.role)),
|
|
1189
|
+
React.createElement('span', { className: 'mopsSnippet' }, m.snippet || '—'),
|
|
1190
|
+
React.createElement('span', { className: 'mopsClock' }, fmtClock(m.time)))))),
|
|
1191
|
+
React.createElement('div', { className: 'mopsDetail' },
|
|
1192
|
+
pickedLive
|
|
1193
|
+
? React.createElement(React.Fragment, null,
|
|
1194
|
+
React.createElement('div', { className: 'mopsDetailMeta' },
|
|
1195
|
+
React.createElement('span', null, '#' + pickedLive.seq),
|
|
1196
|
+
React.createElement('span', null, pickedLive.role || ''),
|
|
1197
|
+
pickedLive.shadowed ? React.createElement('span', null, t('panel.shadowed')) : null,
|
|
1198
|
+
pickedLive.time ? React.createElement('span', null, fmtClock(pickedLive.time)) : null),
|
|
1199
|
+
React.createElement('pre', { className: 'mopsDetailText' },
|
|
1200
|
+
detailState === 'loading' ? t('panel.detailLoading') : (detail || pickedLive.snippet || '—')),
|
|
1201
|
+
React.createElement('div', { className: 'mopsActions' },
|
|
1202
|
+
React.createElement('button', {
|
|
1203
|
+
type: 'button', className: 'mopsAction primary', disabled: !canAct,
|
|
1204
|
+
onClick: () => runAction('revert'),
|
|
1205
|
+
title: t('panel.revertHint'),
|
|
1206
|
+
}, t('panel.revert') + (revertCount ? ' · ' + revertCount : '')),
|
|
1207
|
+
React.createElement('button', {
|
|
1208
|
+
type: 'button', className: 'mopsAction danger', disabled: !canAct,
|
|
1209
|
+
onClick: () => runAction('delete'), title: t('panel.deleteHint'),
|
|
1210
|
+
}, t('panel.delete')),
|
|
1211
|
+
React.createElement('button', {
|
|
1212
|
+
type: 'button', className: 'mopsAction', disabled: !canAct,
|
|
1213
|
+
onClick: () => runAction('branch'), title: t('panel.branchHint'),
|
|
1214
|
+
}, t('panel.branch'))))
|
|
1215
|
+
: React.createElement('div', { className: 'mopsDetailHint' }, t('panel.selectHint'))),
|
|
1216
|
+
React.createElement('footer', { className: 'mopsPanelFoot' },
|
|
1217
|
+
React.createElement('span', { className: 'mopsCount' },
|
|
1218
|
+
t('panel.count', { n: String(totalCount), v: String(visibleCount), s: String(shadowedCount) })),
|
|
1219
|
+
React.createElement('button', {
|
|
1220
|
+
type: 'button', className: 'mopsDanger', disabled: !!busy,
|
|
1221
|
+
onClick: () => { setConfirmTitle(''); setConfirmDelete(true) },
|
|
1222
|
+
}, t('panel.deleteSession'))),
|
|
1223
|
+
confirmDelete
|
|
1224
|
+
? React.createElement('div', { className: 'mopsConfirm' },
|
|
1225
|
+
React.createElement('h3', null, t('panel.deleteSession')),
|
|
1226
|
+
React.createElement('p', null, t('panel.deleteSessionHint')),
|
|
1227
|
+
React.createElement('input', {
|
|
1228
|
+
value: confirmTitle, autoFocus: true,
|
|
1229
|
+
placeholder: target.expectedTitle || target.title || target.sessionId,
|
|
1230
|
+
onChange: (e) => setConfirmTitle(e.target.value),
|
|
1231
|
+
}),
|
|
1232
|
+
React.createElement('div', { className: 'mopsConfirmRow' },
|
|
1233
|
+
React.createElement('button', { type: 'button', className: 'mopsAction', onClick: () => setConfirmDelete(false), disabled: !!busy }, t('panel.cancel')),
|
|
1234
|
+
React.createElement('button', {
|
|
1235
|
+
type: 'button', className: 'mopsAction danger', disabled: !!busy || confirmTitle.trim() !== (target.expectedTitle || target.title || target.sessionId),
|
|
1236
|
+
onClick: deleteSession,
|
|
1237
|
+
}, busy === 'session-delete' ? t('panel.deleting') : t('panel.confirmDelete'))))
|
|
1238
|
+
: null)
|
|
1239
|
+
: React.createElement('div', { className: 'mopsList', style: { borderTop: 0 } },
|
|
1240
|
+
trashState === 'loading'
|
|
1241
|
+
? React.createElement('div', { className: 'mopsEmpty' }, t('panel.loading'))
|
|
1242
|
+
: trash.length === 0
|
|
1243
|
+
? React.createElement('div', { className: 'mopsEmpty' }, t('panel.trashEmpty'))
|
|
1244
|
+
: trash.map((item) => React.createElement('div', { className: 'mopsTrashItem', key: item.trashId },
|
|
1245
|
+
React.createElement('div', { className: 'mopsTrashMain' },
|
|
1246
|
+
React.createElement('div', { className: 'mopsTrashTitle' }, item.title || t('panel.untitled')),
|
|
1247
|
+
React.createElement('div', { className: 'mopsTrashMeta' },
|
|
1248
|
+
item.sessionId + (item.expiresAt ? ' · ' + t('panel.expires') + ' ' + new Date(item.expiresAt).toLocaleDateString() : ''))),
|
|
1249
|
+
React.createElement('button', {
|
|
1250
|
+
type: 'button', className: 'mopsAction', disabled: !!busy,
|
|
1251
|
+
onClick: () => restoreEntry(item.trashId),
|
|
1252
|
+
}, busy === 'restore:' + item.trashId ? t('panel.busy') : t('panel.restore')),
|
|
1253
|
+
React.createElement('button', {
|
|
1254
|
+
type: 'button', className: 'mopsAction danger', disabled: !!busy,
|
|
1255
|
+
onClick: () => purgeEntry(item.trashId),
|
|
1256
|
+
}, busy === 'purge:' + item.trashId ? t('panel.busy') : (purgeId === item.trashId ? t('panel.purgeConfirm') : t('panel.purge'))))))))
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// --- 头部按钮 ---------------------------------------------------------------
|
|
1260
|
+
|
|
1261
|
+
// 0.3.0 分支双路径选择:0.2.0+ 官方 sessions.fork({atSeq}) 优先。
|
|
1262
|
+
function pickForkPath(sessionsSvc) {
|
|
1263
|
+
if (sessionsSvc && typeof sessionsSvc.fork === 'function') {
|
|
1264
|
+
return { kind: 'official', fork: sessionsSvc.fork.bind(sessionsSvc) }
|
|
1265
|
+
}
|
|
1266
|
+
return { kind: 'disk' }
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// 成功反馈统一出口:devkit 标准 toast 优先,无 devkit 时对话框内文案兜底。
|
|
1270
|
+
function notifyDone(msg, kind) {
|
|
1271
|
+
try {
|
|
1272
|
+
const dk = window.__dshDevkit
|
|
1273
|
+
if (dk && typeof dk.toast === 'function') dk.toast(msg, { kind: kind || 'ok' })
|
|
1274
|
+
} catch { /* toast 缺席不阻断成功反馈 */ }
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function OpsButton(props) {
|
|
1278
|
+
const { sessionId, useSessions } = props
|
|
1279
|
+
const t = (props && props.t) || __t
|
|
1280
|
+
useLocaleRevision()
|
|
1281
|
+
const sessions = useSessions ? useSessions((s) => s) : undefined
|
|
1282
|
+
const summary = sessions && sessions.byId ? sessions.byId[sessionId] : undefined
|
|
1283
|
+
const running = summary ? summary.running === true : false
|
|
1284
|
+
|
|
1285
|
+
const openDialog = useCallback(() => {
|
|
1286
|
+
window.dispatchEvent(new CustomEvent(EVENT, {
|
|
1287
|
+
detail: { sessionId, title: summary && summary.title ? summary.title : null, running },
|
|
1288
|
+
}))
|
|
1289
|
+
}, [sessionId, summary, running])
|
|
1290
|
+
|
|
1291
|
+
return React.createElement('button', {
|
|
1292
|
+
type: 'button', title: t('button.title'), 'aria-label': t('button.title'),
|
|
1293
|
+
style: btnStyle, onClick: openDialog,
|
|
1294
|
+
}, React.createElement(BranchIcon))
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// 官方图标适配:0.2.0 primitives 的 IconClock 与原生操作行同款;
|
|
1298
|
+
// require 失败(0.1.x 或裁剪环境)回退内联 SVG,槽在 0.1.x 本就不存在,仅防御。
|
|
1299
|
+
// 0.9.0:移除 IconTrash —— 消息级删除按钮从未渲染(删除只在对话框里),
|
|
1300
|
+
// 专家核查确认 IconTrash/TrashFallback 零渲染点,是死代码。
|
|
1301
|
+
var IconClock = null
|
|
1302
|
+
try {
|
|
1303
|
+
const P = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
1304
|
+
IconClock = P && P.IconClockOutlineRegular
|
|
1305
|
+
} catch { /* fallback below */ }
|
|
1306
|
+
if (!IconClock) IconClock = function ClockFallback() {
|
|
1307
|
+
// 官方 IconClockOutlineArtwork 1:1 路径( Regular = 1px stroke)
|
|
1308
|
+
return React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true, stroke: 'currentColor', strokeWidth: 1 },
|
|
1309
|
+
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' }),
|
|
1310
|
+
React.createElement('path', { d: 'M8 4.31V8.46L11 10.08' }))
|
|
1311
|
+
}
|
|
1312
|
+
var IconQuote = null
|
|
1313
|
+
try {
|
|
1314
|
+
const P2 = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
1315
|
+
IconQuote = P2 && (P2.IconChatOutlineRegular || P2.IconCopyOutlineRegular)
|
|
1316
|
+
} catch { /* fallback below */ }
|
|
1317
|
+
if (!IconQuote) IconQuote = function QuoteFallback() {
|
|
1318
|
+
return React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true, stroke: 'currentColor', strokeWidth: 1 },
|
|
1319
|
+
React.createElement('path', { d: 'M3 6.5A3.5 3.5 0 0 1 6.5 3H7v1.5h-.5A2 2 0 0 0 4.5 6.5V7H7v3.5H3V6.5ZM9 6.5A3.5 3.5 0 0 1 12.5 3H13v1.5h-.5A2 2 0 0 0 10.5 6.5V7H13v3.5H9V6.5Z' }))
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// --- 0.3.0 assistant-actions 官方槽:每条 AI 消息旁的原生回撤按钮 ------------
|
|
1323
|
+
// 契约(dsh-cordis-client-runner):scope session,条目组件收 { messageId } +
|
|
1324
|
+
// 标准props(useSessions/sessionId/useSession/useChat…)。0.1.x 无此 key,
|
|
1325
|
+
// slots.inject 自动 no-op。
|
|
1326
|
+
const CHAT_ACTIONS_SLOT = 'conversation.chat.assistant-actions'
|
|
1327
|
+
const CHAT_ACTIONS_ID = 'message-ops-row'
|
|
1328
|
+
|
|
1329
|
+
function MsgSlotActions(props) {
|
|
1330
|
+
const { messageId, sessionId, useSessions, inputActions, useChat } = props
|
|
1331
|
+
const t = (props && props.t) || __t
|
|
1332
|
+
useLocaleRevision()
|
|
1333
|
+
if (inputActions && typeof inputActions.setDraft === 'function') __inputActions = inputActions
|
|
1334
|
+
const sessions = useSessions ? useSessions((s) => s) : undefined
|
|
1335
|
+
const summary = sessions && sessions.byId ? sessions.byId[sessionId] : undefined
|
|
1336
|
+
const running = summary ? summary.running === true : false
|
|
1337
|
+
const [seq, setSeq] = React.useState(null)
|
|
1338
|
+
const [fullText, setFullText] = React.useState(null)
|
|
1339
|
+
const [busy, setBusy] = React.useState(null) // 'revert' | null(S11 双提交防护)
|
|
1340
|
+
// 0.9.7(P4):索引被 dataSig 重建后要重跑解析,否则「新 AI 消息旁
|
|
1341
|
+
// 没有回撤/引用按钮」—— 已经挂载的槽位 effect 不会因缓存被换而重跑。
|
|
1342
|
+
const [idxRev, setIdxRev] = React.useState(0)
|
|
1343
|
+
React.useEffect(() => {
|
|
1344
|
+
const onIdx = () => setIdxRev((v) => v + 1)
|
|
1345
|
+
window.addEventListener(SEQ_INDEX_EVENT, onIdx)
|
|
1346
|
+
return () => window.removeEventListener(SEQ_INDEX_EVENT, onIdx)
|
|
1347
|
+
}, [])
|
|
1348
|
+
React.useEffect(() => {
|
|
1349
|
+
if (!messageId || !sessionId) return
|
|
1350
|
+
let alive = true
|
|
1351
|
+
let retried = false
|
|
1352
|
+
const load = () => {
|
|
1353
|
+
const p = fetchMessagesJson('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId))
|
|
1354
|
+
.then((data) => {
|
|
1355
|
+
const index = new Map()
|
|
1356
|
+
const full = new Map()
|
|
1357
|
+
for (const m of (data && data.messages) || []) {
|
|
1358
|
+
// 0.5.2(P2):同 id 取**最早** seq——与服务端 buildSeqIndex 口径一致
|
|
1359
|
+
// (重试链取最早可见节点;此前内联 set 覆盖=取最大,双实现分裂)
|
|
1360
|
+
if (m && m.id != null && !index.has(String(m.id))) index.set(String(m.id), m.seq)
|
|
1361
|
+
if (m && m.id != null && m.fullText) full.set(String(m.id), m.fullText)
|
|
1362
|
+
}
|
|
1363
|
+
__seqIndexCache.set(sessionId, index)
|
|
1364
|
+
__fullTextCache.set(sessionId, full)
|
|
1365
|
+
return index
|
|
1366
|
+
})
|
|
1367
|
+
__seqIndexCache.set(sessionId, p)
|
|
1368
|
+
// 0.5.2(P2):失败必须清缓存——否则 rejected promise 被永久缓存,
|
|
1369
|
+
// 该会话按钮/dock 到刷新页面为止全部失效
|
|
1370
|
+
// 0.9.7:只在缓存里还是**我们这条** promise 时才删(syncSeqIndex 可能已换掉)
|
|
1371
|
+
p.catch(() => { if (__seqIndexCache.get(sessionId) === p) __seqIndexCache.delete(sessionId) })
|
|
1372
|
+
return p
|
|
1373
|
+
}
|
|
1374
|
+
const resolve = (index) => {
|
|
1375
|
+
if (!alive) return
|
|
1376
|
+
const hit = index && index.get(String(messageId))
|
|
1377
|
+
if (typeof hit === 'number') { setSeq(hit); return }
|
|
1378
|
+
// 0.9.7(P4):索引里没有这个 messageId = 会话已增长(或索引过期)。
|
|
1379
|
+
// 旧实现这里**什么都不做**,`seq` 永远是 null → 组件 return null →
|
|
1380
|
+
// 按钮再也不渲染。现在失效并重拉一次(retried 保证不自旋);
|
|
1381
|
+
// 复用 fetchMessagesJson 的 1s 合并窗口,连续上新不会打爆请求。
|
|
1382
|
+
if (retried) return
|
|
1383
|
+
retried = true
|
|
1384
|
+
__seqIndexCache.delete(sessionId)
|
|
1385
|
+
__fullTextCache.delete(sessionId)
|
|
1386
|
+
load().then(resolve).catch(() => {})
|
|
1387
|
+
}
|
|
1388
|
+
const cached = __seqIndexCache.get(sessionId)
|
|
1389
|
+
if (cached && typeof cached.then === 'function') cached.then(resolve).catch(() => {})
|
|
1390
|
+
else if (cached) resolve(cached)
|
|
1391
|
+
else load().then(resolve).catch(() => {})
|
|
1392
|
+
return () => { alive = false }
|
|
1393
|
+
}, [messageId, sessionId, idxRev])
|
|
1394
|
+
|
|
1395
|
+
// 原文(引用用):seq 解析后按需取全文
|
|
1396
|
+
React.useEffect(() => {
|
|
1397
|
+
if (seq == null || !sessionId) return
|
|
1398
|
+
let alive = true
|
|
1399
|
+
const cached = __fullTextCache.get(sessionId)
|
|
1400
|
+
const resolve = (full) => {
|
|
1401
|
+
if (!alive) return
|
|
1402
|
+
const hit = full && full.get(String(messageId))
|
|
1403
|
+
if (typeof hit === 'string') setFullText(hit)
|
|
1404
|
+
}
|
|
1405
|
+
if (cached && typeof cached.then === 'function') cached.then(resolve).catch(() => {})
|
|
1406
|
+
else if (cached) resolve(cached)
|
|
1407
|
+
return () => { alive = false }
|
|
1408
|
+
}, [seq, messageId, sessionId])
|
|
1409
|
+
|
|
1410
|
+
// 0.5.0 回撤后的视图刷新:openSession(reveal) 重建会话视图 → 遮蔽立即呈现。
|
|
1411
|
+
// (宿主 live 投影只处理 compaction 类 replace,插件标记需要视图重建才可见。)
|
|
1412
|
+
const revealSession = () => {
|
|
1413
|
+
try {
|
|
1414
|
+
if (__uiWorkspace && typeof __uiWorkspace.openSession === 'function') {
|
|
1415
|
+
__uiWorkspace.openSession(sessionId)
|
|
1416
|
+
return true
|
|
1417
|
+
}
|
|
1418
|
+
} catch { /* fallthrough */ }
|
|
1419
|
+
return false
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const runRevert = () => {
|
|
1423
|
+
if (busy || seq == null || running) return
|
|
1424
|
+
setBusy('revert')
|
|
1425
|
+
// 0.5.2(P2):Promise.resolve 包裹——fetch/stringify 同步抛出也转为
|
|
1426
|
+
// rejection 落进 finally,busy 永不卡死
|
|
1427
|
+
Promise.resolve()
|
|
1428
|
+
.then(() => fetch('/api/message-ops/revert', {
|
|
1429
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1430
|
+
body: JSON.stringify({ sessionId, seq }),
|
|
1431
|
+
}))
|
|
1432
|
+
.then(async (res) => {
|
|
1433
|
+
let data = {}
|
|
1434
|
+
try { data = await res.json() } catch { /* keep {} */ }
|
|
1435
|
+
if (!res.ok || !data.ok) throw new Error(data.error || ('HTTP ' + res.status))
|
|
1436
|
+
notifyDone(t('done.revert'))
|
|
1437
|
+
// 0.9.6:回填被删掉的那句用户原文(边界是 AI 消息时取它之前那条用户消息)。
|
|
1438
|
+
// 此前这个入口完全不回填 —— 表现就是「回滚成功但消息框空着」。
|
|
1439
|
+
refillComposer(sessionId, seq)
|
|
1440
|
+
revealSession()
|
|
1441
|
+
emitChanged()
|
|
1442
|
+
})
|
|
1443
|
+
.catch((reason) => {
|
|
1444
|
+
notifyDone(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error')
|
|
1445
|
+
})
|
|
1446
|
+
.finally(() => setBusy(null))
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// 0.5.0 消息引用:官方 InputActions.captureInsertion + insertText(零 DOM hack)
|
|
1450
|
+
const runQuote = () => {
|
|
1451
|
+
try {
|
|
1452
|
+
if (!inputActions || typeof inputActions.captureInsertion !== 'function' || typeof inputActions.insertText !== 'function') {
|
|
1453
|
+
notifyDone(t('quote.unavailable'), 'warn')
|
|
1454
|
+
return
|
|
1455
|
+
}
|
|
1456
|
+
const span = inputActions.captureInsertion()
|
|
1457
|
+
const insertQuote = (text) => {
|
|
1458
|
+
if (text == null || text === '') { notifyDone(t('quote.unavailable'), 'warn'); return }
|
|
1459
|
+
const quoted = text.split('\n').map((l) => '> ' + l).join('\n') + '\n\n'
|
|
1460
|
+
const ok = inputActions.insertText(quoted, span)
|
|
1461
|
+
notifyDone(ok ? t('quote.done') : t('quote.unavailable'), ok ? 'ok' : 'warn')
|
|
1462
|
+
}
|
|
1463
|
+
// assistant 消息 fullText 恒 null(列表不携带)→ 按 seq 向服务端取单条全文
|
|
1464
|
+
if (fullText != null && fullText !== '') { insertQuote(fullText); return }
|
|
1465
|
+
fetch('/api/message-ops/text?sessionId=' + encodeURIComponent(sessionId) + '&seq=' + encodeURIComponent(seq))
|
|
1466
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
1467
|
+
.then((d) => insertQuote(d && d.ok && typeof d.text === 'string' ? d.text : null))
|
|
1468
|
+
.catch(() => insertQuote(null))
|
|
1469
|
+
} catch (e) {
|
|
1470
|
+
notifyDone(t('errorPrefix') + (e && e.message ? e.message : String(e)), 'error')
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if (seq == null) return null
|
|
1475
|
+
const act = (icon, label, mode, onClick, disabled) => React.createElement('button', {
|
|
1476
|
+
type: 'button', title: running && mode === 'revert' ? t('dialog.runningWarn') : label,
|
|
1477
|
+
'aria-label': label, disabled: !!disabled,
|
|
1478
|
+
style: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
1479
|
+
minWidth: 44, minHeight: 44, padding: 0, border: 'none', background: 'transparent',
|
|
1480
|
+
color: 'inherit', opacity: busy && busy !== mode ? 0.4 : running && mode === 'revert' ? 0.35 : 0.72,
|
|
1481
|
+
cursor: disabled ? 'default' : 'pointer', borderRadius: 6 },
|
|
1482
|
+
onClick,
|
|
1483
|
+
}, icon)
|
|
1484
|
+
return React.createElement('span', { style: { display: 'inline-flex', gap: 0 } },
|
|
1485
|
+
act(React.createElement(IconClock, { size: 16 }), t('slot.revert'), 'revert', runRevert, running),
|
|
1486
|
+
act(React.createElement(IconQuote, { size: 16 }), t('slot.quote'), 'quote', runQuote, false),
|
|
1487
|
+
)
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// 0.4.2 引入(恢复的标记自动离开活跃列表 = opencode clear 语义)。
|
|
1491
|
+
// 0.5.0 脚本化编辑曾误删本函数 → dock 100% 静默失效(P0,0.5.2 恢复)。
|
|
1492
|
+
function activeMarkers(msgs) {
|
|
1493
|
+
// 0.8.0:活跃判定收敛到服务端权威字段 restoreComplete
|
|
1494
|
+
// (按 restoredSourceSeqs 进度算;旧 notice 无该字段 → 服务端视为整段已恢复)。
|
|
1495
|
+
// 旧的"行内 restoresSeq 集合"客户端判定已删除——按轮步进下部分恢复的标记
|
|
1496
|
+
// 也带 restoresSeq,旧逻辑会把它们误杀(实测:第一轮恢复后整条消失)。
|
|
1497
|
+
// 0.10.1:再排掉**已提交**的 marker —— 对齐 opencode
|
|
1498
|
+
// (用户发出下一条真实消息后,opencode 在 Session.prompt 里自动
|
|
1499
|
+
// SessionRevert.commit() → revert:null → affordance 消失)。
|
|
1500
|
+
// 我们是 append-only,marker 不会真消失,所以由服务端按
|
|
1501
|
+
// 「marker 之后有无 source.kind==='user' 的真实输入」算出 committed。
|
|
1502
|
+
return (msgs || []).filter((m) =>
|
|
1503
|
+
m.marker && m.sourceKind !== 'compact-checkpoint'
|
|
1504
|
+
&& m.restoreComplete !== true && m.committed !== true)
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* activity 信号 → 是否重拉的**纯决策**(0.10.6 · 测试性改造)。
|
|
1509
|
+
*
|
|
1510
|
+
* 为什么抽出来:三轮测试审计的结论是「客户端 F1 的锁全是形状锁」——整个
|
|
1511
|
+
* useEffect 可以被注释掉而 164 条测试全绿(审计 W4 实测)。根因是判定逻辑
|
|
1512
|
+
* 埋在 hook 里,node --test 无法执行。抽成纯函数后可用 `new Function` 抽出来
|
|
1513
|
+
* **行为测试**(client-perf-locks.test.js:161 已有同款手法,零新依赖)。
|
|
1514
|
+
*
|
|
1515
|
+
* 语义(三态,返回「推进后的基线」让调用方无脑赋值):
|
|
1516
|
+
* next == null → 不动基线(宿主 store 还没就绪)
|
|
1517
|
+
* prev == undefined → **建基线**。首帧渲染时 activityAt 常先是 null 再被
|
|
1518
|
+
* 填充(或经 lastPromptAt 投影折入),把这次「null→值」
|
|
1519
|
+
* 当成新活动会让开会话白拉一次(实测曾达 6 次 GET)
|
|
1520
|
+
* next <= prev → 单调门,不动基线(refreshList 可能压低再抬高)
|
|
1521
|
+
* next > prev → 真活动:重拉并推进基线
|
|
1522
|
+
*/
|
|
1523
|
+
function activityTransition(prev, next) {
|
|
1524
|
+
if (next == null) return { reload: false, baseline: prev }
|
|
1525
|
+
if (prev === undefined) return { reload: false, baseline: next }
|
|
1526
|
+
if (next <= prev) return { reload: false, baseline: prev }
|
|
1527
|
+
return { reload: true, baseline: next }
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function RevertDock(props) {
|
|
1531
|
+
const { sessionId, useSessions, inputActions } = props
|
|
1532
|
+
if (sessionId) __currentSessionId = sessionId
|
|
1533
|
+
const t = (props && props.t) || __t
|
|
1534
|
+
useLocaleRevision()
|
|
1535
|
+
if (inputActions && typeof inputActions.setDraft === 'function') __inputActions = inputActions
|
|
1536
|
+
const sessions = useSessions ? useSessions((s) => s) : undefined
|
|
1537
|
+
const summary = sessions && sessions.byId ? sessions.byId[sessionId] : undefined
|
|
1538
|
+
const running = summary ? summary.running === true : false
|
|
1539
|
+
// F1 主路径:宿主 activity 信号(见下方 effect);null = 无 useSessions 或尚无活动
|
|
1540
|
+
const activityAt = summary && typeof summary.updatedAt === 'number' ? summary.updatedAt : null
|
|
1541
|
+
const loadRef = useRef(() => {})
|
|
1542
|
+
const lastActivityRef = useRef(undefined)
|
|
1543
|
+
// R2(审计):invalidateMessagesMemo() 只丢 memo 引用、**不取消在途 fetch**。
|
|
1544
|
+
// 两个并发 load(mount + activity,或 CHANGED + activity)时慢的那个可能后落,
|
|
1545
|
+
// setMarkers 会把已正确的 markers 覆盖回去 → dock 重新出现。
|
|
1546
|
+
// 0.10.4 的 350/1400ms 延迟事实上起到了串行化作用,删除后重叠概率上升。
|
|
1547
|
+
const reqSeqRef = useRef(0)
|
|
1548
|
+
const sessRef = useRef(sessionId) // 真正的「切了会话」判据(R1 回归修复)
|
|
1549
|
+
const landedSeqRef = useRef(0) // 已成功落地的最大请求序号(R2:失败不回滚)
|
|
1550
|
+
const [open, setOpen] = useState(false)
|
|
1551
|
+
const [markers, setMarkers] = useState(null) // 活跃回撤标记(已恢复的自动消失)
|
|
1552
|
+
const [shadowCount, setShadowCount] = useState(0) // 标记 range 内被遮蔽消息数(标题口径)
|
|
1553
|
+
const [restoring, setRestoring] = useState(null)
|
|
1554
|
+
const [styleInjected, setStyleInjected] = useState(false)
|
|
1555
|
+
|
|
1556
|
+
// opencode SessionRevertDock 视觉(v2 布局,DSH 令牌等价映射)
|
|
1557
|
+
useEffect(() => {
|
|
1558
|
+
if (styleInjected || typeof document === 'undefined') return
|
|
1559
|
+
if (document.getElementById('dsh-message-ops-dock-style')) { setStyleInjected(true); return }
|
|
1560
|
+
const tag = document.createElement('style')
|
|
1561
|
+
tag.id = 'dsh-message-ops-dock-style'
|
|
1562
|
+
tag.textContent = [
|
|
1563
|
+
// 容器:rounded-xl + 0.5px 边框 + bg-layer-01(M1 修正令牌)
|
|
1564
|
+
// 0.6.0 贴条(对齐 opencode):宽度锁到输入卡片同源变量(uV2eYG_card 同款
|
|
1565
|
+
// max-width),居中;margin-bottom:-5px 把 composerStack 的6px gap 压成
|
|
1566
|
+
// **1px 视觉贴合**(实测基线 gap14→目标1)。
|
|
1567
|
+
'.mopsRd{width:100%;max-width:var(--dsh-composer-card-max-width);margin-inline:auto;margin-bottom:-5px;overflow:hidden;border-radius:var(--dsw-radius-md,12px);border:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.25));background:var(--dsw-alias-bg-layer-1,#1e1e20)}',
|
|
1568
|
+
// 头部 42px:图标 + label + 折叠预览 + 旋转 chevron
|
|
1569
|
+
'.mopsRdHead{display:flex;height:40px;align-items:center;gap:8px;padding-left:16px;padding-right:8px;cursor:pointer;user-select:none}',
|
|
1570
|
+
'.mopsRdIcon{display:inline-flex;color:var(--dsw-alias-label-tertiary,#9a9aa0);flex:none}',
|
|
1571
|
+
'.mopsRdLabel{font-size:13px;font-weight:500;line-height:20px;letter-spacing:-.04px;flex:none;cursor:default;color:var(--dsw-alias-label-primary,inherit)}',
|
|
1572
|
+
'.mopsRdLabelCollapsed{color:var(--dsw-alias-label-secondary,#a8a8ae)}',
|
|
1573
|
+
'.mopsRdPreview{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:400;line-height:20px;letter-spacing:-.04px;cursor:default;color:var(--dsw-alias-label-tertiary,#9a9aa0)}',
|
|
1574
|
+
'.mopsRdChevron{margin-left:auto;flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:999px;background:transparent;color:var(--dsw-alias-label-tertiary,#9a9aa0);cursor:pointer;transition:transform .15s ease}',
|
|
1575
|
+
// 列表:24px 行 + neutral 小按钮(非文字链)
|
|
1576
|
+
'.mopsRdList{display:flex;flex-direction:column;gap:8px;max-height:168px;overflow-y:auto;padding:1px 16px 12px}',
|
|
1577
|
+
'.mopsRdRow{display:flex;height:24px;min-width:0;align-items:center;gap:8px}',
|
|
1578
|
+
'.mopsRdRowText{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:400;line-height:20px;letter-spacing:-.04px;color:var(--dsw-alias-label-secondary,#a8a8ae)}',
|
|
1579
|
+
'.mopsRdRestore{flex:none;font:inherit;font-size:12px;line-height:18px;padding:2px 10px;border-radius:6px;cursor:pointer;color:var(--dsw-alias-label-primary,inherit);background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12));border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2));opacity:1}',
|
|
1580
|
+
'.mopsRdRestore:disabled{opacity:.45;cursor:default}',
|
|
1581
|
+
].join('')
|
|
1582
|
+
document.head.appendChild(tag)
|
|
1583
|
+
setStyleInjected(true)
|
|
1584
|
+
}, [styleInjected])
|
|
1585
|
+
|
|
1586
|
+
useEffect(() => {
|
|
1587
|
+
let alive = true
|
|
1588
|
+
const onChange = () => load()
|
|
1589
|
+
window.addEventListener(CHANGED_EVENT, onChange)
|
|
1590
|
+
const load = () => {
|
|
1591
|
+
const reqSeq = ++reqSeqRef.current // 发起序号(防慢响应覆盖快响应)
|
|
1592
|
+
// R1(复审实测):fetchMessagesJson 有 MSG_COALESCE_MS=1000 共用窗——
|
|
1593
|
+
// 既有 scan 观察器在 T+200ms 已取到**落盘批窗(200ms)之前**的旧响应,
|
|
1594
|
+
// 本路径若不先失效 memo,会原样复用它 → committed 仍是 false → 弹窗不清。
|
|
1595
|
+
// emitChanged() 自己就是先 invalidate 再发事件,这里同款红线。
|
|
1596
|
+
invalidateMessagesMemo()
|
|
1597
|
+
return fetchMessagesJson('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId))
|
|
1598
|
+
.then((data) => {
|
|
1599
|
+
if (!alive || !data || !data.ok) return
|
|
1600
|
+
// R2:只有**最新发起且仍未被更新的请求**的结果才允许落地。
|
|
1601
|
+
// 注意不能只看 reqSeq 是否等于当前发起序号 —— 后发请求若失败(fetchMessagesJson
|
|
1602
|
+
// 对非 2xx / ok!==true 会 throw,被下面 catch 吞掉),它的序号已被占用,
|
|
1603
|
+
// 先发那次**成功的**好响应就会被这里丢弃 → markers 停在旧值且无重试。
|
|
1604
|
+
// 故另记一个「已落地序号」,失败不回滚它。
|
|
1605
|
+
if (reqSeq !== reqSeqRef.current || reqSeq <= landedSeqRef.current) return
|
|
1606
|
+
landedSeqRef.current = reqSeq
|
|
1607
|
+
const msgs = data.messages || []
|
|
1608
|
+
const act = activeMarkers(msgs)
|
|
1609
|
+
setMarkers(act)
|
|
1610
|
+
// 0.5.2(P2):标题口径 = 标记 range 内 visible=false 的去重 seq 数
|
|
1611
|
+
const seen = new Set()
|
|
1612
|
+
for (const mk of act) {
|
|
1613
|
+
const start = mk.range && typeof mk.range.start === 'number' ? mk.range.start : mk.seq
|
|
1614
|
+
const end = mk.range && typeof mk.range.end === 'number' ? mk.range.end : start
|
|
1615
|
+
for (const m of msgs) {
|
|
1616
|
+
if (m && m.visible === false && typeof m.seq === 'number' && m.seq >= start && m.seq <= end) seen.add(m.seq)
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
setShadowCount(seen.size)
|
|
1620
|
+
})
|
|
1621
|
+
.catch(() => {})
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// F1(复审后重做):宿主在 user/message(source.kind==='user') 时会
|
|
1625
|
+
// `ctx.emit("api-session/activity", id, event.time)`
|
|
1626
|
+
// ($B/dsh-api-session-controller/lib/index.js:2890-2896),客户端
|
|
1627
|
+
// :3624 收到后抬高 summary.updatedAt(:3002 case "activity")——
|
|
1628
|
+
// 这是与服务端 markCommitted **完全同判据**的一等信号。
|
|
1629
|
+
//
|
|
1630
|
+
// 第一版用 MutationObserver 盯 DOM 属性,复审判定为次优:
|
|
1631
|
+
// - 判据靠近似(data-chat-flow-kind / 13:input-message 会命中注入行)
|
|
1632
|
+
// - 多挂第三个 body-subtree 观察器
|
|
1633
|
+
// - ready 门控在首拉失败时永久失效
|
|
1634
|
+
// 本组件本就经 useSessions 读 summary(running 同源依赖),零新增依赖。
|
|
1635
|
+
loadRef.current = load
|
|
1636
|
+
load()
|
|
1637
|
+
|
|
1638
|
+
return () => {
|
|
1639
|
+
alive = false
|
|
1640
|
+
loadRef.current = () => {}
|
|
1641
|
+
window.removeEventListener(CHANGED_EVENT, onChange)
|
|
1642
|
+
}
|
|
1643
|
+
}, [sessionId])
|
|
1644
|
+
|
|
1645
|
+
// F1 主路径:宿主 activity 信号抬高 updatedAt → 重拉。
|
|
1646
|
+
// lastActivityRef 让 mount 那次(与初始值相等)不触发,避免开会话双请求。
|
|
1647
|
+
//
|
|
1648
|
+
// R2(复审推翻原「闪一下」定性,实为**弹窗可能一直不消失**):
|
|
1649
|
+
// 宿主在 session.append 后**同步** emit api-session/activity
|
|
1650
|
+
// ($B/dsh-api-session-controller/lib/index.js:2890-2896),而落盘是
|
|
1651
|
+
// **异步批窗**($B/dsh-session/lib/index.js:1406 "hot path never blocks
|
|
1652
|
+
// on I/O";persistence-jsonl:187 buffered/batchTimer)。
|
|
1653
|
+
// 我们的 GET 读磁盘 + derivedCache 按 mtimeMs+size 失效(index.js:376),
|
|
1654
|
+
// 拉取链 ≈30-150ms **几乎必然早于落盘** → 服务端 markCommitted 看不到
|
|
1655
|
+
// 新 user 消息 → committed 仍是 false。
|
|
1656
|
+
// 而 updatedAt 每条消息只跳变一次、scan 走 fetchRows 碰不到 markers、
|
|
1657
|
+
// 宿主输入也不发 CHANGED_EVENT —— **没有第二次触发**,弹窗会一直留到
|
|
1658
|
+
// 下一条消息或一次插件写操作。这正是用户原话的病灶。
|
|
1659
|
+
// 故:有界重试,直到 dock 清空或重试耗尽(每条消息最多 3 次 GET)。
|
|
1660
|
+
// R1(审计):原为 `= undefined`,但若切会话时 activityAt **不变**
|
|
1661
|
+
// (同毫秒 createdAt / 都为 null 且新会话 summary 未进 store),
|
|
1662
|
+
// 下游 effect 的依赖未变 → 不执行 → 基线永远停在 undefined →
|
|
1663
|
+
// **用户切会话后的第一条消息撞上基线分支被吞,无任何 reload**(原 bug 上移一层)。
|
|
1664
|
+
// 重置到「当前」值:变了则基线=B(下游 B<=B 直接 return,不多拉);
|
|
1665
|
+
// 没变则基线=null,下次真实跳变 `x <= null` 为 false → 正常 load。
|
|
1666
|
+
// ⚠️ 必须**只有一个** effect。上一版拆成「重置 + 判定」两段、且重置段的依赖
|
|
1667
|
+
// 带上了 activityAt —— 同一次 commit 里 React 按声明顺序 flush,重置先把基线
|
|
1668
|
+
// 写成当前值,判定再拿到 T(B,B) → next <= prev → **活动信号被 100% 吃掉**,
|
|
1669
|
+
// F1 主路径彻底哑火(第四轮架构复审实跑抓出)。判据必须是「sessionId 真的
|
|
1670
|
+
// 变了」(sessRef),而不是「依赖数组里出现了 activityAt」。
|
|
1671
|
+
//
|
|
1672
|
+
// R1 原目标同样要达成:切会话时基线重置为**新会话当前值**(而非 undefined)——
|
|
1673
|
+
// 置 undefined 时若 activityAt 不变,下游不执行、基线永远 undefined,
|
|
1674
|
+
// 切会话后用户第一条消息撞上「建基线」分支被吞。
|
|
1675
|
+
useEffect(() => {
|
|
1676
|
+
if (sessRef.current !== sessionId) {
|
|
1677
|
+
sessRef.current = sessionId
|
|
1678
|
+
lastActivityRef.current = activityAt // 切会话:以新会话当前值为基线
|
|
1679
|
+
return
|
|
1680
|
+
}
|
|
1681
|
+
const step = activityTransition(lastActivityRef.current, activityAt)
|
|
1682
|
+
lastActivityRef.current = step.baseline
|
|
1683
|
+
if (step.reload) loadRef.current()
|
|
1684
|
+
}, [sessionId, activityAt])
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
// items 变化自动折叠(opencode createEffect 同款;必须在早退之前——Hooks 规则)
|
|
1688
|
+
useEffect(() => { setOpen(false) }, [markers && markers.length, markers && markers[0] && markers[0].seq])
|
|
1689
|
+
if (!markers || !markers.length) return null
|
|
1690
|
+
|
|
1691
|
+
const restoreRow = (row) => {
|
|
1692
|
+
if (restoring != null || running) return
|
|
1693
|
+
setRestoring(rowKey(row))
|
|
1694
|
+
Promise.resolve()
|
|
1695
|
+
.then(() => fetch('/api/message-ops/restore', {
|
|
1696
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1697
|
+
// 0.8.0:upToSeq 存在 → 按轮步进恢复(只重放到该 seq)
|
|
1698
|
+
body: JSON.stringify(row.upTo != null ? { sessionId, seq: row.seq, upToSeq: row.upTo } : { sessionId, seq: row.seq }),
|
|
1699
|
+
}))
|
|
1700
|
+
.then(async (res) => {
|
|
1701
|
+
let data = {}
|
|
1702
|
+
try { data = await res.json() } catch { /* keep {} */ }
|
|
1703
|
+
if (!res.ok || !data.ok) throw new Error(data.error || ('HTTP ' + res.status))
|
|
1704
|
+
notifyDone(t('done.restore', { n: String(data.restoredCount != null ? data.restoredCount : '?'), s: String(data.skipped != null ? data.skipped : 0) }))
|
|
1705
|
+
setRestoring(null)
|
|
1706
|
+
// 0.5.0:恢复后重建会话视图(重放消息立即可见)
|
|
1707
|
+
try {
|
|
1708
|
+
if (__uiWorkspace && typeof __uiWorkspace.openSession === 'function') __uiWorkspace.openSession(sessionId)
|
|
1709
|
+
} catch { /* fallthrough */ }
|
|
1710
|
+
// 0.9.1:重放的消息带**新 seq**,被恢复的行必须解除 hidden;dock 的
|
|
1711
|
+
// marker 列表要按权威数据重算(restoreComplete 会变 true)后重拉。
|
|
1712
|
+
emitChanged()
|
|
1713
|
+
for (const delay of [150, 700, 1600]) setTimeout(() => { if (__refreshSurface) __refreshSurface(sessionId) }, delay)
|
|
1714
|
+
const fresh = await fetchMessagesJson('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId)).catch(() => null)
|
|
1715
|
+
if (fresh && fresh.ok) setMarkers(activeMarkers(fresh.messages || []))
|
|
1716
|
+
})
|
|
1717
|
+
.catch((reason) => {
|
|
1718
|
+
notifyDone(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error')
|
|
1719
|
+
})
|
|
1720
|
+
.finally(() => setRestoring(null))
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
const headerKbd = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen((v) => !v) } }
|
|
1725
|
+
// 0.5.2(P2):标题报「被遮蔽消息数」而非标记数——一个 revert 可遮蔽数百条,
|
|
1726
|
+
// 用标记数会严重低报(0.4.2 文案引入的错口径);无 range 数据时回退标记数
|
|
1727
|
+
const label = t('dock.title', { n: String(shadowCount || markers.length) })
|
|
1728
|
+
const preview = markers[0] && markers[0].snippet ? markers[0].snippet : ''
|
|
1729
|
+
|
|
1730
|
+
// 0.8.0:行 = 待恢复"轮"(对齐 opencode 按轮步进);无可重放轮的标记
|
|
1731
|
+
// 保留旧式整段行(空区间 → 停用 notice 路径)。restoring 键区分同标记多轮。
|
|
1732
|
+
const rowKey = (row) => (row.upTo != null ? row.seq + ':' + row.upTo : String(row.seq))
|
|
1733
|
+
const rowEl = (row) => React.createElement('div', { key: rowKey(row), className: 'mopsRdRow' },
|
|
1734
|
+
React.createElement('span', { className: 'mopsRdRowText' }, '#' + row.seq + ' · ' + (row.snippet || '')),
|
|
1735
|
+
React.createElement('button', {
|
|
1736
|
+
type: 'button', className: 'mopsRdRestore', disabled: restoring != null || running,
|
|
1737
|
+
// 0.10.2 专家组评估结论:唯一「严重度=高」的真实缺口是 restore 为**重放**语义 ——
|
|
1738
|
+
// 消息带新 seq 落到会话末尾,**不会回到原位**(引擎 SurfaceOp 只有 append/replace,
|
|
1739
|
+
// 不存在解除遮蔽的原语)。用户点下去看到消息"搬家到底部"会以为出 bug,
|
|
1740
|
+
// 所以把语义写在按钮上,别让人靠猜。
|
|
1741
|
+
title: t('dock.restoreHint'),
|
|
1742
|
+
onClick: () => restoreRow(row),
|
|
1743
|
+
}, restoring === rowKey(row) ? t('dock.restoring') : t('dock.restore')),
|
|
1744
|
+
)
|
|
1745
|
+
const dockRows = []
|
|
1746
|
+
for (const mk of markers || []) {
|
|
1747
|
+
if (Array.isArray(mk.pendingTurns) && mk.pendingTurns.length > 0) {
|
|
1748
|
+
for (const t of mk.pendingTurns) {
|
|
1749
|
+
dockRows.push({ seq: mk.seq, upTo: t.upTo, snippet: t.preview + (t.count > 1 ? ' (+' + (t.count - 1) + ')' : '') })
|
|
1750
|
+
}
|
|
1751
|
+
} else {
|
|
1752
|
+
dockRows.push({ seq: mk.seq, snippet: mk.snippet })
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const chevron = React.createElement('button', {
|
|
1757
|
+
type: 'button', 'aria-label': open ? t('dock.collapse') : t('dock.expand'), 'aria-expanded': open,
|
|
1758
|
+
className: 'mopsRdChevron',
|
|
1759
|
+
onClick: (e) => { e.stopPropagation(); setOpen((v) => !v) },
|
|
1760
|
+
}, React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none',
|
|
1761
|
+
style: { transform: open ? 'rotate(0deg)' : 'rotate(180deg)', transition: 'transform .15s ease' } },
|
|
1762
|
+
React.createElement('path', { d: 'M4 6l4 4 4-4', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round' })))
|
|
1763
|
+
|
|
1764
|
+
return React.createElement('div', { className: 'mopsRd', role: 'region', 'aria-label': label },
|
|
1765
|
+
React.createElement('div', {
|
|
1766
|
+
className: 'mopsRdHead', role: 'button', tabIndex: 0,
|
|
1767
|
+
onClick: () => setOpen((v) => !v), onKeyDown: headerKbd,
|
|
1768
|
+
},
|
|
1769
|
+
React.createElement('span', { className: 'mopsRdIcon' },
|
|
1770
|
+
React.createElement('svg', { width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none' },
|
|
1771
|
+
React.createElement('path', { d: 'M2.5 8a5.5 5.5 0 1 0 1.6-3.9', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round' }),
|
|
1772
|
+
React.createElement('path', { d: 'M2.2 2.8v3h3', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round' }))),
|
|
1773
|
+
React.createElement('span', { className: 'mopsRdLabel' + (open ? '' : ' mopsRdLabelCollapsed') }, label),
|
|
1774
|
+
open ? null : React.createElement('span', { className: 'mopsRdPreview' }, preview),
|
|
1775
|
+
React.createElement('span', { style: { marginLeft: 'auto', flex: 'none' } }, chevron)),
|
|
1776
|
+
open ? React.createElement('div', { className: 'mopsRdList' }, dockRows.map(rowEl)) : null,
|
|
1777
|
+
)
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// --- 侧栏行菜单注入(DOM 级,同 session-delete 模式) ------------------------
|
|
1781
|
+
|
|
1782
|
+
function findOpenSessionRow() {
|
|
1783
|
+
var rows = document.querySelectorAll('[class*=sessionRow]')
|
|
1784
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1785
|
+
if (String(rows[i].className || '').indexOf('menuOpen') >= 0) return rows[i]
|
|
1786
|
+
}
|
|
1787
|
+
return null
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// --- 0.7.0 用户消息「回滚」按钮(DOM 注入,挂在官方动作行内)-------------------
|
|
1791
|
+
// 官方用户消息的动作行 = .xzv4MW_actions(含 Copy,height28,位于消息下方 16px),
|
|
1792
|
+
// 可见性由宿主 [data-actions-reveal] 控制。把按钮 append 进该行、与 Copy 同级同层,
|
|
1793
|
+
// 可见性天然继承宿主动作行(不再自己玩 hover)。0.6.0 的 absolute 覆盖教训见 README「历史事故记录」#2。
|
|
1794
|
+
function installUserRevertInjector() {
|
|
1795
|
+
if (window.__MOPS_USER_REVERT_INSTALLED) return
|
|
1796
|
+
window.__MOPS_USER_REVERT_INSTALLED = true
|
|
1797
|
+
var rowsCache = { sid: null, rows: null, at: 0 }
|
|
1798
|
+
__peekRowsCache = () => rowsCache
|
|
1799
|
+
var norm = (x) => String(x || '').replace(/\s+/g, '')
|
|
1800
|
+
|
|
1801
|
+
// 0.9.7(P4):messageId→seq / 全文索引挂在**同一个 dataSig** 上。
|
|
1802
|
+
// 旧实现每会话只写一次、仅失败时 delete —— 会话增长后新 assistant 消息的
|
|
1803
|
+
// messageId 不在索引里,`seq == null` → MsgSlotActions 直接 return null,
|
|
1804
|
+
// 那条消息旁的回撤/引用按钮**再也不渲染**。dataSig 变了就用手里这份全量
|
|
1805
|
+
// 载荷就地重建(零额外请求),并广播 SEQ_INDEX_EVENT 让已挂载的槽位重跑解析。
|
|
1806
|
+
var __seqDataSig = null
|
|
1807
|
+
var syncSeqIndex = (sid, messages, idx) => {
|
|
1808
|
+
const sig = sid + '/' + idx.total + '/' + idx.shadowed.size + '/' + idx.visibleTurns.size
|
|
1809
|
+
if (sig === __seqDataSig) return
|
|
1810
|
+
__seqDataSig = sig
|
|
1811
|
+
const index = new Map()
|
|
1812
|
+
const full = new Map()
|
|
1813
|
+
for (const m of (messages || [])) {
|
|
1814
|
+
if (!m || m.id == null) continue
|
|
1815
|
+
const k = String(m.id)
|
|
1816
|
+
// 同 id 取**最早** seq —— 与槽位自己的 buildSeqIndex 口径一致
|
|
1817
|
+
// (隐藏判据 buildIndex 取最新,两套语义不能混)。
|
|
1818
|
+
if (!index.has(k)) index.set(k, m.seq)
|
|
1819
|
+
if (m.fullText) full.set(k, m.fullText)
|
|
1820
|
+
}
|
|
1821
|
+
for (const key of [sid, 'session-' + sid]) {
|
|
1822
|
+
__seqIndexCache.set(key, index)
|
|
1823
|
+
__fullTextCache.set(key, full)
|
|
1824
|
+
}
|
|
1825
|
+
try { window.dispatchEvent(new CustomEvent(SEQ_INDEX_EVENT)) } catch { /* non-browser */ }
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
var fetchRows = (sidIn) => {
|
|
1829
|
+
// 0.9.7:统一按**裸 uuid** 作缓存键。调用方有的传 `session-xxx`(scan /
|
|
1830
|
+
// /undo 的 rawSid)有的传裸 uuid(/redo 的 bareSessionId)—— 混用会让
|
|
1831
|
+
// rowsCache.sid 在两个键之间来回横跳,每次都是缓存未命中(白白多发 GET)。
|
|
1832
|
+
const sid = bareSessionId(sidIn)
|
|
1833
|
+
if (rowsCache.sid === sid && rowsCache.rows && Date.now() - rowsCache.at < 8000) return Promise.resolve(rowsCache)
|
|
1834
|
+
return fetchMessagesJson('/api/message-ops/messages?sessionId=' + encodeURIComponent(sid))
|
|
1835
|
+
.then((d) => {
|
|
1836
|
+
// 宿主会把 <system-reminder> / runtime context 等也记为 role=user 的行,
|
|
1837
|
+
// DOM 里并不渲染成"我的消息"块 —— 必须滤掉,否则按序配对会错位(0.7.0 根因)。
|
|
1838
|
+
// 注意:对话框的「可回滚目标」列表仍应过滤 visible(那是另一套语义)。
|
|
1839
|
+
// 稳妥做法本该在服务端打 injected 标志,先在客户端补齐正则。
|
|
1840
|
+
// 判据一律用**原始 snippet**(norm() 删掉全部空白,正则必然失配)。
|
|
1841
|
+
// 0.9.0 / 0.9.6 两轮事故复盘 + 120 份日志普查样本见 README「历史事故记录」#3。
|
|
1842
|
+
// 关键约束:id 后必须跟得上具体形态([\w-]+ / [0-9a-f]{8}-),
|
|
1843
|
+
// 否则 `Background job 是我自己的问题` 这种真人输入会被误杀
|
|
1844
|
+
// (曾用 ^background job\b,测试当场抓到这个误杀)。
|
|
1845
|
+
// 样本里没出现过的模式(已停止 / User feedback:)一律**不加** —— 没证据就不猜。
|
|
1846
|
+
const injected = (raw) => /^<system-reminder|current\s+runtime\s+context|^<system-Reminder|^background job\s+[\w-]+|^Background subagent\s+[0-9a-f]{8}-|^Agent\s+[0-9a-f]{8}-|^\[model changed:/i.test(String(raw || ''))
|
|
1847
|
+
const rows = ((d && d.messages) || [])
|
|
1848
|
+
.filter((m) => m && m.role === 'user')
|
|
1849
|
+
.map((m) => ({ seq: m.seq, sn: norm(m.snippet), raw: String(m.snippet == null ? '' : m.snippet), fullText: m.fullText ?? null, visible: m.visible !== false }))
|
|
1850
|
+
.filter((r) => r.sn && !injected(r.raw))
|
|
1851
|
+
// 0.9.1:同时产出 node-key 查表索引(隐藏 transcript 用)
|
|
1852
|
+
const idx = buildIndex(d)
|
|
1853
|
+
rowsCache = { sid: sid, rows: rows, idx: idx, at: Date.now() }
|
|
1854
|
+
syncSeqIndex(sid, d && d.messages, idx)
|
|
1855
|
+
return rowsCache
|
|
1856
|
+
})
|
|
1857
|
+
.catch(() => (rowsCache.sid === sid ? rowsCache : null))
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// ═══ 0.9.1 宿主 node-key ↔ 日志 seq 的精确映射 ═══════════════════════════
|
|
1861
|
+
// dsh transcript 刻意不折叠(surface.d.ts:51-63 "replacement copies stay
|
|
1862
|
+
// model-only"),回滚只能在渲染层按 `data-chat-node-key` 精确查表(零启发式)。
|
|
1863
|
+
// 背景长文与「40 字符前缀匹配」事故见 README「0.9.1 / 0.9.2 · 回滚「真的生效了」」。
|
|
1864
|
+
var parseNodeKey = (k) => {
|
|
1865
|
+
var m = /^(\d+):/.exec(k || '');
|
|
1866
|
+
if (!m) return null;
|
|
1867
|
+
var digits = m[1].length;
|
|
1868
|
+
var kindLen = Number(m[1]);
|
|
1869
|
+
var start = digits + 1;
|
|
1870
|
+
if (!Number.isSafeInteger(kindLen) || kindLen <= 0 || k.length < start + kindLen) return null;
|
|
1871
|
+
return { kind: k.slice(start, start + kindLen), id: k.slice(start + kindLen) };
|
|
1872
|
+
};
|
|
1873
|
+
|
|
1874
|
+
/**
|
|
1875
|
+
* 从 /api/message-ops/messages 的响应构建全部查表。
|
|
1876
|
+
* 两条铁律:① 索引只认唯一 id;② 缺数据一律返回 undefined(调用方保持可见)。
|
|
1877
|
+
*/
|
|
1878
|
+
var buildIndex = (payload) => {
|
|
1879
|
+
var list = (payload && payload.messages) || []
|
|
1880
|
+
// 0.9.1:回合页脚/过程条不是消息,用 turn/end 的 seq 定位(边界常落在 turn 内部,
|
|
1881
|
+
// 用 turn/start 会漏判该回合的页脚)
|
|
1882
|
+
var turnEnd = (payload && payload.turnEndSeq) || null;
|
|
1883
|
+
var byMessageId = new Map();
|
|
1884
|
+
var byTurnStep = new Map();
|
|
1885
|
+
var byCallId = new Map();
|
|
1886
|
+
var byTurn = new Map();
|
|
1887
|
+
var shadowed = new Set(); // 落在 replace 区间内 = 已被回滚(transcript 藏行判据)
|
|
1888
|
+
var visibleTurns = new Set(); // 含至少一条**未被遮蔽**消息的 turn 号(回合 chrome)
|
|
1889
|
+
var dupByMessageId = new Map();
|
|
1890
|
+
for (var i = 0; i < list.length; i++) {
|
|
1891
|
+
var m = list[i];
|
|
1892
|
+
if (!m || typeof m.seq !== 'number') continue;
|
|
1893
|
+
if (m.id != null) {
|
|
1894
|
+
var k = String(m.id);
|
|
1895
|
+
// restore 是「重放」语义 → 原件与副本可能同 id 并存。
|
|
1896
|
+
// 0.9.1:不再「保留最早」,而是收集**全部** seq,隐藏判据取「任一 seq 越过
|
|
1897
|
+
// fence」。回放副本才是当前 surface 上的那条,用最早的 seq 判会让
|
|
1898
|
+
// 「恢复后再回滚」漏藏(实测 fence=70、副本 seq=70、原件 seq=54)。
|
|
1899
|
+
if (byMessageId.has(k)) {
|
|
1900
|
+
const arr = dupByMessageId.get(k) || [byMessageId.get(k)]
|
|
1901
|
+
arr.push(m.seq)
|
|
1902
|
+
dupByMessageId.set(k, arr)
|
|
1903
|
+
byMessageId.set(k, m.seq) // 指向最新的
|
|
1904
|
+
} else { byMessageId.set(k, m.seq) }
|
|
1905
|
+
}
|
|
1906
|
+
if (m.type === 'assistant/message' && m.turn != null && m.step != null) {
|
|
1907
|
+
byTurnStep.set(m.turn + ':' + m.step, m.seq);
|
|
1908
|
+
}
|
|
1909
|
+
if (m.type === 'tool/call' && m.id != null) byCallId.set(String(m.id), m.seq);
|
|
1910
|
+
if (m.turn != null && !byTurn.has(String(m.turn))) byTurn.set(String(m.turn), m.seq);
|
|
1911
|
+
// 0.9.6:藏行判据用 shadowed(replace 区间),**不用** m.visible(模型 surface)。
|
|
1912
|
+
// visible 在 live 路径下对 tool/call 恒 false → 拿它当判据会把整页工具行全藏,
|
|
1913
|
+
// 故这里连集合都不建(判据样本见 test/surface-visibility.test.js)。
|
|
1914
|
+
if (m.shadowed === true) shadowed.add(m.seq);
|
|
1915
|
+
else if (m.turn != null) visibleTurns.add(String(m.turn));
|
|
1916
|
+
}
|
|
1917
|
+
// turnEnd 优先:它对「该 turn 内一条消息都没有」的情况仍然有效
|
|
1918
|
+
if (turnEnd) for (const k in turnEnd) if (Object.prototype.hasOwnProperty.call(turnEnd, k)) byTurn.set(k, turnEnd[k])
|
|
1919
|
+
return { byMessageId, dupByMessageId, byTurnStep, byCallId, byTurn, shadowed, visibleTurns, total: list.length };
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
/** node-key → { seq, all }(all = 同 id 的全部 seq,处理 restore 重放副本)。 */
|
|
1923
|
+
var seqOfNodeKey = (key, idx) => {
|
|
1924
|
+
var parsed = parseNodeKey(key);
|
|
1925
|
+
if (!parsed || !idx) return null;
|
|
1926
|
+
var id = parsed.id;
|
|
1927
|
+
switch (parsed.kind) {
|
|
1928
|
+
case 'input-message': // user / steering / turn-trigger
|
|
1929
|
+
case 'developer-message':
|
|
1930
|
+
case 'trajectory-note': {
|
|
1931
|
+
const hit = idx.byMessageId.get(id)
|
|
1932
|
+
if (hit === undefined) return null
|
|
1933
|
+
return { kind: parsed.kind, seq: hit, all: idx.dupByMessageId.get(id) || [hit] }
|
|
1934
|
+
}
|
|
1935
|
+
case 'assistant-step': {
|
|
1936
|
+
const hit = idx.byTurnStep.get(id)
|
|
1937
|
+
return hit === undefined ? null : { kind: parsed.kind, seq: hit, all: [hit] }
|
|
1938
|
+
}
|
|
1939
|
+
case 'tool-call': {
|
|
1940
|
+
const hit = idx.byCallId.get(id)
|
|
1941
|
+
return hit === undefined ? null : { kind: parsed.kind, seq: hit, all: [hit] }
|
|
1942
|
+
}
|
|
1943
|
+
case 'turn-tail':
|
|
1944
|
+
case 'turn-process':
|
|
1945
|
+
case 'turn-error':
|
|
1946
|
+
case 'turn-max-tokens':
|
|
1947
|
+
case 'model-retry': {
|
|
1948
|
+
const hit = idx.byTurn.get(id)
|
|
1949
|
+
// 这类是**回合 chrome**,id 就是 turn 号 —— 判据要按「这个 turn 还有
|
|
1950
|
+
// 没有可见消息」而不是按 seq,因为 turn/end 本身多半不在 surface 上。
|
|
1951
|
+
return hit === undefined ? null : { kind: parsed.kind, seq: hit, all: [hit], turn: id }
|
|
1952
|
+
}
|
|
1953
|
+
default:
|
|
1954
|
+
return null; // compaction / command / workflow-run 等不参与
|
|
1955
|
+
}
|
|
1956
|
+
};
|
|
1957
|
+
|
|
1958
|
+
/**
|
|
1959
|
+
* 把 transcript 行按模型 surface 可见性打上 hidden。
|
|
1960
|
+
* 用 `hidden="until-found"`(与宿主 useSearchableHidden 同款):Ctrl+F 仍能搜到,
|
|
1961
|
+
* 且宿主的分页锚点选择器 `[data-chat-paging-anchor]:not(:empty):not([hidden])`
|
|
1962
|
+
* 会自动跳过这些行,滚动恢复不受影响。
|
|
1963
|
+
*
|
|
1964
|
+
* ⚠️ `seq == null` ⇒ **移除 hidden**(保持可见)。把「查不到」当成「被遮蔽」
|
|
1965
|
+
* 是唯一会误藏用户内容的路径。
|
|
1966
|
+
*/
|
|
1967
|
+
var applySurfaceVisibility = (root, idx) => {
|
|
1968
|
+
if (!idx) return { hidden: 0, unresolved: 0, shown: 0 };
|
|
1969
|
+
// 0.9.7(P2-1):root 既可传 document/元素(调用方自查),也可直接传
|
|
1970
|
+
// 已经查好的行集合 —— ensureAssign 每次都要算 rows.length 当签名,
|
|
1971
|
+
// 传集合避免同一次扫描里 querySelectorAll 跑两遍。
|
|
1972
|
+
var rows = (root && typeof root.querySelectorAll === 'function')
|
|
1973
|
+
? root.querySelectorAll('[data-chat-node-key]')
|
|
1974
|
+
: (root || document.querySelectorAll('[data-chat-node-key]'));
|
|
1975
|
+
var hidden = 0, unresolved = 0, shown = 0;
|
|
1976
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1977
|
+
var row = rows[i];
|
|
1978
|
+
// 0.9.6 藏行判据 = **replace 区间(shadowed)**;revertFences→visible→shadowed
|
|
1979
|
+
// 演进史(含 4792/4793 全藏事故)见 README「历史事故记录」#4。
|
|
1980
|
+
var hit = seqOfNodeKey(row.getAttribute('data-chat-node-key'), idx);
|
|
1981
|
+
// 0.9.7(P2-1)幂等跳写:判据与执行时机**一字未改**,只在
|
|
1982
|
+
// 「目标状态 ≠ 当前状态」时才碰属性 —— 省掉全表几百次无效写。
|
|
1983
|
+
if (hit === null) {
|
|
1984
|
+
unresolved++;
|
|
1985
|
+
if (row.hasAttribute('hidden')) row.removeAttribute('hidden');
|
|
1986
|
+
continue;
|
|
1987
|
+
}
|
|
1988
|
+
var shouldHide;
|
|
1989
|
+
if (hit.turn !== undefined) {
|
|
1990
|
+
// 回合 chrome:本 turn 还有未被遮蔽的消息就露出
|
|
1991
|
+
shouldHide = !idx.visibleTurns.has(String(hit.turn));
|
|
1992
|
+
} else {
|
|
1993
|
+
// 消息/工具类:**是否落在 replace 区间内**(= 被回滚掉了)。
|
|
1994
|
+
// ⚠️ 不能用 visible(模型 surface)——live 路径下 tool/call 恒不在 surface,
|
|
1995
|
+
// 会把整页工具行全藏(实测 157 行藏 152 行)。
|
|
1996
|
+
shouldHide = idx.shadowed.has(hit.seq);
|
|
1997
|
+
}
|
|
1998
|
+
if (shouldHide) {
|
|
1999
|
+
hidden++;
|
|
2000
|
+
if (row.getAttribute('hidden') !== 'until-found') row.setAttribute('hidden', 'until-found');
|
|
2001
|
+
} else {
|
|
2002
|
+
shown++;
|
|
2003
|
+
if (row.hasAttribute('hidden')) row.removeAttribute('hidden');
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
return { hidden, unresolved, shown };
|
|
2007
|
+
};
|
|
2008
|
+
|
|
2009
|
+
// 用户消息锚点:优先官方 Sixlwa_userRow(含 .xzv4MW_actions),回退 flow-kind 属性
|
|
2010
|
+
// 锚点 = 每条用户消息自己的容器(一个块只算一次):
|
|
2011
|
+
// 从官方动作行 .xzv4MW_actions 反查最近的消息容器,最贴近"一条消息"的粒度。
|
|
2012
|
+
var userAnchors = () => {
|
|
2013
|
+
const out = []
|
|
2014
|
+
const seen = new Set()
|
|
2015
|
+
const add = (el) => { if (el && !seen.has(el)) { seen.add(el); out.push(el) } }
|
|
2016
|
+
const rows = Array.from(document.querySelectorAll('[class*="xzv4MW_actions"]'))
|
|
2017
|
+
for (const r of rows) {
|
|
2018
|
+
const block = r.closest('[data-chat-flow-kind="user"]')
|
|
2019
|
+
|| r.closest('[class*="_userRow"], [class*="_userStack"], [class*="_bubble"]')
|
|
2020
|
+
|| r.parentElement
|
|
2021
|
+
// 0.9.0:只按**稳定属性** + hash-immune 类名后缀判定,不再匹配 CSS-module
|
|
2022
|
+
// 生成的前缀(`Sixlwa_*`)。dsh 0.2.0-rc.2 的哈希已变为 `EvIC1a_flowItem`,
|
|
2023
|
+
// 旧前缀正则把所有块都滤掉 → anchors 为空 → 按钮拿不到 seq → 点击必失败。
|
|
2024
|
+
//
|
|
2025
|
+
// ⚠️ 不要把 `_flowItem` 当兜底:那是**通用**流块,官方把 branch 按钮��放在
|
|
2026
|
+
// `data-chat-flow-kind="turn-tail"` 的 _flowItem 里(实测会把 turn-tail 当成
|
|
2027
|
+
// 用户消息,给它挂上回滚按钮)。只用 user 属性 + user 类名后缀。
|
|
2028
|
+
if (block) {
|
|
2029
|
+
const flow = block.getAttribute('data-chat-flow-kind')
|
|
2030
|
+
const isUser = flow === 'user' || /_userRow|_userStack|_bubble/.test(block.className || '')
|
|
2031
|
+
if (isUser) add(block)
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
// 回退:属性锚点
|
|
2035
|
+
for (const k of document.querySelectorAll('[data-chat-flow-kind="user"]')) add(k)
|
|
2036
|
+
return out
|
|
2037
|
+
}
|
|
2038
|
+
// 0.9.0:把配对结果反映到按钮可用性上。
|
|
2039
|
+
// 配不上(data-mops-unmatched)→ 禁用 + 说明原因,绝不留装饰按钮。
|
|
2040
|
+
var syncEnabled = (block, btn) => {
|
|
2041
|
+
if (!btn) return
|
|
2042
|
+
const ok = block.getAttribute('data-mops-seq')
|
|
2043
|
+
if (ok) {
|
|
2044
|
+
btn.disabled = false
|
|
2045
|
+
btn.title = __t('slot.revert')
|
|
2046
|
+
btn.removeAttribute('data-mops-unmatched')
|
|
2047
|
+
} else {
|
|
2048
|
+
btn.disabled = true
|
|
2049
|
+
btn.title = __t('slot.revertUnmatched')
|
|
2050
|
+
btn.setAttribute('data-mops-unmatched', '1')
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
var assignSeqs = (sid, cache, anchors) => {
|
|
2054
|
+
const idx = cache && cache.idx
|
|
2055
|
+
if (!idx) return
|
|
2056
|
+
// 0.9.1:node-key 查表,替代旧的 40 字符前缀包含匹配(实测 4 条错 2 条)
|
|
2057
|
+
for (const b of (anchors || userAnchors())) {
|
|
2058
|
+
b.removeAttribute('data-mops-seq')
|
|
2059
|
+
b.removeAttribute('data-mops-unmatched')
|
|
2060
|
+
const hit = seqOfNodeKey(b.getAttribute('data-chat-node-key'), idx)
|
|
2061
|
+
const seq = hit === null ? null : hit.seq
|
|
2062
|
+
if (seq === null || seq === undefined) {
|
|
2063
|
+
b.setAttribute('data-mops-unmatched', '1')
|
|
2064
|
+
syncEnabled(b, b.querySelector && b.querySelector('.mopsUserRevert'))
|
|
2065
|
+
continue
|
|
2066
|
+
}
|
|
2067
|
+
b.setAttribute('data-mops-seq', String(seq))
|
|
2068
|
+
syncEnabled(b, b.querySelector && b.querySelector('.mopsUserRevert'))
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
// 按钮可用性在 assignSeqs 内部**同步**更新(与打标同一次循环)——
|
|
2072
|
+
// 之前放在 fetch 回调里做事后全局扫描,会与 DOM 注入/重排竞态,导致
|
|
2073
|
+
// 块已打上 seq 而按钮仍是禁用态(实测:block seq=[8,54],按钮 disabled=true)。
|
|
2074
|
+
// 按钮可用性与 transcript 隐藏都在这里统一落地(同一批数据,避免竞态)
|
|
2075
|
+
//
|
|
2076
|
+
// 0.9.7(P2-1)三签名门控。scan **无条件**调 ensureAssign 是故意的
|
|
2077
|
+
// (早先做成「只在没打标时刷」→ 界面停在旧状态),所以门控只放在
|
|
2078
|
+
// assignSeqs 的全量 DOM 改写上,三者全等才跳过:
|
|
2079
|
+
// dataSig —— idx.total / shadowed.size / visibleTurns.size(数据变了必须重算)
|
|
2080
|
+
// domSig —— transcript 行数(行集合变了必须重算)
|
|
2081
|
+
// anchorSig —— 每个 user 块的 node-key + 打标状态(宿主重建块 / CHANGED
|
|
2082
|
+
// 摘牌后 node-key 相同但打标为空,靠这里发现)
|
|
2083
|
+
// 🔴 applySurfaceVisibility 仍然**无条件**执行,藏行判据与时机一字未改。
|
|
2084
|
+
var assignSig = null
|
|
2085
|
+
var anchorSigOf = (anchors) => {
|
|
2086
|
+
let s = ''
|
|
2087
|
+
for (let i = 0; i < anchors.length; i++) {
|
|
2088
|
+
const b = anchors[i]
|
|
2089
|
+
s += (b.getAttribute('data-chat-node-key') || '') + '=' +
|
|
2090
|
+
(b.getAttribute('data-mops-seq') || (b.hasAttribute('data-mops-unmatched') ? '?' : '-')) + ','
|
|
2091
|
+
}
|
|
2092
|
+
return s
|
|
2093
|
+
}
|
|
2094
|
+
var sigOf = (sid, dataSig, rowsLen, anchors) =>
|
|
2095
|
+
bareSessionId(sid) + '|' + dataSig + '|' + rowsLen + '|' + anchorSigOf(anchors)
|
|
2096
|
+
var ensureAssign = (sid) => fetchRows(sid).then((cache) => {
|
|
2097
|
+
if (!cache || !cache.idx) return cache
|
|
2098
|
+
const idx = cache.idx
|
|
2099
|
+
const rows = document.querySelectorAll('[data-chat-node-key]')
|
|
2100
|
+
const dataSig = idx.total + '/' + idx.shadowed.size + '/' + idx.visibleTurns.size
|
|
2101
|
+
const anchors = userAnchors()
|
|
2102
|
+
const before = sigOf(sid, dataSig, rows.length, anchors)
|
|
2103
|
+
if (before !== assignSig) {
|
|
2104
|
+
assignSeqs(sid, cache, anchors)
|
|
2105
|
+
// 存**改写之后**的真实签名:下一次直接相等就跳过,不用跑两轮才收敛
|
|
2106
|
+
assignSig = sigOf(sid, dataSig, rows.length, anchors)
|
|
2107
|
+
}
|
|
2108
|
+
applySurfaceVisibility(rows, idx)
|
|
2109
|
+
return cache
|
|
2110
|
+
})
|
|
2111
|
+
// 0.7.0:点击时先**同步完成**配对(打标是异步 fetch 的结果,
|
|
2112
|
+
// 早先点在未打标的按钮上会静默失败 = 用户反馈的"点了没用")。
|
|
2113
|
+
var runUserRevert = (btn, block) => {
|
|
2114
|
+
const sid = __currentSessionId
|
|
2115
|
+
if (!sid) return
|
|
2116
|
+
btn.disabled = true
|
|
2117
|
+
Promise.resolve()
|
|
2118
|
+
.then(() => ensureAssign(sid))
|
|
2119
|
+
.then(() => fetchRows(sid))
|
|
2120
|
+
.then((cache) => {
|
|
2121
|
+
const rows = cache && cache.rows
|
|
2122
|
+
// 0.9.1:seq 来自 node-key 精确查表,**不再**做文本前缀匹配验证
|
|
2123
|
+
// (旧的前缀包含匹配实测 4 条错 2 条 —— "继续" 命中 "继续,…")。
|
|
2124
|
+
const seq = Number(block.getAttribute('data-mops-seq'))
|
|
2125
|
+
const row = rows && rows.find((r) => r.seq === seq)
|
|
2126
|
+
if (!row) {
|
|
2127
|
+
ensureAssign(sid)
|
|
2128
|
+
notifyDone(__t('errorPrefix') + (__t('revert.unresolved') || 'revert target verification failed'), 'error')
|
|
2129
|
+
return
|
|
2130
|
+
}
|
|
2131
|
+
btn.disabled = true
|
|
2132
|
+
Promise.resolve()
|
|
2133
|
+
.then(() => fetch('/api/message-ops/revert', {
|
|
2134
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
2135
|
+
body: JSON.stringify({ sessionId: sid, seq: row.seq }),
|
|
2136
|
+
}))
|
|
2137
|
+
.then(async (res) => {
|
|
2138
|
+
let data = {}
|
|
2139
|
+
try { data = await res.json() } catch { /* keep {} */ }
|
|
2140
|
+
if (!res.ok || !data.ok) throw new Error(data.error || ('HTTP ' + res.status))
|
|
2141
|
+
notifyDone(__t('done.revert'))
|
|
2142
|
+
// 0.9.6:统一回填出口 —— 这个入口原先也不回填
|
|
2143
|
+
refillComposer(sid, row.seq)
|
|
2144
|
+
emitChanged()
|
|
2145
|
+
try {
|
|
2146
|
+
if (__uiWorkspace && typeof __uiWorkspace.openSession === 'function') __uiWorkspace.openSession(sid)
|
|
2147
|
+
} catch { /* 视图重建失败不阻断 */ }
|
|
2148
|
+
// 0.9.1:视图重建后重新拉权威可见性并隐藏被遮蔽的行。
|
|
2149
|
+
// openSession 会重挂 DOM,旧节点上的 hidden 属性随之失效,必须重刷。
|
|
2150
|
+
if (rowsCache) rowsCache.at = 0
|
|
2151
|
+
for (const delay of [150, 700, 1600]) setTimeout(() => { if (__refreshSurface) __refreshSurface(sid) }, delay)
|
|
2152
|
+
})
|
|
2153
|
+
.catch((reason) => {
|
|
2154
|
+
notifyDone(__t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error')
|
|
2155
|
+
})
|
|
2156
|
+
.finally(() => { btn.disabled = false })
|
|
2157
|
+
})
|
|
2158
|
+
}
|
|
2159
|
+
var ensureBtn = (block) => {
|
|
2160
|
+
// 官方动作行(含 Copy):把按钮放到这一行里,与 Copy 同级
|
|
2161
|
+
let host = block.querySelector('[class*="xzv4MW_actions"]')
|
|
2162
|
+
if (!host) {
|
|
2163
|
+
host = document.createElement('div')
|
|
2164
|
+
host.className = 'mopsUserActionsRow'
|
|
2165
|
+
block.appendChild(host)
|
|
2166
|
+
}
|
|
2167
|
+
let btn = host.querySelector('.mopsUserRevert')
|
|
2168
|
+
if (btn) return btn
|
|
2169
|
+
btn = document.createElement('button')
|
|
2170
|
+
btn.type = 'button'
|
|
2171
|
+
btn.className = 'mopsUserRevert'
|
|
2172
|
+
btn.title = __t('slot.revert')
|
|
2173
|
+
btn.setAttribute('aria-label', __t('slot.revert'))
|
|
2174
|
+
btn.innerHTML = '<svg width="15" height="15" viewBox="0 0 16 16" fill="none"><path d="M2.5 8a5.5 5.5 0 1 0 1.6-3.9" stroke="currentColor" stroke-width="1.2"/><path d="M2.2 2.8v3h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>'
|
|
2175
|
+
btn.addEventListener('click', (e) => {
|
|
2176
|
+
e.preventDefault(); e.stopPropagation()
|
|
2177
|
+
if (btn.disabled) return
|
|
2178
|
+
runUserRevert(btn, block)
|
|
2179
|
+
})
|
|
2180
|
+
host.appendChild(btn)
|
|
2181
|
+
// 0.9.7(P2-1):新按钮按块的**当前**打标状态立刻同步一次。
|
|
2182
|
+
// 门控之后 assignSeqs 可能被跳过(签名未变),靠这里保证「宿主重建了
|
|
2183
|
+
// 动作行 → 按钮重新挂上」这种情况仍然是 disabled/title 正确的。
|
|
2184
|
+
syncEnabled(block, btn)
|
|
2185
|
+
return btn
|
|
2186
|
+
}
|
|
2187
|
+
// 0.9.7(P0-2 读合并):把 150/700/1600ms 三档并成**一次**真网络请求,
|
|
2188
|
+
// 落点 = 本 burst 第一档 + 1500ms ≈ 原来的 1600ms 那一档。
|
|
2189
|
+
// 🔴 红线:这一档是「视图重建后重算隐藏」的兜底,绝不能砍成 0。
|
|
2190
|
+
// 调用当下立刻 rowsCache.at = 0 —— openSession 触发的那次 scan 因此能用
|
|
2191
|
+
// 共享窗口里的新鲜载荷先把隐藏刷对,不必等兜底那一发。
|
|
2192
|
+
var refreshTimer = null
|
|
2193
|
+
var refreshDeadline = 0
|
|
2194
|
+
__refreshSurface = (sid) => {
|
|
2195
|
+
const id = sid || __currentSessionId
|
|
2196
|
+
if (!id) return
|
|
2197
|
+
if (rowsCache) rowsCache.at = 0 // 强制重取权威数据
|
|
2198
|
+
const now = Date.now()
|
|
2199
|
+
if (!refreshTimer) refreshDeadline = now + 1500
|
|
2200
|
+
clearTimeout(refreshTimer)
|
|
2201
|
+
refreshTimer = setTimeout(() => {
|
|
2202
|
+
refreshTimer = null
|
|
2203
|
+
try {
|
|
2204
|
+
// 会话已切走就别把旧会话的 idx 刷到新会话的 DOM 上
|
|
2205
|
+
if (bareSessionId(__currentSessionId) !== bareSessionId(id)) return
|
|
2206
|
+
invalidateMessagesMemo() // 兜底档必须是真网络,不许吃缓存
|
|
2207
|
+
if (rowsCache) rowsCache.at = 0
|
|
2208
|
+
fetchRows(id)
|
|
2209
|
+
.then((cache) => { if (cache && cache.idx) applySurfaceVisibility(document, cache.idx) })
|
|
2210
|
+
.catch(() => {})
|
|
2211
|
+
} catch { /* noop */ }
|
|
2212
|
+
}, Math.max(0, refreshDeadline - now))
|
|
2213
|
+
}
|
|
2214
|
+
var scan = () => {
|
|
2215
|
+
try {
|
|
2216
|
+
const sid = __currentSessionId
|
|
2217
|
+
if (!sid) return
|
|
2218
|
+
const blocks = userAnchors()
|
|
2219
|
+
for (const b of blocks) ensureBtn(b)
|
|
2220
|
+
// 0.9.1:**始终**刷新(原先只在按钮未打标时刷)——
|
|
2221
|
+
// 打标后 transcript 再变化(openSession 重建 / 分页 / restore 重放)
|
|
2222
|
+
// 就再也不会重算隐藏,导致「界面停在旧状态,必须重载页面才对」。
|
|
2223
|
+
ensureAssign(sid)
|
|
2224
|
+
} catch { /* 注入失败不影响会话 */ }
|
|
2225
|
+
}
|
|
2226
|
+
// 触发:hover / 滚动 / DOM 变化 / 变更事件
|
|
2227
|
+
document.addEventListener('mouseover', (e) => {
|
|
2228
|
+
try {
|
|
2229
|
+
if (!e || !e.target || !e.target.closest) return
|
|
2230
|
+
if (!__currentSessionId) return
|
|
2231
|
+
const block = e.target.closest('[class*="Sixlwa_userRow"], [data-chat-flow-kind="user"]')
|
|
2232
|
+
if (!block) return
|
|
2233
|
+
ensureBtn(block)
|
|
2234
|
+
if (!block.hasAttribute('data-mops-seq')) ensureAssign(__currentSessionId)
|
|
2235
|
+
} catch { /* noop */ }
|
|
2236
|
+
}, { passive: true })
|
|
2237
|
+
window.addEventListener('scroll', scan, { passive: true })
|
|
2238
|
+
try {
|
|
2239
|
+
// 0.9.7(P1-2)回调过滤 + maxWait。
|
|
2240
|
+
// ① 过滤:整个 document.body 的 childList 海量 mutation(流式文本、
|
|
2241
|
+
// chunk 重排)里,绝大多数与「打标 / 藏行」无关。只认本插件真正
|
|
2242
|
+
// 关心的四类节点。🔴 判据宁可**漏判放行**(多扫一次 ~0.4ms)也不可
|
|
2243
|
+
// 误杀 —— 漏了就是「遮蔽行不更新」。新增子树可能是祖先容器,
|
|
2244
|
+
// matches() 与 querySelector() 两者都试;过滤自身抛错一律放行。
|
|
2245
|
+
// ② 防抖 trailing 200ms + maxWait 1000ms:纯 trailing 在连续 mutation
|
|
2246
|
+
// 下会**无限推迟**,流式输出时新挂载的遮蔽行会长时间保持可见 ——
|
|
2247
|
+
// 这是正确性问题,不只是性能。
|
|
2248
|
+
// ③ attributeFilter 只盯 data-chat-node-key:本插件不写该属性(写的是
|
|
2249
|
+
// data-mops-seq / hidden),所以不会被自己触发成环。
|
|
2250
|
+
var MO_SEL = '[data-chat-node-key],[data-chat-flow-kind="user"],[class*="xzv4MW_actions"],[class*="_userRow"]'
|
|
2251
|
+
var scanPendingSince = 0
|
|
2252
|
+
var scheduleScan = () => {
|
|
2253
|
+
var now = Date.now()
|
|
2254
|
+
if (!scan._t) {
|
|
2255
|
+
scanPendingSince = now
|
|
2256
|
+
} else if (now - scanPendingSince >= 1000) {
|
|
2257
|
+
clearTimeout(scan._t)
|
|
2258
|
+
scan._t = null
|
|
2259
|
+
scanPendingSince = 0
|
|
2260
|
+
scan()
|
|
2261
|
+
return
|
|
2262
|
+
}
|
|
2263
|
+
clearTimeout(scan._t)
|
|
2264
|
+
scan._t = setTimeout(() => { scan._t = null; scanPendingSince = 0; scan() }, 200)
|
|
2265
|
+
}
|
|
2266
|
+
var mo = new MutationObserver((records) => {
|
|
2267
|
+
try {
|
|
2268
|
+
for (var i = 0; i < records.length; i++) {
|
|
2269
|
+
var rec = records[i]
|
|
2270
|
+
if (rec.type === 'attributes') {
|
|
2271
|
+
var tgt = rec.target
|
|
2272
|
+
if (tgt && tgt.nodeType === 1 && typeof tgt.matches === 'function' && tgt.matches(MO_SEL)) { scheduleScan(); return }
|
|
2273
|
+
continue
|
|
2274
|
+
}
|
|
2275
|
+
var added = rec.addedNodes
|
|
2276
|
+
for (var j = 0; j < added.length; j++) {
|
|
2277
|
+
var n = added[j]
|
|
2278
|
+
if (!n || n.nodeType !== 1) continue
|
|
2279
|
+
if ((typeof n.matches === 'function' && n.matches(MO_SEL))
|
|
2280
|
+
|| (typeof n.querySelector === 'function' && n.querySelector(MO_SEL))) { scheduleScan(); return }
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
} catch { scheduleScan() }
|
|
2284
|
+
})
|
|
2285
|
+
mo.observe(document.body, {
|
|
2286
|
+
childList: true, subtree: true,
|
|
2287
|
+
attributes: true, attributeFilter: ['data-chat-node-key'],
|
|
2288
|
+
})
|
|
2289
|
+
} catch { /* noop */ }
|
|
2290
|
+
window.addEventListener(CHANGED_EVENT, () => {
|
|
2291
|
+
try {
|
|
2292
|
+
document.querySelectorAll('[data-mops-seq]').forEach((b) => b.removeAttribute('data-mops-seq'))
|
|
2293
|
+
} catch { /* noop */ }
|
|
2294
|
+
})
|
|
2295
|
+
// 样式:与官方 action 按钮同款(28px、ghost),可见性继承宿主动作行
|
|
2296
|
+
try {
|
|
2297
|
+
if (!document.getElementById('dsh-message-ops-user-revert-style')) {
|
|
2298
|
+
const tag = document.createElement('style')
|
|
2299
|
+
tag.id = 'dsh-message-ops-user-revert-style'
|
|
2300
|
+
tag.textContent = [
|
|
2301
|
+
// 不再用 absolute + :hover:按钮作为宿主动作行的子元素,
|
|
2302
|
+
// 由宿主 [data-actions-reveal] 统一控制显隐(触屏也跟着宿主走)。
|
|
2303
|
+
'.mopsUserRevert{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary,#9a9aa0);cursor:pointer;padding:0;flex:none}',
|
|
2304
|
+
'.mopsUserRevert:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12))}',
|
|
2305
|
+
'.mopsUserRevert:disabled{opacity:.4;cursor:default}',
|
|
2306
|
+
'.mopsUserActionsRow{height:28px;display:flex;align-items:center;gap:8px;margin-left:-6px}',
|
|
2307
|
+
'@media (hover:none){.mopsUserActionsRow{opacity:1}}',
|
|
2308
|
+
].join('')
|
|
2309
|
+
document.head.appendChild(tag)
|
|
2310
|
+
}
|
|
2311
|
+
} catch { /* style 注入失败不影响功能 */ }
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
function ensureSidebarOpsItem() {
|
|
2315
|
+
var menu = document.querySelector('[role=menu]')
|
|
2316
|
+
if (!menu) return
|
|
2317
|
+
if (menu.querySelector('[data-dsh-message-ops]')) return
|
|
2318
|
+
var row = findOpenSessionRow()
|
|
2319
|
+
if (!row) return
|
|
2320
|
+
var item = document.createElement('button')
|
|
2321
|
+
item.type = 'button'
|
|
2322
|
+
item.setAttribute('role', 'menuitem')
|
|
2323
|
+
item.setAttribute('data-dsh-message-ops', '1')
|
|
2324
|
+
item.style.cssText = [
|
|
2325
|
+
'display:flex', 'align-items:center', 'gap:8px', 'width:100%',
|
|
2326
|
+
'padding:6px 12px', 'border:none', 'background:transparent',
|
|
2327
|
+
'color:var(--dsw-alias-label-primary,inherit)',
|
|
2328
|
+
'font:inherit', 'font-size:13px', 'line-height:20px',
|
|
2329
|
+
'text-align:left', 'border-radius:6px', 'cursor:pointer',
|
|
2330
|
+
].join(';')
|
|
2331
|
+
item.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="flex:none"><path d="' + BRANCH_PATH + '" fill="currentColor"/></svg><span></span>'
|
|
2332
|
+
item.querySelector('span').textContent = __t('menu.ops')
|
|
2333
|
+
item.addEventListener('mouseenter', function () {
|
|
2334
|
+
item.style.background = 'var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14))'
|
|
2335
|
+
})
|
|
2336
|
+
item.addEventListener('mouseleave', function () { item.style.background = 'transparent' })
|
|
2337
|
+
item.addEventListener('click', function () {
|
|
2338
|
+
var titleEl = row.querySelector('[class*=title]')
|
|
2339
|
+
var title = titleEl ? String(titleEl.innerText || '').trim() : ''
|
|
2340
|
+
if (!title) return
|
|
2341
|
+
// 0.5.2(P1):必须带 sessionId——否则对话框加载 effect 早退,永久卡「正在读取…」
|
|
2342
|
+
var sid = ''
|
|
2343
|
+
var rk = row.getAttribute && row.getAttribute('data-row-key')
|
|
2344
|
+
if (rk && rk.indexOf('session:') === 0) sid = rk.slice(8)
|
|
2345
|
+
window.dispatchEvent(new CustomEvent(EVENT, { detail: { title: title, sessionId: sid || undefined } }))
|
|
2346
|
+
})
|
|
2347
|
+
var sep = document.createElement('div')
|
|
2348
|
+
sep.style.cssText = 'height:1px;margin:4px 8px;background:var(--dsw-alias-border-l1,rgba(128,128,128,.2))'
|
|
2349
|
+
menu.appendChild(sep)
|
|
2350
|
+
menu.appendChild(item)
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
function refreshSidebarOpsLabel() {
|
|
2354
|
+
const items = document.querySelectorAll('[data-dsh-message-ops]')
|
|
2355
|
+
for (let i = 0; i < items.length; i++) {
|
|
2356
|
+
const span = items[i].querySelector('span')
|
|
2357
|
+
if (span) span.textContent = __t('menu.ops')
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function installSidebarOps() {
|
|
2362
|
+
if (window.__dshMessageOpsSidebarInstalled) return
|
|
2363
|
+
window.__dshMessageOpsSidebarInstalled = true
|
|
2364
|
+
try { ensureSidebarOpsItem() } catch (e) { /* never crash the UI */ }
|
|
2365
|
+
// 观察范围收窄(评审 M2):仍需 observe document.body(会话行菜单由宿主
|
|
2366
|
+
// React 动态渲染在侧栏任意挂载点,安装期无法锚定稳定容器),但回调改为
|
|
2367
|
+
// 精确过滤——只有新增节点本身就是(或包含)[role=menu] 时才调度探测,
|
|
2368
|
+
// 其余海量 childList mutation(聊天流渲染、流式 chunk 等)零探测成本;
|
|
2369
|
+
// 同帧多次命中经 setTimeout 合并为一次探测。
|
|
2370
|
+
var pending = false
|
|
2371
|
+
function scheduleEnsure() {
|
|
2372
|
+
if (pending) return
|
|
2373
|
+
pending = true
|
|
2374
|
+
setTimeout(function () {
|
|
2375
|
+
pending = false
|
|
2376
|
+
try { ensureSidebarOpsItem() } catch (e) { /* never crash the UI */ }
|
|
2377
|
+
}, 0)
|
|
2378
|
+
}
|
|
2379
|
+
var observer = new MutationObserver(function (mutations) {
|
|
2380
|
+
for (var i = 0; i < mutations.length; i++) {
|
|
2381
|
+
var added = mutations[i].addedNodes
|
|
2382
|
+
for (var j = 0; j < added.length; j++) {
|
|
2383
|
+
var n = added[j]
|
|
2384
|
+
if (n.nodeType !== 1) continue
|
|
2385
|
+
var isMenu = false
|
|
2386
|
+
try {
|
|
2387
|
+
isMenu = (typeof n.matches === 'function' && n.matches('[role=menu]'))
|
|
2388
|
+
|| (typeof n.querySelector === 'function' && n.querySelector('[role=menu]') !== null)
|
|
2389
|
+
} catch (e) { isMenu = false }
|
|
2390
|
+
if (isMenu) { scheduleEnsure(); return }
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
})
|
|
2394
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// --- apply ------------------------------------------------------------------
|
|
2398
|
+
|
|
2399
|
+
function adoptLocale(locale, ctx) {
|
|
2400
|
+
if (!locale) return
|
|
2401
|
+
__locale = locale
|
|
2402
|
+
try {
|
|
2403
|
+
if (typeof locale.register === 'function') {
|
|
2404
|
+
ctx.effect(() => locale.register(NS, { zh: zhDict, en: enDict }))
|
|
2405
|
+
}
|
|
2406
|
+
} catch { /* namespace already registered: keep existing copy */ }
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
|
|
2410
|
+
// ═══ 0.9.3 /undo —— opencode 招牌交互 ═════════════════════════════════════
|
|
2411
|
+
//
|
|
2412
|
+
// 「我说的那句话不对」→ 一个命令 → 消息从 transcript 消失 + 原文回输入框。
|
|
2413
|
+
// 走**官方斜杠命令管线**(`ctx.inputTriggers.registerSource`),不自己劫持键盘,
|
|
2414
|
+
// 因此:菜单分组、搜索、Enter 裁决、composer 状态机全部由宿主负责。
|
|
2415
|
+
//
|
|
2416
|
+
// 契约(dsh-client-ui-input-trigger/lib/types/client/contract.d.ts):
|
|
2417
|
+
// registerSource({ trigger:'/', name, candidates(session,req), onPick(pick),
|
|
2418
|
+
// matchEnter?(session,line,signal,envelope) })
|
|
2419
|
+
// PickOutcome = {claim} | {insert} | {text,continue?} | 'handled' | undefined
|
|
2420
|
+
// undefined = 「不是我的」,交回管线;'handled' = 已消费,**不要**当成消���发出去。
|
|
2421
|
+
var UNDO_CANDIDATES = [
|
|
2422
|
+
{ name: 'undo', value: 'undo', icon: 'undo' },
|
|
2423
|
+
{ name: 'redo', value: 'redo', icon: 'redo' },
|
|
2424
|
+
]
|
|
2425
|
+
var __undoSourceName = 'message-ops'
|
|
2426
|
+
|
|
2427
|
+
/**
|
|
2428
|
+
* 找到当前会话里最后一条**可回滚**的用户消息。
|
|
2429
|
+
*
|
|
2430
|
+
* ⚠️ 必须挑 surface 上的那一条。`restore` 是「重放」语义 → 原件被遮蔽、副本带**新 seq**
|
|
2431
|
+
* 落在日志尾部。若无脑取最后一条,就会挑到那个副本,而副本不在模型 surface 上,
|
|
2432
|
+
* 服务端会以 `surface replace: start seq N not found in surface` 拒绝(实测)。
|
|
2433
|
+
* 回滚的前提就是「目标在 surface 上」,所以先筛 visible。
|
|
2434
|
+
*/
|
|
2435
|
+
var lastRevertable = () => {
|
|
2436
|
+
const cache = __peekRowsCache ? __peekRowsCache() : null
|
|
2437
|
+
if (!cache || !Array.isArray(cache.rows)) return null
|
|
2438
|
+
for (var i = cache.rows.length - 1; i >= 0; i--) {
|
|
2439
|
+
var r = cache.rows[i]
|
|
2440
|
+
if (r && r.seq != null && r.visible) return r
|
|
2441
|
+
}
|
|
2442
|
+
return null
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/** 执行 undo/redo。返回 true 表示确实动过盘。 */
|
|
2446
|
+
/** __currentSessionId 来自 DOM dataset,可能带 `session-` 前缀;API 只要裸 uuid。 */
|
|
2447
|
+
var bareSessionId = (id) => String(id || '').replace(/^session-/, '')
|
|
2448
|
+
/** 写盘成功后的统一收尾:通知、刷新、重放隐藏。 */
|
|
2449
|
+
/**
|
|
2450
|
+
* 写盘成功后的统一收尾。
|
|
2451
|
+
*
|
|
2452
|
+
* ⚠️ `rawSid` 必须是**带 `session-` 前缀**的 id:`uiWorkspace.openSession`
|
|
2453
|
+
* 内部 `sessions.retain(bare)` 只认 `session-<uuid>`(manager.js resolveTarget),
|
|
2454
|
+
* 传裸 uuid 会抛 `unknown session <bare>` —— 而外层 `catch { }` 静默吞掉,
|
|
2455
|
+
* 结果「视图重建」这条路径**从来没生效过**,只剩 150/700/1600ms 的 DOM 打补丁。
|
|
2456
|
+
* 本文件其它调用点(对话框 :403、:684、:858、:1229)传的都是带前缀的,
|
|
2457
|
+
* 只有 /undo /redo 因为先 `bareSessionId` 而传错。
|
|
2458
|
+
*/
|
|
2459
|
+
var afterWrite = (rawSid, okMsg) => {
|
|
2460
|
+
notifyDone(okMsg)
|
|
2461
|
+
// emitChanged = 作废消息读缓存 + 清 rowsCache TTL + 广播 CHANGED
|
|
2462
|
+
// (dock 重拉 / 已打标 seq 摘牌),一次写只做一次。
|
|
2463
|
+
emitChanged()
|
|
2464
|
+
try {
|
|
2465
|
+
if (__uiWorkspace && typeof __uiWorkspace.openSession === 'function') __uiWorkspace.openSession(rawSid)
|
|
2466
|
+
} catch (e) {
|
|
2467
|
+
if (window.__MOPS_DEBUG) console.warn('[message-ops] openSession failed:', e)
|
|
2468
|
+
}
|
|
2469
|
+
const bare = bareSessionId(rawSid)
|
|
2470
|
+
for (const delay of [150, 700, 1600]) setTimeout(() => { if (__refreshSurface) __refreshSurface(bare) }, delay)
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
/**
|
|
2474
|
+
* 回填 composer(opencode 语义:回滚后把被删掉的那句原文还给输入框)。
|
|
2475
|
+
*
|
|
2476
|
+
* 0.9.6 统一出口 —— 此前「5 个入口只有 2 个回填」的事故史见 README「历史事故记录」#5。
|
|
2477
|
+
*
|
|
2478
|
+
* 取文本规则:rowsCache 里 seq <= boundary 的最后一条带 fullText 的行。
|
|
2479
|
+
* - /undo → boundary 就是被回滚的那条用户消息 → 取到它自己 ✓
|
|
2480
|
+
* - AI 时钟按钮 → boundary 是 AI 消息 seq → 取到它之前那条用户消息 ✓
|
|
2481
|
+
* 会话不匹配时直接放弃(不跨会话写,避免串台)。
|
|
2482
|
+
*/
|
|
2483
|
+
var refillComposer = (rawSid, boundarySeq) => {
|
|
2484
|
+
try {
|
|
2485
|
+
const cache = __peekRowsCache ? __peekRowsCache() : null
|
|
2486
|
+
if (!cache || !Array.isArray(cache.rows)) return false
|
|
2487
|
+
const want = bareSessionId(rawSid)
|
|
2488
|
+
if (bareSessionId(cache.sid) !== want) return false
|
|
2489
|
+
const rows = cache.rows.filter((r) => r && typeof r.seq === 'number' && r.seq <= boundarySeq && r.fullText)
|
|
2490
|
+
if (!rows.length) return false
|
|
2491
|
+
if (!__inputActions || typeof __inputActions.setDraft !== 'function') return false
|
|
2492
|
+
__inputActions.setDraft(rows[rows.length - 1].fullText)
|
|
2493
|
+
return true
|
|
2494
|
+
} catch (e) {
|
|
2495
|
+
// 原来这里 `catch {}` 完全静默 —— 失败了连痕迹都没有,排查时看不到
|
|
2496
|
+
console.warn('[message-ops] setDraft failed:', e)
|
|
2497
|
+
return false
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
/**
|
|
2502
|
+
* /undo —— 回滚最后一条**在模型 surface 上**的用户消息,原文回填输入框。
|
|
2503
|
+
* 目标必须是 surface 上的那条:restore 是「重放」语义,副本带新 seq 落在日志
|
|
2504
|
+
* 尾部但不在 surface 上,服务端会以 `surface replace: start seq N not found in surface` 拒绝。
|
|
2505
|
+
*/
|
|
2506
|
+
var runUndo = () => {
|
|
2507
|
+
const rawSid = __currentSessionId
|
|
2508
|
+
const sid = bareSessionId(rawSid)
|
|
2509
|
+
if (!sid) { notifyDone(__t('undo.none') || '没有可回滚的消息', 'error'); return false }
|
|
2510
|
+
const target = lastRevertable()
|
|
2511
|
+
if (!target) { notifyDone(__t('undo.none') || '没有可回滚的消息', 'error'); return false }
|
|
2512
|
+
fetch('/api/message-ops/revert', {
|
|
2513
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
2514
|
+
body: JSON.stringify({ sessionId: sid, seq: target.seq }),
|
|
2515
|
+
})
|
|
2516
|
+
.then(async (res) => {
|
|
2517
|
+
const data = await res.json().catch(() => ({}))
|
|
2518
|
+
if (!res.ok || !data.ok) throw new Error(data.error || ('HTTP ' + res.status))
|
|
2519
|
+
refillComposer(rawSid, target.seq) // 先回填,再重建视图(与对话框一致)
|
|
2520
|
+
afterWrite(rawSid, __t('undo.done.undo'))
|
|
2521
|
+
})
|
|
2522
|
+
.catch((reason) => notifyDone(__t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error'))
|
|
2523
|
+
return true
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
/**
|
|
2527
|
+
* /redo —— 恢复**最新一条未恢复的 marker**。
|
|
2528
|
+
*
|
|
2529
|
+
* ⚠️ 0.9.5 修正:先前把 `lastRevertable()`(用户**消息** seq)传给 /restore,
|
|
2530
|
+
* 但 `opsRestore` 走 `planRestore(events, restoreSeq)` —— 它要的是 **marker seq**
|
|
2531
|
+
* (dock 传的 row.seq 就来自 activeMarkers)。传消息 seq 会找不到 marker 空转。
|
|
2532
|
+
*/
|
|
2533
|
+
var runRedo = () => {
|
|
2534
|
+
const rawSid = __currentSessionId
|
|
2535
|
+
const sid = bareSessionId(rawSid)
|
|
2536
|
+
if (!sid) return false
|
|
2537
|
+
fetchMessagesJson('/api/message-ops/messages?sessionId=' + encodeURIComponent(sid))
|
|
2538
|
+
.then((d) => {
|
|
2539
|
+
if (!d || !d.ok) throw new Error((d && d.error) || 'messages unavailable')
|
|
2540
|
+
const markers = activeMarkers(d.messages || [])
|
|
2541
|
+
if (!markers.length) { notifyDone(__t('undo.none.redo') || '没有可恢复的回滚', 'error'); return null }
|
|
2542
|
+
const mk = markers[markers.length - 1] // 最新一条未恢复标记
|
|
2543
|
+
return fetch('/api/message-ops/restore', {
|
|
2544
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
2545
|
+
body: JSON.stringify({ sessionId: sid, seq: mk.seq }),
|
|
2546
|
+
}).then(async (res) => {
|
|
2547
|
+
const data = await res.json().catch(() => ({}))
|
|
2548
|
+
if (!res.ok || !data.ok) throw new Error(data.error || ('HTTP ' + res.status))
|
|
2549
|
+
// afterWrite 契约:rawSid 必须带 session- 前缀(传裸 uuid 会抛
|
|
2550
|
+
// `sessions.retain: unknown session` 并被外层 catch 静默吞掉)
|
|
2551
|
+
afterWrite(rawSid, __t('undo.done.redo'))
|
|
2552
|
+
})
|
|
2553
|
+
})
|
|
2554
|
+
.catch((reason) => notifyDone(__t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error'))
|
|
2555
|
+
return true
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
var runUndoRedo = (kind) => (kind === 'redo' ? runRedo() : runUndo())
|
|
2559
|
+
|
|
2560
|
+
/** 注册 /undo /redo 到官方斜杠管线。服务缺失时安静 no-op。 */
|
|
2561
|
+
var __undoRegistered = false
|
|
2562
|
+
var doRegisterUndoSlash = (ctx, it) => {
|
|
2563
|
+
if (__undoRegistered || !it || typeof it.registerSource !== 'function') return
|
|
2564
|
+
__undoRegistered = true
|
|
2565
|
+
try {
|
|
2566
|
+
ctx.effect(() => it.registerSource({
|
|
2567
|
+
trigger: '/',
|
|
2568
|
+
name: __undoSourceName,
|
|
2569
|
+
order: 20,
|
|
2570
|
+
showGroupTitle: true,
|
|
2571
|
+
// ⚠️ 必须返回 **Promise**:宿主在 InputTriggerController.fetchCandidates 里
|
|
2572
|
+
// 直接 `source.candidates(...).then(...)`,同步返回数组会抛
|
|
2573
|
+
// `source.candidates(...).then is not a function`,整个菜单静默失效。
|
|
2574
|
+
candidates: async (session, req) => {
|
|
2575
|
+
const q = String((req && req.query) || '').trim().toLowerCase()
|
|
2576
|
+
return UNDO_CANDIDATES
|
|
2577
|
+
.filter((c) => !q || c.name.includes(q))
|
|
2578
|
+
.map((c) => ({
|
|
2579
|
+
name: c.name,
|
|
2580
|
+
icon: c.icon,
|
|
2581
|
+
label: c.name === 'undo'
|
|
2582
|
+
? (__t('undo.label') || '回滚上一条消息')
|
|
2583
|
+
: (__t('redo.label') || '恢复上一条被回滚的消息'),
|
|
2584
|
+
description: c.name === 'undo'
|
|
2585
|
+
? (__t('undo.desc') || '移除最后一条用户消息,原文回填输入框')
|
|
2586
|
+
: (__t('redo.desc') || '把回滚掉的消息重新放回对话'),
|
|
2587
|
+
value: c.value,
|
|
2588
|
+
}))
|
|
2589
|
+
},
|
|
2590
|
+
onPick: (pick) => {
|
|
2591
|
+
const v = pick && pick.candidate && pick.candidate.value
|
|
2592
|
+
if (v !== 'undo' && v !== 'redo') return undefined
|
|
2593
|
+
runUndoRedo(v)
|
|
2594
|
+
return 'handled'
|
|
2595
|
+
},
|
|
2596
|
+
// Enter 直接执行:`/undo` 不必先在菜单里选中
|
|
2597
|
+
matchEnter: (session, line) => {
|
|
2598
|
+
const t = String(line || '').trim().toLowerCase()
|
|
2599
|
+
if (t !== '/undo' && t !== '/redo') return undefined
|
|
2600
|
+
runUndoRedo(t === '/redo' ? 'redo' : 'undo')
|
|
2601
|
+
return 'handled'
|
|
2602
|
+
},
|
|
2603
|
+
}))
|
|
2604
|
+
} catch (e) {
|
|
2605
|
+
__undoRegistered = false
|
|
2606
|
+
if (window.__MOPS_DEBUG) console.warn('[message-ops] /undo 注册失败:', e)
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
var registerUndoSlash = (ctx) => {
|
|
2611
|
+
// ⚠️ cordis 的 inject 回调**第一个参数是服务本身**,不是 ctx。
|
|
2612
|
+
// 早先写成 `registerUndoSlash(sub.ctx || sub)` → 在服务对象上找 .get
|
|
2613
|
+
// 必然是 undefined → 静默不注册(表现为 /undo 根本不出现)。
|
|
2614
|
+
const it = typeof ctx.get === 'function' ? ctx.get('inputTriggers') : null
|
|
2615
|
+
if (it) { doRegisterUndoSlash(ctx, it); return }
|
|
2616
|
+
try {
|
|
2617
|
+
// ⚠️ cordis inject 回调收到的是**注入命名空间**,服务在其同名字段下
|
|
2618
|
+
// (与本文件 `ctx.inject(['uiWorkspace'], sub => sub.uiWorkspace || sub)` 同模式)。
|
|
2619
|
+
// 直接把 sub 当服务用会抛 `cannot get property "registerSource" without inject`。
|
|
2620
|
+
ctx.inject(['inputTriggers'], (sub) => {
|
|
2621
|
+
doRegisterUndoSlash(ctx, sub && (sub.inputTriggers || sub))
|
|
2622
|
+
})
|
|
2623
|
+
} catch (e) {
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
function apply(ctx) {
|
|
2628
|
+
__sessionsSvc = ctx.get('sessions')
|
|
2629
|
+
__uiWorkspace = typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : null
|
|
2630
|
+
if (!__uiWorkspace) {
|
|
2631
|
+
try { ctx.inject(['uiWorkspace'], (sub) => { __uiWorkspace = sub.uiWorkspace || sub }) } catch { /* 0.1.x 无此服务 */ }
|
|
2632
|
+
}
|
|
2633
|
+
if (!__sessionsSvc) {
|
|
2634
|
+
ctx.inject(['sessions'], (sub) => { __sessionsSvc = sub.sessions })
|
|
2635
|
+
}
|
|
2636
|
+
registerUndoSlash(ctx)
|
|
2637
|
+
adoptLocale(ctx.get('locale'), ctx)
|
|
2638
|
+
if (!__locale) {
|
|
2639
|
+
ctx.inject(['locale'], (sub) => {
|
|
2640
|
+
adoptLocale(sub.locale, ctx)
|
|
2641
|
+
refreshSidebarOpsLabel()
|
|
2642
|
+
})
|
|
2643
|
+
}
|
|
2644
|
+
ctx.on('locale/change', refreshSidebarOpsLabel)
|
|
2645
|
+
if (window.__MOPS_DISABLE_HEADER !== true) ctx.slots.inject(SLOT, () => ctx.slots.register({
|
|
2646
|
+
name: SLOT, id: ROW_ID, order: 31,
|
|
2647
|
+
...(__locale ? { locale: NS } : {}),
|
|
2648
|
+
}, OpsButton))
|
|
2649
|
+
// 0.3.0:每条 AI 消息旁的原生回撤按钮(0.1.x 无此槽,inject 自动 no-op)
|
|
2650
|
+
ctx.slots.inject(CHAT_ACTIONS_SLOT, () => ctx.slots.register({
|
|
2651
|
+
name: CHAT_ACTIONS_SLOT, id: CHAT_ACTIONS_ID, order: 20,
|
|
2652
|
+
...(__locale ? { locale: NS } : {}),
|
|
2653
|
+
}, MsgSlotActions))
|
|
2654
|
+
// 0.4.0:composer 上方回撤 dock(0.1.x 无此槽自动 no-op)
|
|
2655
|
+
ctx.slots.inject(INPUT_DOCK_SLOT, () => ctx.slots.register({
|
|
2656
|
+
name: INPUT_DOCK_SLOT, id: INPUT_DOCK_ID, order: 10,
|
|
2657
|
+
...(__locale ? { locale: NS } : {}),
|
|
2658
|
+
}, RevertDock))
|
|
2659
|
+
ctx.slots.inject(OVERLAY_SLOT, () => ctx.slots.register({
|
|
2660
|
+
name: OVERLAY_SLOT, id: DIALOG_ID, order: 101,
|
|
2661
|
+
...(__locale ? { locale: NS } : {}),
|
|
2662
|
+
}, window.__MOPS_LEGACY_DIALOG === true ? OpsDialog : OpsPanel))
|
|
2663
|
+
installSidebarOps()
|
|
2664
|
+
installUserRevertInjector()
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
return { apply, inject: ['slots'], __test: { activityTransition } }
|
|
2668
|
+
},
|
|
2669
|
+
})
|
|
2670
|
+
|
|
2671
|
+
} finally {
|
|
2672
|
+
loader.load = original;
|
|
2673
|
+
}
|
|
2674
|
+
if (typeof factory !== "function") throw new Error("[dsh-plugin-hub] @240xu/dsh-message-ops client bundle did not register a factory");
|
|
2675
|
+
return factory;
|
|
2676
|
+
},
|
|
2677
|
+
create(require) {
|
|
2678
|
+
const factory = this.capture();
|
|
2679
|
+
return factory(require);
|
|
2680
|
+
},
|
|
2681
|
+
},
|
|
2682
|
+
{
|
|
2683
|
+
name: "@240xu/dsh-websearch",
|
|
2684
|
+
moduleId: "@240xu/dsh-plugin-hub/client/websearch",
|
|
2685
|
+
capture() {
|
|
2686
|
+
let factory;
|
|
2687
|
+
const loader = window.__ModuleLoader__;
|
|
2688
|
+
const original = loader.load;
|
|
2689
|
+
loader.load = (registration) => { factory = registration.factory; };
|
|
2690
|
+
try {
|
|
2691
|
+
window.__ModuleLoader__.load({
|
|
2692
|
+
id: "@240xu/dsh-websearch",
|
|
2693
|
+
factory: (require) => {
|
|
2694
|
+
var module = { exports: {} };
|
|
2695
|
+
var exports = module.exports;
|
|
2696
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2697
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
2698
|
+
let react = require("react");
|
|
2699
|
+
|
|
2700
|
+
const NS = "websearch.settings";
|
|
2701
|
+
const PLUGIN_NS = "unified-search";
|
|
2702
|
+
|
|
2703
|
+
/** Backend metadata: id, default credential env ref, label. */
|
|
2704
|
+
const BACKENDS = [
|
|
2705
|
+
{ id: "exa", keyEnv: null, label: "Exa (MCP, 免费)" },
|
|
2706
|
+
{ id: "parallel", keyEnv: null, label: "Parallel (MCP, 免费)" },
|
|
2707
|
+
{ id: "ddg", keyEnv: null, label: "DuckDuckGo (HTML)" },
|
|
2708
|
+
{ id: "searxng", keyEnv: "SEARXNG_API_KEY", optionalKey: true, label: "SearXNG (元搜索)" },
|
|
2709
|
+
{ id: "brave", keyEnv: "BRAVE_API_KEY", label: "Brave Search" },
|
|
2710
|
+
{ id: "tavily", keyEnv: "TAVILY_API_KEY", label: "Tavily (AI)" },
|
|
2711
|
+
{ id: "serper", keyEnv: "SERPER_API_KEY", label: "Serper.dev" },
|
|
2712
|
+
{ id: "mojeek", keyEnv: "MOJEEK_API_KEY", label: "Mojeek" },
|
|
2713
|
+
{ id: "deepseek", keyEnv: "DEEPSEEK_API_KEY", label: "DeepSeek" },
|
|
2714
|
+
{ id: "anthropic", keyEnv: "ANTHROPIC_API_KEY", label: "Anthropic" },
|
|
2715
|
+
{ id: "openai", keyEnv: "OPENAI_API_KEY", label: "OpenAI" },
|
|
2716
|
+
];
|
|
2717
|
+
const GLOBAL_FIELDS = ["numResults", "concurrency", "backendTimeoutMs"];
|
|
2718
|
+
/** Enum-valued settings (validated client-side, saved as strings). */
|
|
2719
|
+
const SELECT_FIELDS = {
|
|
2720
|
+
recency: ["any", "day", "week", "month", "year"],
|
|
2721
|
+
dedupStrategy: ["url", "url+title"],
|
|
2722
|
+
};
|
|
2723
|
+
/** Boolean settings staged as "true"/"false" strings by the selects. */
|
|
2724
|
+
const BOOLEAN_FIELDS = ["rerank", "safeSearch", "resultTelemetry"];
|
|
2725
|
+
const KEYLESS = BACKENDS.filter((b) => !b.keyEnv || b.optionalKey);
|
|
2726
|
+
const KEYED = BACKENDS.filter((b) => b.keyEnv && !b.optionalKey);
|
|
2727
|
+
const OPTIONAL_KEYS = BACKENDS.filter((b) => b.keyEnv && b.optionalKey);
|
|
2728
|
+
|
|
2729
|
+
/** Where the user goes to get/rotate each key. */
|
|
2730
|
+
const KEY_URLS = {
|
|
2731
|
+
brave: "https://api-dashboard.search.brave.com/app/keys",
|
|
2732
|
+
tavily: "https://app.tavily.com/home",
|
|
2733
|
+
serper: "https://serper.dev/api-key",
|
|
2734
|
+
mojeek: "https://www.mojeek.com/services/search/web-search-api/",
|
|
2735
|
+
deepseek: "https://platform.deepseek.com/api_keys",
|
|
2736
|
+
anthropic: "https://console.anthropic.com/settings/keys",
|
|
2737
|
+
openai: "https://platform.openai.com/api-keys",
|
|
2738
|
+
};
|
|
2739
|
+
|
|
2740
|
+
const zh = {
|
|
2741
|
+
nav: "搜索",
|
|
2742
|
+
groupFree: "免密钥(开箱即用)",
|
|
2743
|
+
groupKeyed: "需要 API Key",
|
|
2744
|
+
statusEnabled: "已启用后端",
|
|
2745
|
+
statusKeys: "已配置密钥",
|
|
2746
|
+
keyMissing: "缺密钥",
|
|
2747
|
+
unsavedHint: "有未保存的修改", savedOk: "已保存 ✓",
|
|
2748
|
+
title: "统一搜索 (Unified Search)",
|
|
2749
|
+
description: "聚合网络搜索:11 个后端并发 fan-out、URL 去重合并。开关后端、填 API Key、调参数,保存即生效。",
|
|
2750
|
+
save: "保存",
|
|
2751
|
+
discard: "放弃修改",
|
|
2752
|
+
dirty: "有未保存修改",
|
|
2753
|
+
saved: "已保存",
|
|
2754
|
+
saving: "保存中…",
|
|
2755
|
+
failed: "保存失败",
|
|
2756
|
+
reset: "重置",
|
|
2757
|
+
overridden: "已自定义",
|
|
2758
|
+
keyLabel: "{id} API Key",
|
|
2759
|
+
getKey: "获取 Key ↗",
|
|
2760
|
+
keyHint: "填入后保存到凭证库(对应环境变量引用);留空表示不修改",
|
|
2761
|
+
keySet: "已配置",
|
|
2762
|
+
keyUnset: "未配置",
|
|
2763
|
+
togglesTitle: "启用后端",
|
|
2764
|
+
globalsTitle: "全局参数",
|
|
2765
|
+
keysTitle: "API 密钥(保存后写入凭证库,立即生效)",
|
|
2766
|
+
shapingTitle: "结果策略",
|
|
2767
|
+
recencyLabel: "时间范围",
|
|
2768
|
+
langLabel: "搜索语言",
|
|
2769
|
+
safeSearchLabel: "安全搜索",
|
|
2770
|
+
dedupLabel: "去重策略",
|
|
2771
|
+
rerankLabel: "相关性重排",
|
|
2772
|
+
on: "开",
|
|
2773
|
+
off: "关",
|
|
2774
|
+
recencyAny: "不限",
|
|
2775
|
+
recencyDay: "一天内",
|
|
2776
|
+
recencyWeek: "一周内",
|
|
2777
|
+
recencyMonth: "一月内",
|
|
2778
|
+
recencyYear: "一年内",
|
|
2779
|
+
dedupUrl: "按链接",
|
|
2780
|
+
dedupTitle: "链接 + 标题(合并转载)",
|
|
2781
|
+
langPlaceholder: "如 en、zh-CN,留空为默认",
|
|
2782
|
+
envProvided: "环境变量提供", readOnly: "只读,无需填写", conflict: "设置已在其他端被修改,请重试", keyFailed: "该 Key 保存失败",
|
|
2783
|
+
telemetryLabel: "结果健康遥测",
|
|
2784
|
+
fallbackHint: "全部后端已关闭:搜索将回退到默认免 Key 后端",
|
|
2785
|
+
invalidFields: "以下字段未通过校验:",
|
|
2786
|
+
readOnlyScope: "当前配置只读(可能由宿主锁定)",
|
|
2787
|
+
optionalTitle: "私有实例可选 Key",
|
|
2788
|
+
};
|
|
2789
|
+
const en = {
|
|
2790
|
+
nav: "Web Search",
|
|
2791
|
+
groupFree: "Keyless (ready to use)",
|
|
2792
|
+
groupKeyed: "Requires API key",
|
|
2793
|
+
statusEnabled: "Backends enabled",
|
|
2794
|
+
statusKeys: "Keys configured",
|
|
2795
|
+
keyMissing: "key missing",
|
|
2796
|
+
unsavedHint: "You have unsaved changes", savedOk: "Saved ✓",
|
|
2797
|
+
title: "Unified Search",
|
|
2798
|
+
description: "Aggregated web search: 11 backends fan out concurrently, URL-deduped. Toggle backends, set API keys, tune globals — saves apply immediately.",
|
|
2799
|
+
save: "Save",
|
|
2800
|
+
discard: "Discard",
|
|
2801
|
+
dirty: "Unsaved changes",
|
|
2802
|
+
saved: "Saved",
|
|
2803
|
+
saving: "Saving…",
|
|
2804
|
+
failed: "Save failed",
|
|
2805
|
+
reset: "Reset",
|
|
2806
|
+
overridden: "customized",
|
|
2807
|
+
keyLabel: "{id} API Key",
|
|
2808
|
+
getKey: "Get key ↗",
|
|
2809
|
+
keyHint: "Stored into the credential store on save; leave blank to keep current",
|
|
2810
|
+
keySet: "configured",
|
|
2811
|
+
keyUnset: "not set",
|
|
2812
|
+
togglesTitle: "Enabled backends",
|
|
2813
|
+
globalsTitle: "Global parameters",
|
|
2814
|
+
keysTitle: "API keys (saved to the credential store, effective immediately)",
|
|
2815
|
+
shapingTitle: "Result shaping",
|
|
2816
|
+
recencyLabel: "Recency",
|
|
2817
|
+
langLabel: "Language",
|
|
2818
|
+
safeSearchLabel: "Safe search",
|
|
2819
|
+
dedupLabel: "Dedup strategy",
|
|
2820
|
+
rerankLabel: "Relevance rerank",
|
|
2821
|
+
on: "On",
|
|
2822
|
+
off: "Off",
|
|
2823
|
+
recencyAny: "Any time",
|
|
2824
|
+
recencyDay: "Past day",
|
|
2825
|
+
recencyWeek: "Past week",
|
|
2826
|
+
recencyMonth: "Past month",
|
|
2827
|
+
recencyYear: "Past year",
|
|
2828
|
+
dedupUrl: "By URL",
|
|
2829
|
+
dedupTitle: "URL + title (merge syndication)",
|
|
2830
|
+
langPlaceholder: "e.g. en, zh-CN; blank = backend default",
|
|
2831
|
+
envProvided: "provided by env",
|
|
2832
|
+
readOnly: "read-only, nothing to fill",
|
|
2833
|
+
conflict: "Changed elsewhere - please retry",
|
|
2834
|
+
keyFailed: "failed to save this key",
|
|
2835
|
+
telemetryLabel: "Result health telemetry",
|
|
2836
|
+
fallbackHint: "All backends off: search falls back to the default keyless four.",
|
|
2837
|
+
invalidFields: "Fields failed validation:",
|
|
2838
|
+
readOnlyScope: "Configuration is read-only (locked by host?)",
|
|
2839
|
+
optionalTitle: "Private instance optional keys",
|
|
2840
|
+
};
|
|
2841
|
+
|
|
2842
|
+
//#region styles
|
|
2843
|
+
const css = ".wsc-card{display:flex;flex-direction:column;gap:14px}.wsc-sectionTitle{font-size:13px;font-weight:600;opacity:.75;margin:4px 0 0}.wsc-toggles{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:6px}.wsc-toggle{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:10px;border:1px solid var(--dsw-alias-border-subtle);cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);background:0 0}.wsc-toggle input{accent-color:var(--dsw-alias-accent-primary)}.wsc-input,.wsc-keyInput{box-sizing:border-box;width:100%;padding:8px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-subtle);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px;font-family:inherit}.wsc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px}.wsc-fieldHead{display:flex;justify-content:space-between;align-items:center;margin-bottom:3px}.wsc-label{font-size:12px;opacity:.8}.wsc-badge{font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-subtle)}.wsc-badgeOn{color:var(--dsw-alias-accent-primary);border-color:var(--dsw-alias-accent-primary)}.wsc-hint{font-size:11px;opacity:.55;margin:3px 0 0}.wsc-actions{display:flex;gap:8px;align-items:center}.wsc-btn{cursor:pointer;border:none;border-radius:10px;padding:8px 16px;font-size:13px;font-family:inherit;background:var(--dsw-alias-accent-primary);color:#fff}.wsc-btnGhost{background:0 0;color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-subtle)}.wsc-btn:disabled{opacity:.5;cursor:default}.wsc-status{font-size:12px;opacity:.7}";
|
|
2844
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"dsh-websearch/card\"]") === null) {
|
|
2845
|
+
const tag = document.createElement("style");
|
|
2846
|
+
tag.dataset.plugin = "@240xu/dsh-websearch";
|
|
2847
|
+
tag.dataset.pluginCss = "dsh-websearch/card";
|
|
2848
|
+
tag.textContent = css;
|
|
2849
|
+
document.head.appendChild(tag);
|
|
2850
|
+
}
|
|
2851
|
+
//#endregion
|
|
2852
|
+
|
|
2853
|
+
/** Build a snapshot store (same shape as client-runtime's createSnapshotStore). */
|
|
2854
|
+
function createStore(initial) {
|
|
2855
|
+
let snap = initial;
|
|
2856
|
+
const listeners = new Set();
|
|
2857
|
+
return {
|
|
2858
|
+
getSnapshot: () => snap,
|
|
2859
|
+
subscribe: (l) => (listeners.add(l), () => listeners.delete(l)),
|
|
2860
|
+
set: (next) => { snap = next; listeners.forEach((l) => l()); },
|
|
2861
|
+
};
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
/**
|
|
2865
|
+
* Settings bridge for 0.2.x builds where the legacy `settingsScope`
|
|
2866
|
+
* client service is gone: the same snapshot/subscribe/set/unset surface
|
|
2867
|
+
* the card controller expects, backed by this plugin's fenced settings
|
|
2868
|
+
* route. Writes land in the volatile profile entry the host provider
|
|
2869
|
+
* resolves from on the next call -- no second settings store.
|
|
2870
|
+
*/
|
|
2871
|
+
class SettingsRoutesBridge {
|
|
2872
|
+
constructor() {
|
|
2873
|
+
this.store = createStore({
|
|
2874
|
+
status: "loading",
|
|
2875
|
+
writable: false,
|
|
2876
|
+
value: undefined,
|
|
2877
|
+
base: undefined,
|
|
2878
|
+
user: undefined,
|
|
2879
|
+
revision: undefined,
|
|
2880
|
+
mode: "routes",
|
|
2881
|
+
});
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
getSnapshot() { return this.store.getSnapshot(); }
|
|
2885
|
+
subscribe(listener) { return this.store.subscribe(listener); }
|
|
2886
|
+
|
|
2887
|
+
accept(payload) {
|
|
2888
|
+
if (payload === null || typeof payload !== "object" || payload.ok !== true || payload.writable === false) {
|
|
2889
|
+
// Keep the card rendered (status ready) but read-only, so the section
|
|
2890
|
+
// explains itself instead of showing an endless loading ellipsis.
|
|
2891
|
+
this.store.set({ status: "ready", writable: false, value: {}, base: undefined, user: undefined, revision: undefined, mode: "routes" });
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
this.store.set({
|
|
2895
|
+
status: "ready",
|
|
2896
|
+
writable: true,
|
|
2897
|
+
value: payload.value,
|
|
2898
|
+
base: payload.base,
|
|
2899
|
+
user: payload.user,
|
|
2900
|
+
revision: payload.revision,
|
|
2901
|
+
mode: "routes",
|
|
2902
|
+
});
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
async load() {
|
|
2906
|
+
try {
|
|
2907
|
+
const response = await fetch("/api/unified-search/settings", { credentials: "same-origin", headers: { accept: "application/json" } });
|
|
2908
|
+
const payload = await response.json().catch(() => undefined);
|
|
2909
|
+
this.accept(response.status === 404 ? undefined : payload);
|
|
2910
|
+
} catch (_noRoutes) {
|
|
2911
|
+
this.accept(undefined);
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
async write(ops) {
|
|
2916
|
+
const expectedRevision = this.store.getSnapshot().revision;
|
|
2917
|
+
const response = await fetch("/api/unified-search/settings", {
|
|
2918
|
+
method: "POST",
|
|
2919
|
+
credentials: "same-origin",
|
|
2920
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
2921
|
+
// eslint-disable-next-line object-shorthand
|
|
2922
|
+
body: JSON.stringify(expectedRevision === undefined ? { ops } : { ops, expectedRevision }),
|
|
2923
|
+
});
|
|
2924
|
+
const payload = await response.json().catch(() => undefined);
|
|
2925
|
+
if (response.status === 409 || (payload !== undefined && payload.code === "SETTINGS_CONFLICT")) throw new Error("SETTINGS_CONFLICT");
|
|
2926
|
+
if (!response.ok || payload === undefined || payload.ok !== true) {
|
|
2927
|
+
throw new Error(payload !== undefined && payload.error !== undefined ? String(payload.error) : "settings request failed (" + response.status + ")");
|
|
2928
|
+
}
|
|
2929
|
+
this.accept(payload);
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
set(field, value) { return this.write([{ op: "set", path: [field], value }]); }
|
|
2933
|
+
unset(field) { return this.write([{ op: "unset", path: [field] }]); }
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
/**
|
|
2937
|
+
* Card state machine: reads the bound settings scope snapshot, stages edits,
|
|
2938
|
+
* writes section fields through scope.set/unset and secrets via credentials.
|
|
2939
|
+
*/
|
|
2940
|
+
class UnifiedSearchCardController {
|
|
2941
|
+
constructor(scope, connection, remote) {
|
|
2942
|
+
this.scope = scope;
|
|
2943
|
+
this.connection = connection;
|
|
2944
|
+
this.remote = remote;
|
|
2945
|
+
this.staged = new Map(); // field -> { text } | { clear: true }
|
|
2946
|
+
this.stagedKeys = new Map(); // envRef -> text
|
|
2947
|
+
this.keyState = new Map(); // envRef -> {configured}
|
|
2948
|
+
this.saving = false;
|
|
2949
|
+
this.failed = false;
|
|
2950
|
+
this.conflict = false;
|
|
2951
|
+
this.failedKeys = new Map(); // envRef -> failure reason
|
|
2952
|
+
this.failedFields = new Set();
|
|
2953
|
+
this.savedAt = 0;
|
|
2954
|
+
this.store = createStore(this.projection());
|
|
2955
|
+
this.scope.subscribe(() => this.republish());
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
sectionValue(field) {
|
|
2959
|
+
return this.scope.getSnapshot().value?.[field];
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
fieldState(field) {
|
|
2963
|
+
const staged = this.staged.get(field);
|
|
2964
|
+
const current = this.sectionValue(field);
|
|
2965
|
+
if (staged === undefined) return { text: current === undefined ? "" : String(current), overridden: false };
|
|
2966
|
+
return { text: staged.clear ? "" : staged.text, overridden: !staged.clear };
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
projection() {
|
|
2970
|
+
const snap = this.scope.getSnapshot();
|
|
2971
|
+
return {
|
|
2972
|
+
ready: snap.status === "ready",
|
|
2973
|
+
writable: snap.writable,
|
|
2974
|
+
value: snap.value ?? {},
|
|
2975
|
+
keyState: Object.fromEntries(this.keyState),
|
|
2976
|
+
stagedKeys: Object.fromEntries([...this.stagedKeys].map(([k, v]) => [k, v.text])),
|
|
2977
|
+
stagedCount: this.staged.size + this.stagedKeys.size,
|
|
2978
|
+
savedAt: this.savedAt,
|
|
2979
|
+
stagedText: (field) => {
|
|
2980
|
+
const s = this.staged.get(field);
|
|
2981
|
+
return s ? (s.clear ? "" : s.text) : undefined;
|
|
2982
|
+
},
|
|
2983
|
+
conflict: this.conflict,
|
|
2984
|
+
failedKeys: [...this.failedKeys].map(([ref, reason]) => ({ ref, reason })),
|
|
2985
|
+
failedFields: [...this.failedFields],
|
|
2986
|
+
saving: this.saving,
|
|
2987
|
+
failed: this.failed,
|
|
2988
|
+
version: (this.staged.size + ":" + [...this.staged.keys()].join(",")),
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
republish() { this.store.set(this.projection()); }
|
|
2993
|
+
|
|
2994
|
+
edit(field, text) { this.savedAt = 0; this.staged.set(field, { text }); this.republish(); }
|
|
2995
|
+
reset(field) { this.staged.delete(field); this.republish(); }
|
|
2996
|
+
editKey(envRef, text) { this.savedAt = 0; this.stagedKeys.set(envRef, { text }); this.republish(); }
|
|
2997
|
+
|
|
2998
|
+
/** Toggles write immediately (no staging); globals/keys stage + save. */
|
|
2999
|
+
async setToggle(id, checked) {
|
|
3000
|
+
try {
|
|
3001
|
+
await this.scope.set("enable" + id.charAt(0).toUpperCase() + id.slice(1), checked);
|
|
3002
|
+
} catch (err) {
|
|
3003
|
+
this.failed = true;
|
|
3004
|
+
this.conflict = String(err).includes("SETTINGS_CONFLICT");
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
this.republish();
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
async readCredential(ref) {
|
|
3011
|
+
try {
|
|
3012
|
+
let view;
|
|
3013
|
+
if (this.remote !== undefined && this.remote.credentials !== undefined && typeof this.remote.credentials.describe === "function") {
|
|
3014
|
+
// 0.2.x remote face: Record<ref, {configured, writable, source}> behind {ok, value}.
|
|
3015
|
+
const res = await this.remote.credentials.describe([ref]);
|
|
3016
|
+
const record = res !== null && typeof res === "object" && res.value !== undefined ? res.value : res;
|
|
3017
|
+
const byRef = record !== null && typeof record === "object" && record.credentials !== undefined ? record.credentials : record;
|
|
3018
|
+
view = byRef !== null && typeof byRef === "object" ? byRef[ref] : undefined;
|
|
3019
|
+
}
|
|
3020
|
+
if (view === undefined && this.connection !== undefined && this.connection.api !== undefined && this.connection.api.credentials !== undefined) {
|
|
3021
|
+
// Older line: the connection controller's credentials API.
|
|
3022
|
+
const res = await this.connection.api.credentials.describe({ refs: [ref] });
|
|
3023
|
+
if (res?.result?.ok) view = res.result.value.credentials[ref];
|
|
3024
|
+
}
|
|
3025
|
+
if (view === undefined) return;
|
|
3026
|
+
this.keyState.set(ref, { configured: view.configured === true, writable: view.writable !== false });
|
|
3027
|
+
} catch { /* ignore */ }
|
|
3028
|
+
this.republish();
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
async writeCredential(ref, value) {
|
|
3032
|
+
if (this.remote !== undefined && this.remote.credentials !== undefined && typeof this.remote.credentials.set === "function") {
|
|
3033
|
+
await this.remote.credentials.set(ref, value);
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
if (this.connection !== undefined && this.connection.api !== undefined && this.connection.api.credentials !== undefined && typeof this.connection.api.credentials.set === "function") {
|
|
3037
|
+
await this.connection.api.credentials.set({ ref, value });
|
|
3038
|
+
return;
|
|
3039
|
+
}
|
|
3040
|
+
throw new Error("credentials service unavailable");
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
refreshAllCredentials() {
|
|
3044
|
+
for (const b of BACKENDS) if (b.keyEnv) this.readCredential(b.keyEnv);
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
async save() {
|
|
3048
|
+
if (this.saving) return;
|
|
3049
|
+
this.saving = true; this.failed = false;
|
|
3050
|
+
this.conflict = false; this.failedFields.clear(); this.republish();
|
|
3051
|
+
const doneFields = [];
|
|
3052
|
+
const doneKeys = [];
|
|
3053
|
+
try {
|
|
3054
|
+
for (const [field, staged] of this.staged) {
|
|
3055
|
+
// Empty draft clears the override so the schema default applies again.
|
|
3056
|
+
if (staged.clear || staged.text === "") { await this.scope.unset(field); doneFields.push(field); continue; }
|
|
3057
|
+
if (SELECT_FIELDS[field]) {
|
|
3058
|
+
if (!SELECT_FIELDS[field].includes(staged.text)) { this.failed = true; this.failedFields.add(field); continue; }
|
|
3059
|
+
await this.scope.set(field, staged.text);
|
|
3060
|
+
doneFields.push(field);
|
|
3061
|
+
continue;
|
|
3062
|
+
}
|
|
3063
|
+
if (BOOLEAN_FIELDS.includes(field)) {
|
|
3064
|
+
if (staged.text !== "true" && staged.text !== "false") { this.failed = true; this.failedFields.add(field); continue; }
|
|
3065
|
+
await this.scope.set(field, staged.text === "true");
|
|
3066
|
+
doneFields.push(field);
|
|
3067
|
+
continue;
|
|
3068
|
+
}
|
|
3069
|
+
if (GLOBAL_FIELDS.includes(field)) {
|
|
3070
|
+
const num = Number(staged.text);
|
|
3071
|
+
// Mirror the server schema bounds client-side so bad input is
|
|
3072
|
+
// caught in the UI instead of a failed Host round-trip.
|
|
3073
|
+
const BOUNDS = {
|
|
3074
|
+
numResults: [1, 50],
|
|
3075
|
+
concurrency: [1, 10],
|
|
3076
|
+
backendTimeoutMs: [1000, 120000],
|
|
3077
|
+
};
|
|
3078
|
+
const [lo, hi] = BOUNDS[field] ?? [-Infinity, Infinity];
|
|
3079
|
+
if (!Number.isFinite(num) || num < lo || num > hi) { this.failed = true; this.failedFields.add(field); continue; }
|
|
3080
|
+
await this.scope.set(field, num);
|
|
3081
|
+
doneFields.push(field);
|
|
3082
|
+
} else {
|
|
3083
|
+
await this.scope.set(field, staged.text);
|
|
3084
|
+
doneFields.push(field);
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
for (const [ref, staged] of this.stagedKeys) {
|
|
3088
|
+
const value = staged.text.trim();
|
|
3089
|
+
if (value !== "") {
|
|
3090
|
+
try {
|
|
3091
|
+
await this.writeCredential(ref, value);
|
|
3092
|
+
this.keyState.set(ref, { configured: true, writable: true });
|
|
3093
|
+
doneKeys.push(ref);
|
|
3094
|
+
this.failedKeys.delete(ref);
|
|
3095
|
+
} catch (keyErr) {
|
|
3096
|
+
// env-shadowed reference (fail-loud doctrine): isolate so other keys still land.
|
|
3097
|
+
this.failed = true;
|
|
3098
|
+
this.failedKeys.set(ref, String(keyErr && keyErr.message ? keyErr.message : keyErr).slice(0, 140));
|
|
3099
|
+
void keyErr;
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
for (const f of doneFields) this.staged.delete(f);
|
|
3104
|
+
for (const k of doneKeys) this.stagedKeys.delete(k);
|
|
3105
|
+
if (!this.failed && (doneFields.length > 0 || doneKeys.length > 0)) this.savedAt = Date.now();
|
|
3106
|
+
} catch (err) {
|
|
3107
|
+
this.failed = true;
|
|
3108
|
+
this.conflict = String(err).includes("SETTINGS_CONFLICT");
|
|
3109
|
+
}
|
|
3110
|
+
this.saving = false;
|
|
3111
|
+
this.republish();
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
inject() {
|
|
3115
|
+
// Slot contract: hooks.{name} must be an observable store; the renderer
|
|
3116
|
+
// converts it into props.use{Name} (observableHook). Actions go top-level.
|
|
3117
|
+
return {
|
|
3118
|
+
hooks: { unifiedSearchCard: this.store },
|
|
3119
|
+
edit: (f, t) => this.edit(f, t),
|
|
3120
|
+
setToggle: (id, checked) => this.setToggle(id, checked),
|
|
3121
|
+
reset: (f) => this.reset(f),
|
|
3122
|
+
editKey: (r, t) => this.editKey(r, t),
|
|
3123
|
+
save: () => this.save(),
|
|
3124
|
+
discard: () => { this.staged.clear(); this.stagedKeys.clear(); this.failed = false; this.conflict = false; this.failedKeys.clear(); this.republish(); },
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
/** Toggle row for one backend. */
|
|
3130
|
+
function ToggleRow(props) {
|
|
3131
|
+
const b = props.backend;
|
|
3132
|
+
// The Host serves schema-resolved values, so defaults arrive materialized;
|
|
3133
|
+
// no client-side default list to drift against the server.
|
|
3134
|
+
const checked = props.value === true;
|
|
3135
|
+
// Status dot: green keyless-or-keyed, amber enabled-but-key-missing.
|
|
3136
|
+
const needsKey = !!b.keyEnv && !b.optionalKey && !props.keyConfigured;
|
|
3137
|
+
const dotColor = needsKey ? (checked ? "#e6a700" : "#888") : "#2ea043";
|
|
3138
|
+
return react_jsx_runtime.jsxs("label", { className: "wsc-toggle", style: { display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }, children: [
|
|
3139
|
+
react_jsx_runtime.jsx("input", {
|
|
3140
|
+
type: "checkbox",
|
|
3141
|
+
checked,
|
|
3142
|
+
onChange: (e) => props.onChange(e.target.checked),
|
|
3143
|
+
}),
|
|
3144
|
+
react_jsx_runtime.jsx("span", { style: { width: 8, height: 8, borderRadius: "50%", background: dotColor, display: "inline-block" } }),
|
|
3145
|
+
react_jsx_runtime.jsx("span", { children: b.label }),
|
|
3146
|
+
needsKey && checked && react_jsx_runtime.jsx("span", { style: { fontSize: 10, opacity: .65 }, children: props.t("keyMissing") }),
|
|
3147
|
+
] });
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
/** Labeled <select> for enum / boolean settings. */
|
|
3151
|
+
function SelectField(props) {
|
|
3152
|
+
return react_jsx_runtime.jsxs("div", { children: [
|
|
3153
|
+
react_jsx_runtime.jsx("div", { className: "wsc-fieldHead", children: react_jsx_runtime.jsx("span", { className: "wsc-label", children: props.label }) }),
|
|
3154
|
+
react_jsx_runtime.jsx("select", {
|
|
3155
|
+
className: "wsc-input",
|
|
3156
|
+
value: props.value,
|
|
3157
|
+
onChange: (e) => props.onChange(e.target.value),
|
|
3158
|
+
children: props.options.map((o) =>
|
|
3159
|
+
react_jsx_runtime.jsx("option", { value: o.value, children: o.label }, o.value)
|
|
3160
|
+
),
|
|
3161
|
+
}),
|
|
3162
|
+
] });
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
/** The card root. */
|
|
3166
|
+
function UnifiedSearchSection(props) {
|
|
3167
|
+
const state = props.useUnifiedSearchCard((snapshot) => snapshot);
|
|
3168
|
+
const card = { edit: props.edit, setToggle: props.setToggle, reset: props.reset, editKey: props.editKey, save: props.save, discard: props.discard };
|
|
3169
|
+
// Occupants of settings.plugin.item render inside the tab's <ul>; use <li>.
|
|
3170
|
+
if (!state.ready) {
|
|
3171
|
+
return react_jsx_runtime.jsx("div", { className: "wsc-card", style: { fontSize: 12, opacity: .6 }, children: "…" });
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
const toggleField = (id) => "enable" + id.charAt(0).toUpperCase() + id.slice(1);
|
|
3175
|
+
|
|
3176
|
+
// Status summary: enabled backends + configured keys at a glance.
|
|
3177
|
+
const enabledCount = BACKENDS.filter((b) => state.value[toggleField(b.id)] === true).length;
|
|
3178
|
+
const keyedBackends = BACKENDS.filter((b) => b.keyEnv && !b.optionalKey);
|
|
3179
|
+
const keysReady = keyedBackends.filter((b) => state.keyState[b.keyEnv]?.configured).length;
|
|
3180
|
+
|
|
3181
|
+
const recencyOptions = [
|
|
3182
|
+
{ value: "any", label: props.t("recencyAny") },
|
|
3183
|
+
{ value: "day", label: props.t("recencyDay") },
|
|
3184
|
+
{ value: "week", label: props.t("recencyWeek") },
|
|
3185
|
+
{ value: "month", label: props.t("recencyMonth") },
|
|
3186
|
+
{ value: "year", label: props.t("recencyYear") },
|
|
3187
|
+
];
|
|
3188
|
+
const dedupOptions = [
|
|
3189
|
+
{ value: "url", label: props.t("dedupUrl") },
|
|
3190
|
+
{ value: "url+title", label: props.t("dedupTitle") },
|
|
3191
|
+
];
|
|
3192
|
+
const boolOptions = [
|
|
3193
|
+
{ value: "true", label: props.t("on") },
|
|
3194
|
+
{ value: "false", label: props.t("off") },
|
|
3195
|
+
];
|
|
3196
|
+
const selVal = (f, dflt) => state.stagedText(f) ?? String(state.value[f] ?? dflt);
|
|
3197
|
+
|
|
3198
|
+
return react_jsx_runtime.jsxs("div", { className: "wsc-card", children: [
|
|
3199
|
+
react_jsx_runtime.jsx("div", { style: { display: "flex", gap: 14, alignItems: "center", padding: "10px 12px", marginBottom: 10, borderRadius: 8, background: "rgba(127,127,127,.08)", fontSize: 13 }, children: [
|
|
3200
|
+
react_jsx_runtime.jsxs("span", { children: [props.t("statusEnabled"), ": ", react_jsx_runtime.jsx("b", { children: enabledCount + "/" + BACKENDS.length })] }),
|
|
3201
|
+
react_jsx_runtime.jsxs("span", { children: [props.t("statusKeys"), ": ", react_jsx_runtime.jsx("b", { children: keysReady + "/" + keyedBackends.length })] }),
|
|
3202
|
+
] }),
|
|
3203
|
+
react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 12, opacity: .65 }, children: props.t("description") }),
|
|
3204
|
+
(enabledCount === 0) && react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 12, color: "#e6a700" }, children: props.t("fallbackHint") }),
|
|
3205
|
+
(state.writable === false) && react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 12, color: "#e6a700" }, children: props.t("readOnlyScope") }),
|
|
3206
|
+
react_jsx_runtime.jsx("h5", { className: "wsc-sectionTitle", children: props.t("groupFree") }),
|
|
3207
|
+
react_jsx_runtime.jsx("div", { className: "wsc-toggles", children: KEYLESS.map((b) =>
|
|
3208
|
+
react_jsx_runtime.jsx(ToggleRow, {
|
|
3209
|
+
backend: b,
|
|
3210
|
+
value: state.value[toggleField(b.id)],
|
|
3211
|
+
keyConfigured: true,
|
|
3212
|
+
t: props.t,
|
|
3213
|
+
onChange: (checked) => card.setToggle(b.id, checked),
|
|
3214
|
+
}, b.id)
|
|
3215
|
+
) }),
|
|
3216
|
+
react_jsx_runtime.jsx("h5", { className: "wsc-sectionTitle", children: props.t("groupKeyed") }),
|
|
3217
|
+
react_jsx_runtime.jsx("div", { className: "wsc-toggles", children: KEYED.map((b) =>
|
|
3218
|
+
react_jsx_runtime.jsx(ToggleRow, {
|
|
3219
|
+
backend: b,
|
|
3220
|
+
value: state.value[toggleField(b.id)],
|
|
3221
|
+
keyConfigured: !!state.keyState[b.keyEnv]?.configured,
|
|
3222
|
+
t: props.t,
|
|
3223
|
+
onChange: (checked) => card.setToggle(b.id, checked),
|
|
3224
|
+
}, b.id)
|
|
3225
|
+
) }),
|
|
3226
|
+
react_jsx_runtime.jsx("h4", { className: "wsc-sectionTitle", children: props.t("globalsTitle") }),
|
|
3227
|
+
react_jsx_runtime.jsxs("div", { className: "wsc-grid", children: [
|
|
3228
|
+
react_jsx_runtime.jsxs("div", { children: [
|
|
3229
|
+
react_jsx_runtime.jsx("div", { className: "wsc-fieldHead", children: react_jsx_runtime.jsx("span", { className: "wsc-label", children: "numResults" }) }),
|
|
3230
|
+
react_jsx_runtime.jsx("input", { className: "wsc-input", value: state.stagedText("numResults") ?? String(state.value.numResults ?? ""), onChange: e => card.edit("numResults", e.target.value), inputMode: "numeric" }),
|
|
3231
|
+
] }),
|
|
3232
|
+
react_jsx_runtime.jsxs("div", { children: [
|
|
3233
|
+
react_jsx_runtime.jsx("div", { className: "wsc-fieldHead", children: react_jsx_runtime.jsx("span", { className: "wsc-label", children: "concurrency" }) }),
|
|
3234
|
+
react_jsx_runtime.jsx("input", { className: "wsc-input", value: state.stagedText("concurrency") ?? String(state.value.concurrency ?? ""), onChange: e => card.edit("concurrency", e.target.value), inputMode: "numeric" }),
|
|
3235
|
+
] }),
|
|
3236
|
+
react_jsx_runtime.jsxs("div", { children: [
|
|
3237
|
+
react_jsx_runtime.jsx("div", { className: "wsc-fieldHead", children: react_jsx_runtime.jsx("span", { className: "wsc-label", children: "backendTimeoutMs" }) }),
|
|
3238
|
+
react_jsx_runtime.jsx("input", { className: "wsc-input", value: state.stagedText("backendTimeoutMs") ?? String(state.value.backendTimeoutMs ?? ""), onChange: e => card.edit("backendTimeoutMs", e.target.value), inputMode: "numeric" }),
|
|
3239
|
+
] }),
|
|
3240
|
+
] }),
|
|
3241
|
+
react_jsx_runtime.jsx("h4", { className: "wsc-sectionTitle", children: props.t("shapingTitle") }),
|
|
3242
|
+
react_jsx_runtime.jsxs("div", { className: "wsc-grid", children: [
|
|
3243
|
+
react_jsx_runtime.jsx(SelectField, {
|
|
3244
|
+
label: props.t("recencyLabel"),
|
|
3245
|
+
value: selVal("recency", "any"),
|
|
3246
|
+
options: recencyOptions,
|
|
3247
|
+
onChange: (v) => card.edit("recency", v),
|
|
3248
|
+
}),
|
|
3249
|
+
react_jsx_runtime.jsxs("div", { children: [
|
|
3250
|
+
react_jsx_runtime.jsx("div", { className: "wsc-fieldHead", children: react_jsx_runtime.jsx("span", { className: "wsc-label", children: props.t("langLabel") }) }),
|
|
3251
|
+
react_jsx_runtime.jsx("input", {
|
|
3252
|
+
className: "wsc-input",
|
|
3253
|
+
placeholder: props.t("langPlaceholder"),
|
|
3254
|
+
value: state.stagedText("language") ?? String(state.value.language ?? ""),
|
|
3255
|
+
onChange: e => card.edit("language", e.target.value),
|
|
3256
|
+
}),
|
|
3257
|
+
] }),
|
|
3258
|
+
react_jsx_runtime.jsx(SelectField, {
|
|
3259
|
+
label: props.t("dedupLabel"),
|
|
3260
|
+
value: selVal("dedupStrategy", "url"),
|
|
3261
|
+
options: dedupOptions,
|
|
3262
|
+
onChange: (v) => card.edit("dedupStrategy", v),
|
|
3263
|
+
}),
|
|
3264
|
+
react_jsx_runtime.jsx(SelectField, {
|
|
3265
|
+
label: props.t("rerankLabel"),
|
|
3266
|
+
value: selVal("rerank", "false"),
|
|
3267
|
+
options: boolOptions,
|
|
3268
|
+
onChange: (v) => card.edit("rerank", v),
|
|
3269
|
+
}),
|
|
3270
|
+
react_jsx_runtime.jsx(SelectField, {
|
|
3271
|
+
label: props.t("safeSearchLabel"),
|
|
3272
|
+
value: selVal("safeSearch", "true"),
|
|
3273
|
+
options: boolOptions,
|
|
3274
|
+
onChange: (v) => card.edit("safeSearch", v),
|
|
3275
|
+
}),
|
|
3276
|
+
react_jsx_runtime.jsx(SelectField, {
|
|
3277
|
+
label: props.t("telemetryLabel"),
|
|
3278
|
+
value: selVal("resultTelemetry", "true"),
|
|
3279
|
+
options: boolOptions,
|
|
3280
|
+
onChange: (v) => card.edit("resultTelemetry", v),
|
|
3281
|
+
}),
|
|
3282
|
+
] }),
|
|
3283
|
+
react_jsx_runtime.jsx("h4", { className: "wsc-sectionTitle", children: props.t("keysTitle") }),
|
|
3284
|
+
react_jsx_runtime.jsx("div", { className: "wsc-grid", children: BACKENDS.filter((b) => b.keyEnv && !b.optionalKey).map((b) =>
|
|
3285
|
+
react_jsx_runtime.jsxs("div", { children: [
|
|
3286
|
+
react_jsx_runtime.jsxs("div", { className: "wsc-fieldHead", children: [
|
|
3287
|
+
react_jsx_runtime.jsx("span", { className: "wsc-label", children: props.t("keyLabel", { id: b.id }) }),
|
|
3288
|
+
react_jsx_runtime.jsxs("span", { className: "wsc-badges", style: { display: "flex", gap: 6, alignItems: "center" }, children: [
|
|
3289
|
+
KEY_URLS[b.id] && react_jsx_runtime.jsx("a", {
|
|
3290
|
+
href: KEY_URLS[b.id], target: "_blank", rel: "noreferrer",
|
|
3291
|
+
style: { fontSize: 11, color: "var(--dsw-alias-accent-primary)", textDecoration: "none" },
|
|
3292
|
+
children: props.t("getKey"),
|
|
3293
|
+
}),
|
|
3294
|
+
react_jsx_runtime.jsx("span", { className: "wsc-badge" + (state.keyState[b.keyEnv]?.configured ? " wsc-badgeOn" : ""), children: state.keyState[b.keyEnv]?.configured ? props.t("keySet") : props.t("keyUnset") }),
|
|
3295
|
+
state.keyState[b.keyEnv]?.configured && state.keyState[b.keyEnv]?.writable === false && react_jsx_runtime.jsx("span", { className: "wsc-badge", style: { opacity: .75 }, children: props.t("envProvided") }),
|
|
3296
|
+
] }),
|
|
3297
|
+
] }),
|
|
3298
|
+
react_jsx_runtime.jsx("input", {
|
|
3299
|
+
className: "wsc-keyInput", type: "password", autoComplete: "off",
|
|
3300
|
+
disabled: state.keyState[b.keyEnv]?.configured && state.keyState[b.keyEnv]?.writable === false,
|
|
3301
|
+
placeholder: (state.keyState[b.keyEnv]?.configured && state.keyState[b.keyEnv]?.writable === false) ? props.t("readOnly") : b.keyEnv,
|
|
3302
|
+
value: state.stagedKeys[b.keyEnv] ?? "",
|
|
3303
|
+
onChange: (e) => card.editKey(b.keyEnv, e.target.value),
|
|
3304
|
+
}),
|
|
3305
|
+
react_jsx_runtime.jsx("p", { className: "wsc-hint", children: props.t("keyHint") }),
|
|
3306
|
+
state.failedKeys.includes(b.keyEnv) && react_jsx_runtime.jsx("p", { className: "wsc-hint", style: { color: "#e5534b" }, children: props.t("keyFailed") }),
|
|
3307
|
+
] }, b.id)
|
|
3308
|
+
) }),
|
|
3309
|
+
react_jsx_runtime.jsx("h5", { className: "wsc-sectionTitle", children: props.t("optionalTitle") }),
|
|
3310
|
+
react_jsx_runtime.jsx("div", { className: "wsc-grid", children: OPTIONAL_KEYS.map((b) =>
|
|
3311
|
+
react_jsx_runtime.jsxs("div", { children: [
|
|
3312
|
+
react_jsx_runtime.jsx("div", { className: "wsc-fieldHead", children: react_jsx_runtime.jsx("span", { className: "wsc-label", children: props.t("keyLabel", { id: b.id }) }) }),
|
|
3313
|
+
react_jsx_runtime.jsx("input", {
|
|
3314
|
+
className: "wsc-keyInput", type: "password", autoComplete: "off",
|
|
3315
|
+
placeholder: b.keyEnv,
|
|
3316
|
+
value: state.stagedKeys[b.keyEnv] ?? "",
|
|
3317
|
+
onChange: (e) => card.editKey(b.keyEnv, e.target.value),
|
|
3318
|
+
}),
|
|
3319
|
+
] }, b.id),
|
|
3320
|
+
) }),
|
|
3321
|
+
state.stagedCount > 0 && react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 12, opacity: .6 }, children: props.t("unsavedHint") }),
|
|
3322
|
+
react_jsx_runtime.jsxs("div", { className: "wsc-actions", children: [
|
|
3323
|
+
react_jsx_runtime.jsx("button", { className: "wsc-btn", disabled: state.saving || state.stagedCount === 0 || state.writable === false, onClick: () => card.save(), children: state.saving ? props.t("saving") : props.t("save") }),
|
|
3324
|
+
(state.stagedCount > 0) && react_jsx_runtime.jsx("button", { className: "wsc-btn wsc-btnGhost", onClick: () => card.discard(), children: props.t("discard") }),
|
|
3325
|
+
state.failed && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#e5534b" }, children: props.t("failed") }),
|
|
3326
|
+
(state.failedFields.length > 0) && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#e5534b", fontSize: 11 }, children: props.t("invalidFields") + ": " + state.failedFields.join(", ") }),
|
|
3327
|
+
state.conflict && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#e6a700" }, children: props.t("conflict") }),
|
|
3328
|
+
!state.failed && !state.saving && state.savedAt > 0 && react_jsx_runtime.jsx("span", { className: "wsc-status", style: { color: "#2ea043" }, children: props.t("savedOk") }),
|
|
3329
|
+
] }),
|
|
3330
|
+
] });
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
/**
|
|
3334
|
+
* Plugin entry: register the card into the settings plugins page.
|
|
3335
|
+
* Compatibility: the legacy `settingsScope` client service was removed on
|
|
3336
|
+
* the 0.2.x line; the card binds whichever data source the build offers
|
|
3337
|
+
* (see selectBridge) and registers the section unconditionally.
|
|
3338
|
+
*/
|
|
3339
|
+
const inject = ["slots", "locale", "connection", "remote"];
|
|
3340
|
+
/**
|
|
3341
|
+
* Resolve the settings data source for this build, in order:
|
|
3342
|
+
* 1. the legacy `settingsScope` service (DSH <= 0.1.6 line, or a shim);
|
|
3343
|
+
* 2. this plugin's own fenced settings routes (0.2.x line, where the
|
|
3344
|
+
* client service was removed and the settings shell renders client
|
|
3345
|
+
* slot sections only);
|
|
3346
|
+
* 3. an unavailable bridge -- the section still registers and the card
|
|
3347
|
+
* explains itself through its read-only banner.
|
|
3348
|
+
*/
|
|
3349
|
+
function selectBridge(ctx) {
|
|
3350
|
+
try {
|
|
3351
|
+
const scope = ctx.get("settingsScope");
|
|
3352
|
+
if (scope !== undefined && typeof scope.bind === "function") return scope.bind({ namespace: PLUGIN_NS });
|
|
3353
|
+
} catch (_noSettingsScope) { /* fall through to the routes bridge */ }
|
|
3354
|
+
return new SettingsRoutesBridge();
|
|
3355
|
+
}
|
|
3356
|
+
function apply(ctx) {
|
|
3357
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "websearch: copy dictionaries");
|
|
3358
|
+
const bridge = selectBridge(ctx);
|
|
3359
|
+
if (typeof bridge.load === "function") void bridge.load();
|
|
3360
|
+
const controller = new UnifiedSearchCardController(
|
|
3361
|
+
bridge,
|
|
3362
|
+
ctx.get("connection"),
|
|
3363
|
+
ctx.get("remote"),
|
|
3364
|
+
);
|
|
3365
|
+
// Credential invalidations: 0.2.x forwards `credentials/reference-updated`;
|
|
3366
|
+
// older lines forwarded `credentials/updated`. Subscribe to whichever
|
|
3367
|
+
// event face this build exposes; a missing face must not abort apply().
|
|
3368
|
+
for (const event of ["credentials/reference-updated", "credentials/updated"]) {
|
|
3369
|
+
try {
|
|
3370
|
+
ctx.effect(() => ctx.remote.$on(event, () => controller.refreshAllCredentials()), "websearch: credential invalidations");
|
|
3371
|
+
} catch (_noEventFace) { /* this build does not forward that event */ }
|
|
3372
|
+
}
|
|
3373
|
+
// Re-read when another client saves this namespace (settings document
|
|
3374
|
+
// revisions travel through the remote event face on 0.2.x).
|
|
3375
|
+
try {
|
|
3376
|
+
ctx.effect(() => ctx.remote.$on("settings/document-updated", () => { if (typeof bridge.load === "function") void bridge.load(); }), "websearch: settings document invalidations");
|
|
3377
|
+
} catch (_noSettingsEvent) { /* settings events unavailable */ }
|
|
3378
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
3379
|
+
name: "settings.section",
|
|
3380
|
+
id: "websearch",
|
|
3381
|
+
order: 16,
|
|
3382
|
+
label: () => ctx.locale.bind(NS)("nav"),
|
|
3383
|
+
locale: NS,
|
|
3384
|
+
inject: () => controller.inject(),
|
|
3385
|
+
}, UnifiedSearchSection));
|
|
3386
|
+
controller.refreshAllCredentials();
|
|
3387
|
+
|
|
3388
|
+
// Interop: devkit (and any third-party plugin) dispatches the window
|
|
3389
|
+
// event "dsh-websearch:open-settings" to surface this plugin's
|
|
3390
|
+
// settings. The host exposes no section-jump API, so: if devkit's
|
|
3391
|
+
// toast is available, point the user at the path; otherwise log.
|
|
3392
|
+
// (fe-ui C3 keeps devkit toast as the ecosystem standard notifier.)
|
|
3393
|
+
const onOpenSettings = () => {
|
|
3394
|
+
const hint = "统一搜索设置位于:设置 → 左侧栏「搜索 / Web Search」";
|
|
3395
|
+
try {
|
|
3396
|
+
if (window.__dshDevkit && typeof window.__dshDevkit.toast === "function") {
|
|
3397
|
+
window.__dshDevkit.toast(hint, { kind: "info" });
|
|
3398
|
+
return;
|
|
3399
|
+
}
|
|
3400
|
+
} catch (e) { /* fall through */ }
|
|
3401
|
+
console.info("[dsh-websearch] " + hint);
|
|
3402
|
+
};
|
|
3403
|
+
// Probe contract (devkit 0.1.1): ready flag + ack event, so the
|
|
3404
|
+
// palette can tell "listener present" from "gracefully absent".
|
|
3405
|
+
try {
|
|
3406
|
+
window.__dshWebsearchSettingsReady = true;
|
|
3407
|
+
window.addEventListener("dsh-websearch:open-settings", (ev) => {
|
|
3408
|
+
onOpenSettings(ev);
|
|
3409
|
+
try {
|
|
3410
|
+
window.dispatchEvent(new CustomEvent("dsh-websearch:open-settings:ack"));
|
|
3411
|
+
} catch (e) { /* CustomEvent unavailable */ }
|
|
3412
|
+
});
|
|
3413
|
+
ctx.effect(() => () => window.removeEventListener("dsh-websearch:open-settings", onOpenSettings), "websearch: open-settings listener");
|
|
3414
|
+
} catch (e) { /* non-window env */ }
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
exports.apply = apply;
|
|
3418
|
+
exports.inject = inject;
|
|
3419
|
+
return module.exports;
|
|
3420
|
+
}
|
|
3421
|
+
});
|
|
3422
|
+
|
|
3423
|
+
} finally {
|
|
3424
|
+
loader.load = original;
|
|
3425
|
+
}
|
|
3426
|
+
if (typeof factory !== "function") throw new Error("[dsh-plugin-hub] @240xu/dsh-websearch client bundle did not register a factory");
|
|
3427
|
+
return factory;
|
|
3428
|
+
},
|
|
3429
|
+
create(require) {
|
|
3430
|
+
const factory = this.capture();
|
|
3431
|
+
return factory(require);
|
|
3432
|
+
},
|
|
3433
|
+
},
|
|
3434
|
+
];
|
|
3435
|
+
const ROWS_PATH = "api/dsh-plugin-hub/rows";
|
|
3436
|
+
const FETCH_TIMEOUT_MS = 1500;
|
|
3437
|
+
const MOUNTED = Symbol.for("dsh-plugin-hub.mounted-client-plugins");
|
|
3438
|
+
|
|
3439
|
+
function mountedRegistry() {
|
|
3440
|
+
const root = globalThis;
|
|
3441
|
+
return (root[MOUNTED] ??= new Set());
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
function ownClientEntryIds() {
|
|
3445
|
+
const entries = globalThis.__DSH_BOOT__?.entries;
|
|
3446
|
+
const ids = new Set();
|
|
3447
|
+
if (!Array.isArray(entries)) return ids;
|
|
3448
|
+
for (const entry of entries) {
|
|
3449
|
+
if (typeof entry === "string") ids.add(entry);
|
|
3450
|
+
else if (entry && typeof entry.id === "string") ids.add(entry.id);
|
|
3451
|
+
}
|
|
3452
|
+
return ids;
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
async function fetchActiveRows() {
|
|
3456
|
+
if (typeof fetch !== "function") return undefined;
|
|
3457
|
+
let timer;
|
|
3458
|
+
try {
|
|
3459
|
+
const controller = new AbortController();
|
|
3460
|
+
timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
3461
|
+
const response = await fetch(ROWS_PATH, {
|
|
3462
|
+
credentials: "same-origin",
|
|
3463
|
+
headers: { accept: "application/json" },
|
|
3464
|
+
signal: controller.signal,
|
|
3465
|
+
});
|
|
3466
|
+
if (!response.ok) return undefined;
|
|
3467
|
+
const payload = await response.json();
|
|
3468
|
+
if (!payload || payload.ok !== true || !Array.isArray(payload.children)) return undefined;
|
|
3469
|
+
return new Set(payload.children.filter((name) => typeof name === "string"));
|
|
3470
|
+
} catch {
|
|
3471
|
+
return undefined;
|
|
3472
|
+
} finally {
|
|
3473
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
function pluginShape(module) {
|
|
3478
|
+
const face = module && module.default !== undefined ? module.default : module;
|
|
3479
|
+
if (typeof face === "function") return { apply: face, inject: undefined };
|
|
3480
|
+
if (face && typeof face.apply === "function") {
|
|
3481
|
+
return { apply: face.apply, inject: face.inject };
|
|
3482
|
+
}
|
|
3483
|
+
return undefined;
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
async function mountChildren(ctx, options = {}) {
|
|
3487
|
+
const active = await fetchActiveRows();
|
|
3488
|
+
if (options.isDisposed?.()) return;
|
|
3489
|
+
const own = ownClientEntryIds();
|
|
3490
|
+
const registry = mountedRegistry();
|
|
3491
|
+
const claimed = options.claimed ?? new Set();
|
|
3492
|
+
const create = options.create ?? ((child) => child.create(require));
|
|
3493
|
+
for (const child of CHILDREN) {
|
|
3494
|
+
if (options.isDisposed?.()) return;
|
|
3495
|
+
if (active !== undefined && !active.has(child.name)) continue;
|
|
3496
|
+
if (own.has(child.name)) continue;
|
|
3497
|
+
if (registry.has(child.name)) continue;
|
|
3498
|
+
registry.add(child.name);
|
|
3499
|
+
claimed.add(child.name);
|
|
3500
|
+
try {
|
|
3501
|
+
const shape = pluginShape(create(child));
|
|
3502
|
+
if (shape === undefined) {
|
|
3503
|
+
registry.delete(child.name);
|
|
3504
|
+
claimed.delete(child.name);
|
|
3505
|
+
console.error(`[dsh-plugin-hub] folded client ${child.name} has no usable plugin shape`);
|
|
3506
|
+
continue;
|
|
3507
|
+
}
|
|
3508
|
+
const definition = {
|
|
3509
|
+
name: child.name,
|
|
3510
|
+
inject: Array.isArray(shape.inject) ? [...shape.inject] : [],
|
|
3511
|
+
apply: shape.apply,
|
|
3512
|
+
};
|
|
3513
|
+
const fiber = ctx.plugin(definition);
|
|
3514
|
+
Promise.resolve(fiber).then(undefined, (error) => {
|
|
3515
|
+
registry.delete(child.name);
|
|
3516
|
+
claimed.delete(child.name);
|
|
3517
|
+
console.error(`[dsh-plugin-hub] folded client ${child.name} failed to start:`, error);
|
|
3518
|
+
});
|
|
3519
|
+
} catch (error) {
|
|
3520
|
+
registry.delete(child.name);
|
|
3521
|
+
claimed.delete(child.name);
|
|
3522
|
+
console.error(`[dsh-plugin-hub] folded client ${child.name} failed to mount:`, error);
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
function apply(ctx, options = {}) {
|
|
3528
|
+
const claimed = new Set();
|
|
3529
|
+
let disposed = false;
|
|
3530
|
+
try {
|
|
3531
|
+
ctx.effect(() => () => {
|
|
3532
|
+
disposed = true;
|
|
3533
|
+
const registry = mountedRegistry();
|
|
3534
|
+
for (const name of claimed) registry.delete(name);
|
|
3535
|
+
claimed.clear();
|
|
3536
|
+
}, "dsh-plugin-hub: client mount claims");
|
|
3537
|
+
} catch (error) {
|
|
3538
|
+
console.warn("[dsh-plugin-hub] failed to register client mount cleanup:", error);
|
|
3539
|
+
}
|
|
3540
|
+
void mountChildren(ctx, { ...options, claimed, isDisposed: () => disposed }).catch((error) => {
|
|
3541
|
+
console.error("[dsh-plugin-hub] folded client bridge failed:", error);
|
|
3542
|
+
});
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
return {
|
|
3546
|
+
apply,
|
|
3547
|
+
inject: [],
|
|
3548
|
+
__test: { CHILDREN, mountChildren, fetchActiveRows, ownClientEntryIds, mountedRegistry },
|
|
3549
|
+
};
|
|
3550
|
+
},
|
|
3551
|
+
});
|