dsh-as-aistudio 0.2.13 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +5 -5
- package/package.json +17 -12
- package/src/client.js +3362 -225
- package/src/components.js +1 -1
- package/src/index.js +26 -1
- package/src/shell.js +30 -5
- package/src/vendor/host/_platform.js +132 -0
- package/src/vendor/host/dsh-delete-turn.js +661 -0
- package/src/vendor/host/dsh-delete-turn.logic.js +655 -0
- package/src/vendor/host/dsh-edit-turn.js +2813 -0
- package/src/vendor/host/dsh-edit-turn.patch.js +285 -0
- package/src/vendor/host/dsh-markdown-bubble.js +37 -0
- package/src/vendor/host/dsh-rerun-turn.js +2784 -0
- package/src/vendor/versions.json +6 -0
- package/docs/INTEROP.md +0 -230
- package/src/studio.js +0 -553
- package/src/vendor/dsh-delete-turn.js +0 -1174
- package/src/vendor/dsh-edit-turn.js +0 -2626
- package/src/vendor/dsh-markdown-bubble.js +0 -872
- package/src/vendor/dsh-rerun-turn.js +0 -1014
- package/tools/build.mjs +0 -87
- package/tools/bump.mjs +0 -82
- package/tools/head.txt +0 -13
- package/tools/tail.txt +0 -5
- package/tools/tail2.txt +0 -4
- package/tools/vendor.mjs +0 -125
- package/tools/verify-manifest.mjs +0 -77
package/src/client.js
CHANGED
|
@@ -20,6 +20,9 @@ window.__ModuleLoader__.load({
|
|
|
20
20
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
21
21
|
|
|
22
22
|
const react = require('react')
|
|
23
|
+
// DOM injection has to land before the browser paints, or the host's own
|
|
24
|
+
// buttons are visible for a frame and ours pop in after them.
|
|
25
|
+
const useBrowserLayoutEffect = typeof react.useLayoutEffect === 'function' ? react.useLayoutEffect : react.useEffect
|
|
23
26
|
const jsxRuntime = require('react/jsx-runtime')
|
|
24
27
|
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
25
28
|
const { jsx, jsxs, Fragment } = jsxRuntime
|
|
@@ -40,7 +43,7 @@ window.__ModuleLoader__.load({
|
|
|
40
43
|
const RERUN_PREFIX = '/dsh-rerun-turn'
|
|
41
44
|
|
|
42
45
|
/** Keep in sync with package.json and lib/index.js. */
|
|
43
|
-
const PLUGIN_VERSION = '0.
|
|
46
|
+
const PLUGIN_VERSION = '0.5.4'
|
|
44
47
|
|
|
45
48
|
// --- copy -----------------------------------------------------------------
|
|
46
49
|
|
|
@@ -57,11 +60,47 @@ window.__ModuleLoader__.load({
|
|
|
57
60
|
'action.copied': '复制成功',
|
|
58
61
|
'editor.title': '编辑这条消息',
|
|
59
62
|
'editor.title-reply': '编辑这条回答',
|
|
60
|
-
'editor.placeholder': '
|
|
63
|
+
'editor.placeholder': '改成你希望自己当时说的内容。保存后就地替换这条消息,后面的对话保持不变。',
|
|
61
64
|
'editor.placeholder-reply': '改成你希望模型说过的内容,保存后它就成为这条回答。',
|
|
62
65
|
'editor.save': '保存',
|
|
63
66
|
'editor.rerun': '重跑',
|
|
64
67
|
'editor.save-reply': '保存',
|
|
68
|
+
'editor.block': '编辑哪一块',
|
|
69
|
+
'editor.block.all': '全部',
|
|
70
|
+
'config.style': '编辑器样式',
|
|
71
|
+
'config.placement': '编辑器位置',
|
|
72
|
+
'config.height': '编辑器高度',
|
|
73
|
+
'height.max': '一轮最大',
|
|
74
|
+
'height.content': '随内容',
|
|
75
|
+
'height.fill': '全部铺满',
|
|
76
|
+
'config.summary.height.max': '一轮最大',
|
|
77
|
+
'config.summary.height.content': '随内容',
|
|
78
|
+
'config.summary.height.fill': '全部铺满',
|
|
79
|
+
'config.hint.height.max': '打开时取这一轮所有思维链/正文里的最大高度;编辑中随你打字长高、删字变矮,但不会比打开时更矮。',
|
|
80
|
+
'config.hint.height.content': '打开时框高跟着当前块走;编辑中随你打字长高、删字变矮,但不会比打开时更矮。',
|
|
81
|
+
'config.hint.height.fill': '普通块打开时按一轮最大,编辑中随打字长高、删字变矮(不会比打开时更矮);选「全部」时铺满对话区到输入框之间的整段高度,只有一个滚动条。',
|
|
82
|
+
'placement.overlay': '浮层',
|
|
83
|
+
'placement.inline': '页面内联',
|
|
84
|
+
'placement.legacy': '整行旧版',
|
|
85
|
+
'config.summary.legacy': '整行旧版',
|
|
86
|
+
'config.hint.legacy': '整条 assistant 行套框、正文保持旧的内部滚动;同样向下盖住 action bar,功能键在左下。',
|
|
87
|
+
'config.summary.overlay': '浮层',
|
|
88
|
+
'config.summary.inline': '页面内联',
|
|
89
|
+
'config.hint.overlay': '悬浮在视口内,长内容在编辑框内滚动。',
|
|
90
|
+
'config.hint.inline': '按页面坐标铺在消息上,跟随页面滚动,高度按内容展开,不再截到视口。',
|
|
91
|
+
'config.summary.frame': '整行描边',
|
|
92
|
+
'config.summary.bubble': '贴合气泡',
|
|
93
|
+
'config.summary.center': '居中面板',
|
|
94
|
+
'config.hint.frame': '一圈描边套住整条消息行,正文输入框落在消息文字上,按钮在右上角。',
|
|
95
|
+
'config.hint.bubble': '编辑框精确等于消息气泡的大小。',
|
|
96
|
+
'config.hint.center': '大号居中面板,输入框随内容增长。',
|
|
97
|
+
'style.frame': '整行',
|
|
98
|
+
'style.bubble': '气泡',
|
|
99
|
+
'style.center': '居中',
|
|
100
|
+
'editor.block.text': '正文',
|
|
101
|
+
'editor.block.reasoning': '思维链',
|
|
102
|
+
'editor.placeholder-block': '只改这一块;工具调用、其余内容和后续对话保持不变。',
|
|
103
|
+
'confirm.body-block': '确认后只替换这一块,工具调用、其他内容和后续对话保持不变。原始会话日志不会改写。',
|
|
65
104
|
'editor.review': '确认执行',
|
|
66
105
|
'editor.cancel': '取消',
|
|
67
106
|
'editor.pending': '正在保存…',
|
|
@@ -69,12 +108,14 @@ window.__ModuleLoader__.load({
|
|
|
69
108
|
'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
|
|
70
109
|
'editor.attach.add': '添加附件',
|
|
71
110
|
'editor.attach.remove': '移除这个附件',
|
|
72
|
-
'confirm.body': '
|
|
111
|
+
'confirm.body': '确认后会立即执行:只有这条消息被替换,后面的对话保持不变。原始会话日志不会改写。',
|
|
73
112
|
'error.invalid': '请求无效,请刷新后重试。',
|
|
74
113
|
'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
|
|
75
114
|
'error.session-not-found': '找不到该会话的日志。',
|
|
115
|
+
'error.session-empty': '这个会话还没有内容,暂时没有可编辑的东西。',
|
|
76
116
|
'error.busy': '该会话正在回复中,请等回复结束后再编辑。',
|
|
77
117
|
'error.not-editable': '这条消息不支持编辑。',
|
|
118
|
+
'error.unsupported': '当前宿主不支持就地改写回复,需要走重放路径。',
|
|
78
119
|
'error.already-rolled-back': '这条消息已经不在当前上下文里了。',
|
|
79
120
|
'error.stale': '会话刚刚发生了变化,请重试。',
|
|
80
121
|
'error.forbidden': '请求来源不被允许。',
|
|
@@ -89,11 +130,13 @@ window.__ModuleLoader__.load({
|
|
|
89
130
|
'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
|
|
90
131
|
'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
|
|
91
132
|
'error.internal': '服务器内部错误,请稍后重试。',
|
|
133
|
+
'error.projection-missing': '编辑投影未注册,请重启 DSH 后重试。',
|
|
92
134
|
'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
|
|
93
135
|
'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
|
|
94
136
|
'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
|
|
95
137
|
'error.attachment-too-large': '这个文件太大,无法作为附件。',
|
|
96
138
|
'error.too-large': '这次修改太大,服务器没有接收。',
|
|
139
|
+
'error.replay-partial': '已保存并改写了这条回答,但后面的轮次没能全部重放回来,请重新打开会话确认。',
|
|
97
140
|
}
|
|
98
141
|
|
|
99
142
|
const en = {
|
|
@@ -104,11 +147,47 @@ window.__ModuleLoader__.load({
|
|
|
104
147
|
'action.copied': 'Copied',
|
|
105
148
|
'editor.title': 'Edit this message',
|
|
106
149
|
'editor.title-reply': 'Edit this reply',
|
|
107
|
-
'editor.placeholder': '
|
|
150
|
+
'editor.placeholder': 'Write what you want to have said. Saving replaces this message in place; the conversation after it stays as it is.',
|
|
108
151
|
'editor.placeholder-reply': 'Write what you want the model to have said; saving makes it this reply.',
|
|
109
152
|
'editor.save': 'Save',
|
|
110
153
|
'editor.rerun': 'Re-run',
|
|
111
154
|
'editor.save-reply': 'Save',
|
|
155
|
+
'editor.block': 'Block',
|
|
156
|
+
'editor.block.all': 'All blocks',
|
|
157
|
+
'config.style': 'Editor style',
|
|
158
|
+
'config.placement': 'Editor placement',
|
|
159
|
+
'config.height': 'Editor height',
|
|
160
|
+
'height.max': 'Turn max',
|
|
161
|
+
'height.content': 'Content',
|
|
162
|
+
'height.fill': 'Fill when all',
|
|
163
|
+
'config.summary.height.max': 'Turn max',
|
|
164
|
+
'config.summary.height.content': 'Content',
|
|
165
|
+
'config.summary.height.fill': 'Fill when all',
|
|
166
|
+
'config.hint.height.max': 'Opens at the tallest reasoning/text block of the turn; while editing it grows with your text and shrinks back, never below the height it opened at.',
|
|
167
|
+
'config.hint.height.content': 'Opens sized for the current block; while editing it grows with your text and shrinks back, never below the height it opened at.',
|
|
168
|
+
'config.hint.height.fill': 'Single blocks open at the turn max and follow your text while editing (never below the opening height); the all-blocks page fills the whole band between the conversation top and the composer, with one scrollbar.',
|
|
169
|
+
'placement.overlay': 'Overlay',
|
|
170
|
+
'placement.inline': 'In-page',
|
|
171
|
+
'placement.legacy': 'Legacy row',
|
|
172
|
+
'config.summary.legacy': 'Legacy row',
|
|
173
|
+
'config.hint.legacy': 'Frames the whole assistant row and keeps the old inner scroll; still covers the action bar, with the controls at the bottom left.',
|
|
174
|
+
'config.summary.overlay': 'Overlay',
|
|
175
|
+
'config.summary.inline': 'In-page',
|
|
176
|
+
'config.hint.overlay': 'Floats inside the viewport; long content scrolls inside the editor.',
|
|
177
|
+
'config.hint.inline': 'Lays over the message in page coordinates and scrolls with the page; it grows with the content instead of being clipped to the viewport.',
|
|
178
|
+
'config.summary.frame': 'Row frame',
|
|
179
|
+
'config.summary.bubble': 'Exact bubble',
|
|
180
|
+
'config.summary.center': 'Centered panel',
|
|
181
|
+
'config.hint.frame': 'Outline the whole message row; the textarea sits on the message text and the actions move to the top-right.',
|
|
182
|
+
'config.hint.bubble': 'The editor is exactly the message bubble.',
|
|
183
|
+
'config.hint.center': 'A large centred panel whose textarea grows with the content.',
|
|
184
|
+
'style.frame': 'Frame',
|
|
185
|
+
'style.bubble': 'Bubble',
|
|
186
|
+
'style.center': 'Center',
|
|
187
|
+
'editor.block.text': 'Answer',
|
|
188
|
+
'editor.block.reasoning': 'Reasoning',
|
|
189
|
+
'editor.placeholder-block': 'Only this block changes; tool calls, the rest of the reply and later turns stay.',
|
|
190
|
+
'confirm.body-block': 'Confirming replaces only this block; tool calls, the rest of the reply and later turns stay. The original session log is never rewritten.',
|
|
112
191
|
'editor.review': 'Confirm',
|
|
113
192
|
'editor.cancel': 'Cancel',
|
|
114
193
|
'editor.pending': 'Working...',
|
|
@@ -116,12 +195,14 @@ window.__ModuleLoader__.load({
|
|
|
116
195
|
'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
|
|
117
196
|
'editor.attach.add': 'Add an attachment',
|
|
118
197
|
'editor.attach.remove': 'Remove this attachment',
|
|
119
|
-
'confirm.body': 'Confirming applies right away:
|
|
198
|
+
'confirm.body': 'Confirming applies right away: only this message is replaced; the conversation after it stays as it is. The original session log is never rewritten.',
|
|
120
199
|
'error.invalid': 'Invalid request; refresh and try again.',
|
|
121
200
|
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
122
201
|
'error.session-not-found': 'No session log was found for this id.',
|
|
202
|
+
'error.session-empty': 'This session has nothing in it yet; there is nothing to edit.',
|
|
123
203
|
'error.busy': 'This session is still replying; wait for it to finish.',
|
|
124
204
|
'error.not-editable': 'This message cannot be edited.',
|
|
205
|
+
'error.unsupported': 'This harness does not expose the in-place reply path; the replay path is needed.',
|
|
125
206
|
'error.already-rolled-back': 'This message is no longer in the current context.',
|
|
126
207
|
'error.stale': 'The session just changed; try again.',
|
|
127
208
|
'error.forbidden': 'The request origin is not allowed.',
|
|
@@ -136,11 +217,13 @@ window.__ModuleLoader__.load({
|
|
|
136
217
|
'error.already-retired': 'This turn is no longer part of the current context.',
|
|
137
218
|
'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
|
|
138
219
|
'error.internal': 'The host hit an internal error; try again shortly.',
|
|
220
|
+
'error.projection-missing': 'The edit projection is not registered; restart DSH and retry.',
|
|
139
221
|
'error.attachment-refused': 'The attachment could not be stored, so nothing was changed.',
|
|
140
222
|
'error.attachments-dropped': 'Saved, but the images or files on this message could not be kept: this deployment mounts no attachment store.',
|
|
141
223
|
'error.attachment-unreadable': 'That attachment could not be read; try another file.',
|
|
142
224
|
'error.attachment-too-large': 'That file is too large to attach.',
|
|
143
225
|
'error.too-large': 'That revision was too large for the host to accept.',
|
|
226
|
+
'error.replay-partial': 'Saved and corrected, but the turns after it could not all be replayed; reopen the session to check.',
|
|
144
227
|
}
|
|
145
228
|
|
|
146
229
|
// Every error code the host can return travels through here. A code is
|
|
@@ -153,7 +236,157 @@ window.__ModuleLoader__.load({
|
|
|
153
236
|
return t(typeof code === 'string' && ERROR_KEYS.has(`error.${code}`) ? `error.${code}` : 'error.generic')
|
|
154
237
|
}
|
|
155
238
|
|
|
239
|
+
// --- a rewritten prompt, rendered the way the bubble it stands in for is ---
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Whether the markdown bubble plugin is on this page.
|
|
243
|
+
*
|
|
244
|
+
* Its own presence marker is the only honest signal: the host draws a user
|
|
245
|
+
* bubble as plain text (`white-space: pre-wrap`), so rendering markdown in
|
|
246
|
+
* the revision bubble is only correct when something else is already
|
|
247
|
+
* rendering the OTHER user bubbles that way. Without it, a rewritten prompt
|
|
248
|
+
* would look different from every untouched prompt around it.
|
|
249
|
+
*/
|
|
250
|
+
function markdownBubbleInstalled() {
|
|
251
|
+
try {
|
|
252
|
+
return typeof globalThis !== 'undefined' && globalThis.__DSH_MARKDOWN_BUBBLE__ !== undefined
|
|
253
|
+
} catch {
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** One mounted markdown occurrence per bubble, so a rebuild can unmount it. */
|
|
259
|
+
const revisionRoots = new WeakMap()
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Draw one rewritten prompt's text into `host`.
|
|
263
|
+
*
|
|
264
|
+
* The host's own markdown renderer (`MarkdownText`) is a React component and
|
|
265
|
+
* this half builds DOM by hand, so it is mounted through `react-dom/client` -
|
|
266
|
+
* the same module the host's own bundles require. Anything missing (no
|
|
267
|
+
* react-dom, no primitives, a hostile host shape) falls back to the plain
|
|
268
|
+
* text node this bubble has always used.
|
|
269
|
+
*
|
|
270
|
+
* @param host - the bubble element that shows the text.
|
|
271
|
+
* @param text - the rewritten text.
|
|
272
|
+
* @returns whether the markdown path was used.
|
|
273
|
+
*/
|
|
274
|
+
function renderRevisionText(host, text) {
|
|
275
|
+
if (!markdownBubbleInstalled()) return false
|
|
276
|
+
try {
|
|
277
|
+
const react = require('react')
|
|
278
|
+
const reactDom = require('react-dom/client')
|
|
279
|
+
const MarkdownText = primitives.MarkdownText
|
|
280
|
+
if (
|
|
281
|
+
typeof react?.createElement !== 'function' ||
|
|
282
|
+
typeof reactDom?.createRoot !== 'function' ||
|
|
283
|
+
MarkdownText === undefined ||
|
|
284
|
+
MarkdownText === null
|
|
285
|
+
) {
|
|
286
|
+
return false
|
|
287
|
+
}
|
|
288
|
+
const previous = revisionRoots.get(host)
|
|
289
|
+
if (previous !== undefined && typeof previous.unmount === 'function') previous.unmount()
|
|
290
|
+
const node = document.createElement('span')
|
|
291
|
+
node.className = 'dshet-revision-text'
|
|
292
|
+
host.appendChild(node)
|
|
293
|
+
const root = reactDom.createRoot(node)
|
|
294
|
+
root.render(react.createElement(MarkdownText, { text }))
|
|
295
|
+
revisionRoots.set(host, root)
|
|
296
|
+
return true
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.warn('[dsh-edit-turn] the rewritten prompt could not be rendered as markdown:', error)
|
|
299
|
+
return false
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
156
303
|
// --- style ----------------------------------------------------------------
|
|
304
|
+
/**
|
|
305
|
+
* Take the active skin's own surfaces when they are actually opaque.
|
|
306
|
+
*
|
|
307
|
+
* A skin may paint its surfaces translucent so artwork shows through, and it
|
|
308
|
+
* compensates with its own text halo - which a plugin element does not have.
|
|
309
|
+
* So the theme's surfaces are adopted only after resolving them to a real
|
|
310
|
+
* colour and finding them opaque; a translucent skin leaves this editor on
|
|
311
|
+
* its own opaque palette. Colours, radii and shadows need no such guard:
|
|
312
|
+
* those are hue and geometry, and they survive any skin.
|
|
313
|
+
*
|
|
314
|
+
* The result is written as inline variables on `body`, which beats both the
|
|
315
|
+
* `:root` defaults and the dark-theme rule below them.
|
|
316
|
+
*
|
|
317
|
+
* @returns nothing; the stylesheet defaults stand when no probe is possible.
|
|
318
|
+
*/
|
|
319
|
+
function resolveSkinPalette() {
|
|
320
|
+
if (typeof document === 'undefined' || !document.body || typeof document.createElement !== 'function') return
|
|
321
|
+
if (typeof getComputedStyle !== 'function') return
|
|
322
|
+
const probe = document.createElement('span')
|
|
323
|
+
probe.setAttribute('aria-hidden', 'true')
|
|
324
|
+
probe.style.cssText = 'position:absolute;left:-9999px;top:0;width:0;height:0;pointer-events:none'
|
|
325
|
+
document.body.appendChild(probe)
|
|
326
|
+
const read = (name) => {
|
|
327
|
+
try {
|
|
328
|
+
probe.style.color = `var(${name})`
|
|
329
|
+
const match = /rgba?\(([^)]+)\)/.exec(getComputedStyle(probe).color)
|
|
330
|
+
if (match === null) return null
|
|
331
|
+
const parts = match[1].split(',').map((value) => Number.parseFloat(value))
|
|
332
|
+
if (parts.length < 3 || parts.some((value) => !Number.isFinite(value))) return null
|
|
333
|
+
return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 }
|
|
334
|
+
} catch {
|
|
335
|
+
return null
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const css = (color) => `rgba(${Math.round(color.r)},${Math.round(color.g)},${Math.round(color.b)},${color.a})`
|
|
339
|
+
const style = document.body.style
|
|
340
|
+
const setVar = (name, value) => {
|
|
341
|
+
if (style === undefined || style === null) return
|
|
342
|
+
if (typeof style.setProperty === 'function') style.setProperty(name, value)
|
|
343
|
+
else style[name] = value
|
|
344
|
+
}
|
|
345
|
+
const clearVar = (name) => {
|
|
346
|
+
if (style === undefined || style === null) return
|
|
347
|
+
if (typeof style.removeProperty === 'function') style.removeProperty(name)
|
|
348
|
+
else delete style[name]
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
const panel = read('--dsw-alias-bg-layer-1')
|
|
352
|
+
const layer2 = read('--dsw-alias-bg-layer-2')
|
|
353
|
+
const accent = read('--dsw-alias-button-primary-fill')
|
|
354
|
+
// The skin's surface HUE is taken; its transparency is not. A skin paints
|
|
355
|
+
// its own elements translucent over artwork and gives them a text halo -
|
|
356
|
+
// a plugin element has none, so the same colour is adopted at full
|
|
357
|
+
// opacity. That keeps a dark artwork skin dark (instead of falling back
|
|
358
|
+
// to this plugin's light palette, which is what a skin that never sets
|
|
359
|
+
// `data-ds-dark-theme` used to get) without ever painting text on art.
|
|
360
|
+
const solid = (color) => (color === null ? null : { r: color.r, g: color.g, b: color.b, a: 1 })
|
|
361
|
+
const panelSolid = solid(panel)
|
|
362
|
+
if (panelSolid === null) {
|
|
363
|
+
clearVar('--dshet-panel')
|
|
364
|
+
clearVar('--dshet-chip')
|
|
365
|
+
clearVar('--dshet-field')
|
|
366
|
+
} else {
|
|
367
|
+
const fieldSolid = solid(layer2) ?? panelSolid
|
|
368
|
+
setVar('--dshet-panel', css(panelSolid))
|
|
369
|
+
setVar('--dshet-chip', css(panelSolid))
|
|
370
|
+
setVar('--dshet-field', css(fieldSolid))
|
|
371
|
+
}
|
|
372
|
+
if (accent === null) {
|
|
373
|
+
clearVar('--dshet-on-accent')
|
|
374
|
+
} else {
|
|
375
|
+
// The label that sits ON the accent fill: the platform exposes no
|
|
376
|
+
// inverse token here, so it is picked from the fill's own luminance.
|
|
377
|
+
const channel = (value) => {
|
|
378
|
+
const v = value / 255
|
|
379
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
|
|
380
|
+
}
|
|
381
|
+
const luminance = 0.2126 * channel(accent.r) + 0.7152 * channel(accent.g) + 0.0722 * channel(accent.b)
|
|
382
|
+
setVar('--dshet-on-accent', luminance > 0.35 ? '#0f1115' : '#fff')
|
|
383
|
+
}
|
|
384
|
+
} finally {
|
|
385
|
+
if (typeof probe.remove === 'function') probe.remove()
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
157
390
|
|
|
158
391
|
// A skin is free to make the theme's surface colours translucent - that is
|
|
159
392
|
// the point of one: the artwork shows through, and the skin compensates by
|
|
@@ -168,8 +401,21 @@ window.__ModuleLoader__.load({
|
|
|
168
401
|
// UI packages use. Theme variables are still used for *colours* (hue,
|
|
169
402
|
// semantic tones) - those survive a skin; it is alpha that does not.
|
|
170
403
|
const CSS = [
|
|
171
|
-
|
|
172
|
-
|
|
404
|
+
// Colours, radii and shadows come from the active theme's own tokens, so a
|
|
405
|
+
// skin restyles this editor the way it restyles the rest of the app; every
|
|
406
|
+
// one of them falls back to the value this plugin shipped with. The palette
|
|
407
|
+
// hangs off `body`, not `:root`: a custom property substitutes against the
|
|
408
|
+
// values in scope where it is DEFINED, and the theme declares its tokens on
|
|
409
|
+
// body - on `:root` every reference would silently fall back. Only the
|
|
410
|
+
// SURFACES stay namespaced: a skin is free to make its own surfaces
|
|
411
|
+
// translucent (that is the point of one), and a plugin's element has no
|
|
412
|
+
// artwork-aware halo to stay readable behind. The surfaces are taken from
|
|
413
|
+
// the theme too, but only when a runtime probe finds them opaque (see
|
|
414
|
+
// resolveSkinPalette).
|
|
415
|
+
'body{--dshet-panel:rgba(255,255,255,.96);--dshet-field:rgba(244,247,252,.98);--dshet-chip:rgba(255,255,255,.82);--dshet-line:var(--dsw-alias-border-l2,rgba(16,24,40,.16));--dshet-ink:var(--dsw-alias-label-primary,#0f1524);--dshet-ink-dim:var(--dsw-alias-label-secondary,#4b5872);--dshet-ink-soft:var(--dsw-alias-label-tertiary,#81858c);--dshet-hover:rgba(16,24,40,.07);--dshet-shadow:var(--dsw-shadow-lv3,0 12px 32px rgba(9,18,40,.22));--dshet-accent:var(--dsw-alias-button-primary-fill,#4d6bfe);--dshet-on-accent:#fff;--dshet-bubble:var(--dsw-specific-bubble,var(--dshet-field));--dshet-radius-panel:var(--dsw-radius-panel,16px);--dshet-radius-xl:var(--dsw-radius-xl,16px);--dshet-radius-lg:var(--dsw-radius-lg,16px);--dshet-radius-md:var(--dsw-radius-md,12px);--dshet-radius-sm:var(--dsw-radius-sm,8px)}',
|
|
416
|
+
'body[data-ds-dark-theme]{--dshet-panel:rgba(22,28,44,.96);--dshet-field:rgba(14,19,33,.98);--dshet-chip:rgba(22,28,44,.82);--dshet-hover:rgba(255,255,255,.11);--dshet-shadow:var(--dsw-shadow-lv3,0 12px 32px rgba(0,0,0,.5))}',
|
|
417
|
+
// A hover wash that follows the skin's ink instead of a fixed grey.
|
|
418
|
+
'@supports (color:color-mix(in srgb,red,blue)){body{--dshet-hover:color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent)}body[data-ds-dark-theme]{--dshet-hover:color-mix(in srgb,var(--dsw-alias-label-primary) 14%,transparent)}}',
|
|
173
419
|
// The pencil sits among the platform's own action icons, so it takes the
|
|
174
420
|
// platform's icon colour: our own `--dshet-ink-dim` is a blue-grey and
|
|
175
421
|
// read as a different, "active" icon next to the neutral grey ones. The
|
|
@@ -195,7 +441,7 @@ window.__ModuleLoader__.load({
|
|
|
195
441
|
// inherit picks up the row's 16px, fixed ignores the reader's content
|
|
196
442
|
// font setting, and either way the rewritten text changed size against
|
|
197
443
|
// the bubble it replaces.
|
|
198
|
-
'.dshet-revision{box-sizing:border-box;align-self:flex-end;width:fit-content;max-width:min(72ch,86%);margin:6px 0 6px auto;padding:9px 14px;border-radius:
|
|
444
|
+
'.dshet-revision{box-sizing:border-box;align-self:flex-end;width:fit-content;max-width:min(72ch,86%);margin:6px 0 6px auto;padding:9px 14px;border-radius:var(--dshet-radius-xl);background:var(--dshet-bubble);color:var(--dshet-ink);font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}',
|
|
199
445
|
'.dshet-revision:empty{display:none}',
|
|
200
446
|
'.dshet-revision{position:relative}',
|
|
201
447
|
// Fallback only: a row with no action bar to keep is displayed away
|
|
@@ -204,6 +450,9 @@ window.__ModuleLoader__.load({
|
|
|
204
450
|
// positioned button inside the bubble used to land on the last line, which
|
|
205
451
|
// is how the rewritten prompt got covered up.
|
|
206
452
|
'.dshet-revision.dshet-revision-action{padding-inline-end:40px}',
|
|
453
|
+
'.dshet-revision-text{display:block}',
|
|
454
|
+
'.dshet-revision-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}',
|
|
455
|
+
'.dshet-revision-file{display:inline-flex;align-items:center;gap:6px;max-width:100%;padding:2px 6px 2px 2px;border:1px solid var(--dshet-line);border-radius:var(--dshet-radius-sm);background:var(--dshet-chip);font-size:12px;line-height:18px}',
|
|
207
456
|
'.dshet-revision .dshet-action-host{position:absolute;inset-inline-end:6px;top:6px;opacity:0;transition:opacity .12s ease}',
|
|
208
457
|
'.dshet-revision:hover .dshet-action-host,.dshet-revision:focus-within .dshet-action-host{opacity:1}',
|
|
209
458
|
// A sibling plugin can append its own button (dsh-delete-turn's bin) into
|
|
@@ -242,11 +491,21 @@ window.__ModuleLoader__.load({
|
|
|
242
491
|
// composition and its clicks a piece at a time. The layer covers the
|
|
243
492
|
// viewport but only the box itself takes pointer events, and the box is
|
|
244
493
|
// positioned under its row by `placeEditor`.
|
|
494
|
+
'.dshet-layer[data-dshet-inline="1"]{position:absolute;left:0;top:0;right:auto;bottom:auto;width:100%;height:0}',
|
|
245
495
|
'.dshet-layer{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:40}',
|
|
246
496
|
'.dshet-layer .dshet-editor{position:absolute;pointer-events:auto}',
|
|
247
|
-
'.dshet-editor{margin:0;border:1px solid var(--dshet-line);border-radius:
|
|
497
|
+
'.dshet-editor{display:flex;flex-direction:column;max-height:calc(100vh - 24px);overflow-y:auto;overscroll-behavior:contain;margin:0;border:1px solid var(--dshet-line);border-radius:var(--dshet-radius-panel);background:var(--dshet-panel);backdrop-filter:blur(var(--dsh-input-card-blur,18px)) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink);transition:border-color .12s}',
|
|
498
|
+
'.dshet-editor[data-dshet-drop="1"]{outline:2px dashed #4a7dff;outline-offset:-4px}',
|
|
248
499
|
'.dshet-editor:focus-within{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
|
|
249
|
-
'.dshet-editor
|
|
500
|
+
'.dshet-editor[data-dshet-inplace="1"]{overflow:hidden;border:1.5px solid var(--dsw-alias-button-primary-fill,#4a7dff);border-radius:var(--dshet-radius-md);background:var(--dshet-panel);backdrop-filter:none;box-shadow:none}',
|
|
501
|
+
'.dshet-editor[data-dshet-inplace="1"] textarea{position:absolute;min-height:0;max-height:none;padding:6px 10px;border:0;border-radius:8px;background:transparent;overflow-y:auto}',
|
|
502
|
+
'.dshet-editor[data-dshet-inplace="1"] .dshet-footer{position:absolute;top:auto;left:0;right:0;bottom:0;height:46px;align-items:center;padding:6px 10px;box-sizing:border-box;background:var(--dshet-panel);border-radius:0 0 var(--dshet-radius-md) var(--dshet-radius-md);gap:6px}',
|
|
503
|
+
'.dshet-editor[data-dshet-inplace="1"] .dshet-btn{padding:2px 8px;font-size:12px;line-height:18px;border-radius:8px}',
|
|
504
|
+
'.dshet-editor[data-dshet-inplace="1"] .dshet-pick{position:static;top:auto;left:auto;padding:0;background:transparent;border-radius:0}',
|
|
505
|
+
'.dshet-editor[data-dshet-inplace="1"] .dshet-note,.dshet-editor[data-dshet-inplace="1"] .dshet-warn{position:absolute;left:8px;bottom:6px;max-width:60%;margin:0;background:var(--dshet-panel);border-radius:8px;padding:2px 6px}',
|
|
506
|
+
'.dshet-editor[data-dshet-inplace="1"] .dshet-error{position:absolute;left:8px;right:8px;bottom:52px;max-width:none;margin:0;padding:6px 10px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-panel);box-shadow:var(--dshet-shadow);font-weight:500}',
|
|
507
|
+
|
|
508
|
+
'.dshet-editor textarea{display:block;width:100%;min-height:47px;max-height:min(46vh,420px);overflow-y:auto;resize:none;box-sizing:border-box;padding:11px 14px 0;border:0;background:transparent;color:inherit;font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));outline:none}',
|
|
250
509
|
'.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
|
|
251
510
|
'.dshet-editor textarea:disabled{opacity:.65}',
|
|
252
511
|
// The blocks this message carries: one chip each, drawn from the same
|
|
@@ -262,8 +521,9 @@ window.__ModuleLoader__.load({
|
|
|
262
521
|
'.dshet-chip-remove:hover:not(:disabled){background:var(--dshet-hover);color:var(--dsw-alias-state-error-primary,#c93a31)}',
|
|
263
522
|
'.dshet-chip-remove:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:1px}',
|
|
264
523
|
'.dshet-chip-remove:disabled{cursor:default;opacity:.5}',
|
|
265
|
-
'.dshet-attach-row{display:flex;align-items:center;
|
|
266
|
-
'.dshet-attach{
|
|
524
|
+
'.dshet-attach-row{position:absolute;left:12px;bottom:10px;z-index:2;display:flex;align-items:center;margin:0}',
|
|
525
|
+
'.dshet-attach{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:1px 6px;box-sizing:border-box;border:0;border-radius:999px;background:#f5f6f7;color:#0f1115;font:inherit;line-height:1;cursor:pointer;outline:none;box-shadow:none;-webkit-appearance:none;transition:background-color .12s,color .12s}',
|
|
526
|
+
'.dshet-attach svg{width:14px;height:14px;display:block;stroke:currentColor;fill:none;stroke-width:1.3;}',
|
|
267
527
|
'.dshet-attach:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink)}',
|
|
268
528
|
'.dshet-attach:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
|
|
269
529
|
'.dshet-attach:disabled{cursor:default;opacity:.5}',
|
|
@@ -272,13 +532,46 @@ window.__ModuleLoader__.load({
|
|
|
272
532
|
'.dshet-note{color:var(--dshet-ink-dim)}',
|
|
273
533
|
'.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
|
|
274
534
|
'.dshet-error{color:var(--dsw-alias-state-error-primary,#c93a31)}',
|
|
275
|
-
'.dshet-footer{display:flex;align-items:center;justify-content:
|
|
535
|
+
'.dshet-footer{position:absolute;left:0;right:0;bottom:0;top:auto;z-index:3;display:flex;align-items:center;justify-content:space-between;gap:6px;height:46px;padding:6px 10px;box-sizing:border-box;background:var(--dshet-panel);border-radius:0 0 var(--dshet-radius-panel) var(--dshet-radius-panel)}',
|
|
536
|
+
'.dshet-footer-actions{display:flex;align-items:center;gap:6px}',
|
|
537
|
+
'.dshet-all-nav{position:sticky;top:0;z-index:2;display:flex;flex-wrap:nowrap;align-items:center;gap:6px;min-height:28px;padding:4px 0 6px;background:var(--dshet-panel);overflow-x:auto;scrollbar-width:none}',
|
|
538
|
+
'.dshet-all-nav button{flex:none;border:0;border-radius:999px;background:var(--dshet-hover);color:var(--dshet-ink-dim);font:inherit;font-size:12px;line-height:18px;padding:3px 10px;cursor:pointer;white-space:nowrap}',
|
|
539
|
+
'.dshet-all-nav button:hover{color:var(--dshet-ink)}',
|
|
540
|
+
'.dshet-all{position:absolute;left:8px;right:8px;top:6px;bottom:52px;overflow-y:auto;display:flex;flex-direction:column;padding:2px 4px 4px}',
|
|
541
|
+
'.dshet-all-block{display:flex;flex-direction:column;padding:6px 0 8px}',
|
|
542
|
+
'.dshet-all-block:first-child{padding-top:2px}',
|
|
543
|
+
'.dshet-all-head{display:flex;width:100%;box-sizing:border-box;align-items:center;gap:8px;font-size:11px;color:var(--dshet-ink-dim);padding:0 2px 4px}',
|
|
544
|
+
'.dshet-all-head>span:first-child{background:var(--dshet-hover);border-radius:6px;padding:1px 6px;white-space:nowrap}',
|
|
545
|
+
'.dshet-all-line{flex:1;height:1px;background:var(--dshet-line);opacity:.55}',
|
|
546
|
+
'.dshet-editor[data-dshet-inplace="1"] .dshet-all .dshet-all-area{position:static;left:auto;top:auto;width:100%;height:auto;min-height:32px;max-height:none;padding:0 2px;border-radius:0;overflow:hidden}',
|
|
547
|
+
'.dshet-all-area{display:block;width:100%;min-height:32px;max-height:none;box-sizing:border-box;border:0;background:transparent;color:inherit;font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));resize:none;outline:none;overflow:hidden;scrollbar-width:none;padding:0 2px}',
|
|
548
|
+
'.dshet-editor .dshet-footer .dshet-btn{height:34px;box-sizing:border-box;padding:0 14px}',
|
|
276
549
|
'.dshet-btn{padding:6px 16px;border:1px solid var(--dshet-line);border-radius:999px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background-color .12s,border-color .12s}',
|
|
277
550
|
'.dshet-btn:hover:not(:disabled){background:var(--dshet-hover)}',
|
|
278
551
|
'.dshet-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
|
|
279
552
|
'.dshet-btn:disabled{cursor:default;opacity:.5}',
|
|
553
|
+
'.dshet-btn-save{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:#81858c}',
|
|
554
|
+
'.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover)}',
|
|
555
|
+
'.dshet-btn-save svg{width:15px;height:15px;display:block;stroke:currentColor;fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round}',
|
|
556
|
+
'.dshet-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}',
|
|
280
557
|
'.dshet-btn-primary{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent));background:var(--dsw-alias-button-primary-fill,var(--dshet-accent));color:var(--dsw-alias-label-primary-foreground,var(--dshet-on-accent))}',
|
|
281
558
|
'.dshet-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-fill,var(--dshet-accent));filter:brightness(1.08)}',
|
|
559
|
+
'.dshet-btn.dshet-btn-save{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:var(--dshet-ink-soft);filter:none}',
|
|
560
|
+
'.dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
|
|
561
|
+
'.dshet-btn.dshet-btn-save.dshet-btn-confirm{background:var(--dshet-hover);box-shadow:inset 0 0 0 1.5px var(--dshet-accent)}',
|
|
562
|
+
'.dshet-editor .dshet-footer .dshet-btn.dshet-btn-icon{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:#81858c;filter:none}',
|
|
563
|
+
'.dshet-editor .dshet-footer .dshet-btn.dshet-btn-icon:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink-soft);filter:none}',
|
|
564
|
+
'.dshet-btn-icon svg{width:15px;height:15px;display:block;fill:none;stroke:currentColor;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round}',
|
|
565
|
+
'.dshet-editor[data-dshet-inplace="1"] .dshet-btn.dshet-btn-save{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:#81858c;filter:none}',
|
|
566
|
+
'.dshet-editor[data-dshet-inplace="1"] .dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
|
|
567
|
+
'.dshet-pick{display:flex;align-items:center;gap:4px;padding:0;font-size:12px;color:var(--dshet-ink-dim)}',
|
|
568
|
+
'.dshet-config{display:flex;flex-direction:column;gap:10px}',
|
|
569
|
+
'.dshet-config-row{display:flex;align-items:center;justify-content:space-between;gap:12px}',
|
|
570
|
+
'.dshet-config-hint{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
|
|
571
|
+
'.dshet-select{padding:3px 8px;border:1px solid var(--dshet-line);border-radius:var(--dshet-radius-sm);background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:12px;line-height:18px}',
|
|
572
|
+
'.dshet-pick .dshet-select{height:28px;padding:0 6px;border:0;border-radius:8px;background:transparent;line-height:1;cursor:pointer;outline:none}',
|
|
573
|
+
'.dshet-pick .dshet-select:hover{background:var(--dshet-hover)}',
|
|
574
|
+
'[data-dshet-patch]{white-space:pre-wrap}',
|
|
282
575
|
'.dshet-notice{margin:4px 0;padding:6px 10px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dsw-alias-state-error-primary,#c93a31);font-size:12px;line-height:19px}',
|
|
283
576
|
'@media (prefers-reduced-motion:reduce){.dshet-collapsing{transition:none}.dshet-floating{transition:none}}',
|
|
284
577
|
].join('')
|
|
@@ -340,8 +633,13 @@ window.__ModuleLoader__.load({
|
|
|
340
633
|
this.editorElement = null
|
|
341
634
|
this.editorBox = null
|
|
342
635
|
this.editorRow = null
|
|
636
|
+
this.editorAnchor = null
|
|
637
|
+
this.editorMode = 'reply'
|
|
638
|
+
this.editorStyle = 'frame'
|
|
343
639
|
this.editorFocus = false
|
|
344
640
|
this.editorDraft = ''
|
|
641
|
+
this.editorBlockKey = ''
|
|
642
|
+
this.editorDrafts = new Map()
|
|
345
643
|
// The block list being edited: what the carrier will be rebuilt from.
|
|
346
644
|
// Every entry is either `{ keep: <index> }` - a block of this message the
|
|
347
645
|
// user left alone - or `{ add: { data, mediaType?, name } }` for
|
|
@@ -366,6 +664,15 @@ window.__ModuleLoader__.load({
|
|
|
366
664
|
editable: new Map(),
|
|
367
665
|
replies: new Map(),
|
|
368
666
|
repliesByMessage: new Map(),
|
|
667
|
+
// Landed in-place reply patches, latest per block, and the same data
|
|
668
|
+
// indexed by seq for the in-place text display.
|
|
669
|
+
patches: [],
|
|
670
|
+
patchBySeq: new Map(),
|
|
671
|
+
replyMode: 'replay',
|
|
672
|
+
// The block being edited in the reply editor (null for a prompt).
|
|
673
|
+
blocks: [],
|
|
674
|
+
blockIndex: null,
|
|
675
|
+
blockKind: null,
|
|
369
676
|
editing: null,
|
|
370
677
|
draft: '',
|
|
371
678
|
parts: [],
|
|
@@ -444,6 +751,22 @@ window.__ModuleLoader__.load({
|
|
|
444
751
|
for (const turn of Array.isArray(data.turns) ? data.turns : []) {
|
|
445
752
|
if (turn && typeof turn.seq === 'number') editable.set(turn.seq, turn)
|
|
446
753
|
}
|
|
754
|
+
// The in-place reply patches, latest per block. The model sees them
|
|
755
|
+
// through the host projection; this half uses them to draw the
|
|
756
|
+
// projected text over the recorded row.
|
|
757
|
+
const patches = []
|
|
758
|
+
const patchBySeq = new Map()
|
|
759
|
+
for (const patch of Array.isArray(data.patches) ? data.patches : []) {
|
|
760
|
+
if (!patch || typeof patch.seq !== 'number' || typeof patch.blockIndex !== 'number') continue
|
|
761
|
+
if (patch.kind !== 'text' && patch.kind !== 'reasoning') continue
|
|
762
|
+
const normalized = { seq: patch.seq, blockIndex: patch.blockIndex, kind: patch.kind, text: String(patch.text ?? '') }
|
|
763
|
+
const previous = patches.findIndex((entry) => entry.seq === normalized.seq && entry.blockIndex === normalized.blockIndex)
|
|
764
|
+
if (previous >= 0) patches[previous] = normalized
|
|
765
|
+
else patches.push(normalized)
|
|
766
|
+
let byBlock = patchBySeq.get(normalized.seq)
|
|
767
|
+
if (byBlock === undefined) { byBlock = new Map(); patchBySeq.set(normalized.seq, byBlock) }
|
|
768
|
+
byBlock.set(normalized.blockIndex, normalized)
|
|
769
|
+
}
|
|
447
770
|
// The model's replies are editable too, as a separate list: they are
|
|
448
771
|
// rewritten rather than re-run, so the editor words the consequence
|
|
449
772
|
// differently.
|
|
@@ -456,13 +779,26 @@ window.__ModuleLoader__.load({
|
|
|
456
779
|
// replies are indexed by it too.
|
|
457
780
|
if (typeof reply.messageId === 'string' && reply.messageId !== '') repliesByMessage.set(reply.messageId, reply)
|
|
458
781
|
}
|
|
782
|
+
let preferredStyle = data.config && (data.config.editorStyle === 'bubble' || data.config.editorStyle === 'center')
|
|
783
|
+
? data.config.editorStyle
|
|
784
|
+
: 'frame'
|
|
785
|
+
const savedStyle = readStoredEditorStyle()
|
|
786
|
+
if (savedStyle !== null) preferredStyle = savedStyle
|
|
787
|
+
this.editorStyle = preferredStyle
|
|
459
788
|
this.publish({
|
|
460
789
|
hidden,
|
|
461
790
|
revisions,
|
|
462
791
|
editable,
|
|
463
792
|
replies,
|
|
464
793
|
repliesByMessage,
|
|
794
|
+
patches,
|
|
795
|
+
patchBySeq,
|
|
796
|
+
replyMode: data.mode === 'patch' ? 'patch' : 'replay',
|
|
797
|
+
editorStyle: data.config && (data.config.editorStyle === 'bubble' || data.config.editorStyle === 'center')
|
|
798
|
+
? data.config.editorStyle
|
|
799
|
+
: 'frame',
|
|
465
800
|
surfaceReady: true,
|
|
801
|
+
busy: data.busy === true,
|
|
466
802
|
loaded: true,
|
|
467
803
|
loadError: false,
|
|
468
804
|
confirmStep: !(data.config && data.config.confirm === false),
|
|
@@ -477,6 +813,14 @@ window.__ModuleLoader__.load({
|
|
|
477
813
|
})
|
|
478
814
|
.finally(() => {
|
|
479
815
|
this.inflight = null
|
|
816
|
+
// While the session is replying, the editor stays open but cannot
|
|
817
|
+
// save; re-check so the banner clears itself when the turn ends.
|
|
818
|
+
if (this.view.busy === true && this.editor !== null) {
|
|
819
|
+
const token = (this.busyToken = (this.busyToken ?? 0) + 1)
|
|
820
|
+
window.setTimeout(() => {
|
|
821
|
+
if (this.busyToken === token && this.editor !== null) this.load()
|
|
822
|
+
}, 3000)
|
|
823
|
+
}
|
|
480
824
|
})
|
|
481
825
|
this.inflight = pending
|
|
482
826
|
return pending
|
|
@@ -489,10 +833,53 @@ window.__ModuleLoader__.load({
|
|
|
489
833
|
const same = this.editor !== null && this.editor.seq === target.seq && this.editor.mode === target.mode
|
|
490
834
|
const previous = this.editorElement
|
|
491
835
|
this.load()
|
|
492
|
-
|
|
836
|
+
// A reply on the patch path carries its editable blocks; open the one
|
|
837
|
+
// the editor was asked for, or the answer text when none was named.
|
|
838
|
+
const replyBlocks = target.mode === 'reply' && Array.isArray(target.blocks) ? target.blocks : []
|
|
839
|
+
const requested = typeof target.blockIndex === 'number'
|
|
840
|
+
? replyBlocks.find((block) => block.index === target.blockIndex)
|
|
841
|
+
: undefined
|
|
842
|
+
// Pressing the same pencil again keeps the block the user already
|
|
843
|
+
// switched to; only a fresh open picks the answer block by default.
|
|
844
|
+
const current = same && typeof this.editorBlockIndex === 'number'
|
|
845
|
+
? replyBlocks.find((block) => block.index === this.editorBlockIndex)
|
|
846
|
+
: undefined
|
|
847
|
+
// A fresh open always lands on the answer text: a turn's block list is
|
|
848
|
+
// mostly reasoning now, and opening on a chain-of-thought is not what
|
|
849
|
+
// the pencil means. The picker is how the user goes to a thought.
|
|
850
|
+
const answerBlock = [...replyBlocks].reverse().find((block) => block.kind === 'text')
|
|
851
|
+
const chosen = (same ? current : undefined) ?? answerBlock ?? requested ?? replyBlocks[0]
|
|
852
|
+
const normalized = replyBlocks.length > 0
|
|
853
|
+
? {
|
|
854
|
+
...target,
|
|
855
|
+
patch: true,
|
|
856
|
+
blocks: replyBlocks,
|
|
857
|
+
blockIndex: chosen === undefined ? undefined : chosen.index,
|
|
858
|
+
blockKind: chosen === undefined ? undefined : chosen.kind,
|
|
859
|
+
}
|
|
860
|
+
: target
|
|
861
|
+
this.editor = normalized
|
|
862
|
+
this.editorMode = normalized.mode === 'prompt' ? 'prompt' : 'reply'
|
|
863
|
+
this.editorAnchor = target.anchor !== undefined && target.anchor !== null && target.anchor.isConnected === true
|
|
864
|
+
? target.anchor
|
|
865
|
+
: null
|
|
866
|
+
this.editorBlocks = replyBlocks
|
|
867
|
+
this.editorBlockIndex = chosen === undefined ? null : chosen.index
|
|
868
|
+
this.editorBlockKind = chosen === undefined ? null : chosen.kind
|
|
869
|
+
this.editorTargetSeq = chosen !== undefined && typeof chosen.seq === 'number' ? chosen.seq : null
|
|
870
|
+
this.editorAll = false
|
|
871
|
+
// The height the box opens at: the floor the user cannot shrink below
|
|
872
|
+
// for as long as this edit lasts (save or cancel ends it). Null until
|
|
873
|
+
// the first layout measures it.
|
|
874
|
+
this.editorBaseHeight = null
|
|
875
|
+
// ONE draft store per open, keyed by block: the 正文/思维链 pages and the
|
|
876
|
+
// sub-pages inside 全部 all read and write it, so switching pages never
|
|
877
|
+
// drops what the user typed (only closing the editor does).
|
|
878
|
+
this.editorDrafts = new Map()
|
|
493
879
|
if (!same) {
|
|
494
|
-
this.
|
|
495
|
-
this.
|
|
880
|
+
this.editorBlockKey = chosen === undefined ? '' : blockDraftKey(chosen)
|
|
881
|
+
this.editorDraft = chosen === undefined ? target.text : chosen.text
|
|
882
|
+
this.editorParts = blocksToParts(normalized, this.sessionId)
|
|
496
883
|
}
|
|
497
884
|
this.confirming = false
|
|
498
885
|
this.editorElement = null
|
|
@@ -509,7 +896,18 @@ window.__ModuleLoader__.load({
|
|
|
509
896
|
/* focus is best-effort */
|
|
510
897
|
}
|
|
511
898
|
}
|
|
512
|
-
this.publish({
|
|
899
|
+
this.publish({
|
|
900
|
+
editing: normalized,
|
|
901
|
+
draft: this.editorDraft,
|
|
902
|
+
parts: this.editorParts.slice(),
|
|
903
|
+
blocks: replyBlocks,
|
|
904
|
+
blockIndex: this.editorBlockIndex,
|
|
905
|
+
blockKind: this.editorBlockKind,
|
|
906
|
+
targetSeq: this.editorTargetSeq,
|
|
907
|
+
allBlocks: false,
|
|
908
|
+
confirming: false,
|
|
909
|
+
failure: null,
|
|
910
|
+
})
|
|
513
911
|
this.probeSiblingRerun()
|
|
514
912
|
}
|
|
515
913
|
|
|
@@ -537,23 +935,108 @@ window.__ModuleLoader__.load({
|
|
|
537
935
|
if (this.view.siblingRerun !== next) this.publish({ siblingRerun: next })
|
|
538
936
|
}
|
|
539
937
|
|
|
938
|
+
// Pick another block of the same reply. The editor is rebuilt by the
|
|
939
|
+
// block index in its marker; the draft becomes that block's current
|
|
940
|
+
// (already projected) text.
|
|
941
|
+
selectBlock(value) {
|
|
942
|
+
if (this.view.pending) return
|
|
943
|
+
const target = this.editor
|
|
944
|
+
if (target === null || !Array.isArray(target.blocks)) return
|
|
945
|
+
const raw = String(value)
|
|
946
|
+
if (raw === 'all') {
|
|
947
|
+
// The all-blocks page reads the SAME drafts: a block edited on its own
|
|
948
|
+
// page shows that edit in its sub-page, and the other way round.
|
|
949
|
+
this.editorAll = true
|
|
950
|
+
this.editorElement = null
|
|
951
|
+
this.publish({ allBlocks: true, blockIndex: null, blockKind: null, targetSeq: null, failure: null })
|
|
952
|
+
return
|
|
953
|
+
}
|
|
954
|
+
const split = raw.includes(':') ? raw.split(':') : ['', raw]
|
|
955
|
+
const seq = split[0] === '' ? null : Number(split[0])
|
|
956
|
+
const index = Number(split[1])
|
|
957
|
+
const block = target.blocks.find((entry) =>
|
|
958
|
+
entry.index === index && (seq === null || entry.seq === undefined || entry.seq === seq))
|
|
959
|
+
// Leaving the all-blocks view has to say so: the picker change alone left
|
|
960
|
+
// `allBlocks` true, so the box was redrawn as the all view again with a
|
|
961
|
+
// picker that said "正文" - two fields over a single-block selection.
|
|
962
|
+
this.editorAll = false
|
|
963
|
+
if (block === undefined || block.kind === undefined) {
|
|
964
|
+
// Nothing matched: still leave the all view, keeping the block that was
|
|
965
|
+
// selected before it, so the picker describes the box again.
|
|
966
|
+
this.publish({ allBlocks: false, failure: null })
|
|
967
|
+
return
|
|
968
|
+
}
|
|
969
|
+
this.editorBlockIndex = block.index
|
|
970
|
+
this.editorBlockKind = block.kind
|
|
971
|
+
this.editorTargetSeq = typeof block.seq === 'number' ? block.seq : this.editorTargetSeq
|
|
972
|
+
this.editorBlockKey = blockDraftKey(block)
|
|
973
|
+
// This block's own draft, which may already carry what the user typed on
|
|
974
|
+
// another page - never the recorded text, which used to wipe it.
|
|
975
|
+
this.editorDraft = this.draftOfBlock(block)
|
|
976
|
+
this.editorElement = null
|
|
977
|
+
this.publish({ draft: this.editorDraft, blockIndex: block.index, blockKind: block.kind, targetSeq: this.editorTargetSeq, allBlocks: false, failure: null })
|
|
978
|
+
}
|
|
979
|
+
|
|
540
980
|
close() {
|
|
541
981
|
if (this.view.pending) return
|
|
542
982
|
this.pendingRerun = false
|
|
543
983
|
this.editorElement = null
|
|
544
984
|
this.editorBox = null
|
|
545
985
|
this.editorRow = null
|
|
986
|
+
this.editorAnchor = null
|
|
987
|
+
this.editorMode = 'reply'
|
|
546
988
|
this.editorFocus = false
|
|
547
989
|
this.editor = null
|
|
548
990
|
this.editorDraft = ''
|
|
549
991
|
this.editorParts = []
|
|
550
992
|
this.confirming = false
|
|
551
993
|
this.editorElement = null
|
|
552
|
-
this.
|
|
994
|
+
this.editorBlocks = []
|
|
995
|
+
this.editorBlockIndex = null
|
|
996
|
+
this.editorBlockKind = null
|
|
997
|
+
this.editorTargetSeq = null
|
|
998
|
+
this.editorAll = false
|
|
999
|
+
this.editorDrafts = new Map()
|
|
1000
|
+
this.editorBlockKey = ''
|
|
1001
|
+
this.editorBaseHeight = null
|
|
1002
|
+
this.publish({ editing: null, draft: '', parts: [], blocks: [], blockIndex: null, blockKind: null, confirming: false, failure: null })
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** The draft of one block: what the user typed, else the recorded text. */
|
|
1006
|
+
draftOfBlock(block) {
|
|
1007
|
+
const drafts = this.editorDrafts
|
|
1008
|
+
if (drafts instanceof Map) {
|
|
1009
|
+
const key = blockDraftKey(block)
|
|
1010
|
+
const typed = drafts.get(key)
|
|
1011
|
+
if (typeof typed === 'string') return typed
|
|
1012
|
+
}
|
|
1013
|
+
return block !== null && block !== undefined && typeof block.text === 'string' ? block.text : ''
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** Remember one block's draft, wherever the user typed it. */
|
|
1017
|
+
setDraftFor(block, value) {
|
|
1018
|
+
if (!(this.editorDrafts instanceof Map)) this.editorDrafts = new Map()
|
|
1019
|
+
this.editorDrafts.set(blockDraftKey(block), String(value ?? ''))
|
|
553
1020
|
}
|
|
554
1021
|
|
|
555
1022
|
setDraft(value) {
|
|
556
1023
|
this.editorDraft = value
|
|
1024
|
+
// The 正文/思维链 page and the matching sub-page inside 全部 are the same
|
|
1025
|
+
// block: one store, so the edit survives a page switch.
|
|
1026
|
+
const blocks = Array.isArray(this.editorBlocks) ? this.editorBlocks : []
|
|
1027
|
+
const current =
|
|
1028
|
+
blocks.find((block) => blockDraftKey(block) === this.editorBlockKey) ??
|
|
1029
|
+
(typeof this.editorBlockIndex === 'number'
|
|
1030
|
+
? blocks.find((block) => block.index === this.editorBlockIndex && (this.editorBlockKind === null || block.kind === this.editorBlockKind))
|
|
1031
|
+
: undefined)
|
|
1032
|
+
if (current !== undefined) this.setDraftFor(current, value)
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
setEditorStyle(style) {
|
|
1036
|
+
if (!EDITOR_STYLES.includes(style)) return
|
|
1037
|
+
setEditorStylePreference(style)
|
|
1038
|
+
this.editorStyle = style
|
|
1039
|
+
this.publish({ editorStyle: style })
|
|
557
1040
|
}
|
|
558
1041
|
|
|
559
1042
|
/**
|
|
@@ -621,6 +1104,59 @@ window.__ModuleLoader__.load({
|
|
|
621
1104
|
}
|
|
622
1105
|
}
|
|
623
1106
|
|
|
1107
|
+
/** The all-blocks save: one request carrying every edited block. */
|
|
1108
|
+
async confirmAll(target) {
|
|
1109
|
+
const blocks = Array.isArray(target.blocks) ? target.blocks : []
|
|
1110
|
+
const targets = []
|
|
1111
|
+
for (const block of blocks) {
|
|
1112
|
+
const text = this.draftOfBlock(block)
|
|
1113
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
1114
|
+
this.publish({ failure: 'empty' })
|
|
1115
|
+
return
|
|
1116
|
+
}
|
|
1117
|
+
if (text !== block.text) {
|
|
1118
|
+
targets.push({ seq: typeof block.seq === 'number' ? block.seq : target.seq, blockIndex: block.index, kind: block.kind, text })
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (targets.length === 0) {
|
|
1122
|
+
this.publish({ failure: null })
|
|
1123
|
+
this.editor = null
|
|
1124
|
+
this.publish({ editing: null })
|
|
1125
|
+
return
|
|
1126
|
+
}
|
|
1127
|
+
this.publish({ pending: true, failure: null, failureDetail: null })
|
|
1128
|
+
try {
|
|
1129
|
+
const res = await fetch(`${ROUTE_PREFIX}/apply`, {
|
|
1130
|
+
method: 'POST',
|
|
1131
|
+
headers: { 'content-type': 'application/json' },
|
|
1132
|
+
...(typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
|
1133
|
+
? { signal: AbortSignal.timeout(20000) }
|
|
1134
|
+
: {}),
|
|
1135
|
+
body: JSON.stringify({ sessionId: this.sessionId, targets }),
|
|
1136
|
+
})
|
|
1137
|
+
const data = await res.json().catch(() => ({}))
|
|
1138
|
+
if (!res.ok || !data.ok) {
|
|
1139
|
+
const code = data && data.code ? String(data.code) : 'generic'
|
|
1140
|
+
const detail = data && typeof data.error === 'string' && data.error !== '' ? data.error : null
|
|
1141
|
+
this.publish({ pending: false, failure: code, failureDetail: detail })
|
|
1142
|
+
return
|
|
1143
|
+
}
|
|
1144
|
+
this.editor = null
|
|
1145
|
+
this.editorDraft = ''
|
|
1146
|
+
this.editorAll = false
|
|
1147
|
+
this.editorDrafts = new Map()
|
|
1148
|
+
this.editorElement = null
|
|
1149
|
+
this.editorBox = null
|
|
1150
|
+
this.editorRow = null
|
|
1151
|
+
this.editorFocus = false
|
|
1152
|
+
this.confirming = false
|
|
1153
|
+
this.publish({ pending: false, editing: null, confirming: false, failure: null })
|
|
1154
|
+
this.load()
|
|
1155
|
+
} catch {
|
|
1156
|
+
this.publish({ pending: false, failure: 'generic' })
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
624
1160
|
review() {
|
|
625
1161
|
this.pendingRerun = false
|
|
626
1162
|
this.runReview()
|
|
@@ -656,6 +1192,9 @@ window.__ModuleLoader__.load({
|
|
|
656
1192
|
if (target === null || this.view.pending) return
|
|
657
1193
|
const rerunIntent = this.pendingRerun
|
|
658
1194
|
this.pendingRerun = false
|
|
1195
|
+
if (this.editorAll === true) {
|
|
1196
|
+
return this.confirmAll(target)
|
|
1197
|
+
}
|
|
659
1198
|
const text = this.editorDraft
|
|
660
1199
|
if (text.trim() === '') {
|
|
661
1200
|
this.publish({ failure: 'empty' })
|
|
@@ -668,22 +1207,38 @@ window.__ModuleLoader__.load({
|
|
|
668
1207
|
const parts = target.mode === 'prompt' && this.view.attachmentsOk === true
|
|
669
1208
|
? this.editorParts.map((part) => (part.keep !== undefined ? { keep: part.keep } : { add: part.add }))
|
|
670
1209
|
: undefined
|
|
671
|
-
this.publish({ pending: true, failure: null })
|
|
1210
|
+
this.publish({ pending: true, failure: null, failureDetail: null })
|
|
672
1211
|
try {
|
|
673
1212
|
const res = await fetch(`${ROUTE_PREFIX}/apply`, {
|
|
674
1213
|
method: 'POST',
|
|
675
1214
|
headers: { 'content-type': 'application/json' },
|
|
1215
|
+
// A hung host must not leave the editor pending forever with a
|
|
1216
|
+
// button that looks dead.
|
|
1217
|
+
...(typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
|
1218
|
+
? { signal: AbortSignal.timeout(20000) }
|
|
1219
|
+
: {}),
|
|
676
1220
|
body: JSON.stringify({
|
|
677
1221
|
sessionId: this.sessionId,
|
|
678
1222
|
seq: target.seq,
|
|
679
1223
|
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
680
1224
|
text,
|
|
681
1225
|
...(parts === undefined ? {} : { parts }),
|
|
1226
|
+
// The in-place reply path names one block; the host validates that
|
|
1227
|
+
// it is still that kind at that index before writing the record.
|
|
1228
|
+
...(target.patch === true && typeof this.editorBlockIndex === 'number'
|
|
1229
|
+
? {
|
|
1230
|
+
blockIndex: this.editorBlockIndex,
|
|
1231
|
+
kind: this.editorBlockKind,
|
|
1232
|
+
...(this.editorTargetSeq === null ? {} : { targetSeq: this.editorTargetSeq }),
|
|
1233
|
+
}
|
|
1234
|
+
: {}),
|
|
682
1235
|
}),
|
|
683
1236
|
})
|
|
684
1237
|
const data = await res.json().catch(() => ({}))
|
|
685
1238
|
if (!res.ok || !data.ok) {
|
|
686
|
-
|
|
1239
|
+
const code = data && data.code ? String(data.code) : 'generic'
|
|
1240
|
+
const detail = data && typeof data.error === 'string' && data.error !== '' ? data.error : null
|
|
1241
|
+
this.publish({ pending: false, failure: code, failureDetail: detail })
|
|
687
1242
|
return
|
|
688
1243
|
}
|
|
689
1244
|
const shadowed = Array.isArray(data.shadowed) ? data.shadowed : []
|
|
@@ -720,6 +1275,60 @@ window.__ModuleLoader__.load({
|
|
|
720
1275
|
// replies here and re-enters through the next /state refresh (the
|
|
721
1276
|
// appended correction is itself an editable reply).
|
|
722
1277
|
const replies = new Map(this.view.replies)
|
|
1278
|
+
// The in-place path wrote one small record and touched no surface
|
|
1279
|
+
// row: the reply stays where it is, and its block list is updated
|
|
1280
|
+
// optimistically so the next render already shows the new text.
|
|
1281
|
+
let patches = this.view.patches
|
|
1282
|
+
let patchBySeq = this.view.patchBySeq
|
|
1283
|
+
if (data.mode === 'patch' && data.target && typeof data.target.blockIndex === 'number') {
|
|
1284
|
+
// The BLOCK's own seq, not the reply's. A turn's reasoning can live
|
|
1285
|
+
// in another step's message, and the host reports that seq in the
|
|
1286
|
+
// answer. Keying the optimistic patch by the reply's seq looked up
|
|
1287
|
+
// the wrong block, took the wrong `original`, and the patch then
|
|
1288
|
+
// matched nothing in the DOM - the edit appeared only after a reload,
|
|
1289
|
+
// when /state supplied it under its own seq (reported from the field
|
|
1290
|
+
// for the 思维链 page; the 正文 page looked fine because its block IS
|
|
1291
|
+
// the reply's own).
|
|
1292
|
+
const patchSeq = typeof data.target.seq === 'number' ? data.target.seq : target.seq
|
|
1293
|
+
const targetPatch = {
|
|
1294
|
+
seq: patchSeq,
|
|
1295
|
+
blockIndex: data.target.blockIndex,
|
|
1296
|
+
kind: data.target.kind === 'reasoning' ? 'reasoning' : 'text',
|
|
1297
|
+
text: String(data.target.text ?? ''),
|
|
1298
|
+
}
|
|
1299
|
+
patches = patches
|
|
1300
|
+
.filter((entry) => !(entry.seq === targetPatch.seq && entry.blockIndex === targetPatch.blockIndex))
|
|
1301
|
+
.concat([targetPatch])
|
|
1302
|
+
patchBySeq = new Map(patchBySeq)
|
|
1303
|
+
const byBlock = new Map(patchBySeq.get(targetPatch.seq) ?? [])
|
|
1304
|
+
byBlock.set(targetPatch.blockIndex, targetPatch)
|
|
1305
|
+
patchBySeq.set(targetPatch.seq, byBlock)
|
|
1306
|
+
// The entry that carries this block: the reply's own, or the one
|
|
1307
|
+
// whose block list holds another step's reasoning.
|
|
1308
|
+
let entry = replies.get(patchSeq)
|
|
1309
|
+
if (entry === undefined || !Array.isArray(entry.blocks)) {
|
|
1310
|
+
for (const candidate of replies.values()) {
|
|
1311
|
+
if (Array.isArray(candidate?.blocks) && candidate.blocks.some((block) => block.seq === patchSeq)) {
|
|
1312
|
+
entry = candidate
|
|
1313
|
+
break
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
if (entry !== undefined && Array.isArray(entry.blocks)) {
|
|
1318
|
+
const blocks = entry.blocks.map((block) =>
|
|
1319
|
+
block.index === targetPatch.blockIndex &&
|
|
1320
|
+
(block.seq === undefined || block.seq === patchSeq)
|
|
1321
|
+
? { ...block, text: targetPatch.text, patched: true }
|
|
1322
|
+
: block)
|
|
1323
|
+
const text = blocks.filter((block) => block.kind === 'text').map((block) => block.text).join('\n')
|
|
1324
|
+
replies.set(target.seq, {
|
|
1325
|
+
...entry,
|
|
1326
|
+
blocks,
|
|
1327
|
+
text,
|
|
1328
|
+
patched: blocks.some((block) => block.patched === true),
|
|
1329
|
+
})
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
723
1332
|
for (const entry of replies.values()) {
|
|
724
1333
|
if (hidden.has(entry.seq)) replies.delete(entry.seq)
|
|
725
1334
|
}
|
|
@@ -740,6 +1349,11 @@ window.__ModuleLoader__.load({
|
|
|
740
1349
|
revisions,
|
|
741
1350
|
editable,
|
|
742
1351
|
replies,
|
|
1352
|
+
patches,
|
|
1353
|
+
patchBySeq,
|
|
1354
|
+
blocks: [],
|
|
1355
|
+
blockIndex: null,
|
|
1356
|
+
blockKind: null,
|
|
743
1357
|
parts: [],
|
|
744
1358
|
failure: null,
|
|
745
1359
|
})
|
|
@@ -773,6 +1387,11 @@ window.__ModuleLoader__.load({
|
|
|
773
1387
|
// the only place the user can still be told (see applyEdit's
|
|
774
1388
|
// `dropped`).
|
|
775
1389
|
if (data.kind !== 'reply' && data.applied === true && data.dropped === true) this.notify('attachments-dropped')
|
|
1390
|
+
// The correction landed, but replaying the turns behind it did not:
|
|
1391
|
+
// the editor is already gone, so this is the only place left to say
|
|
1392
|
+
// that the tail is missing from the context instead of silently
|
|
1393
|
+
// hiding rows the user still expects.
|
|
1394
|
+
if (data.applied === true && data.replay && data.replay.error) this.notify('replay-partial')
|
|
776
1395
|
}
|
|
777
1396
|
// Save first, re-run second - always: the sibling re-runs the turn
|
|
778
1397
|
// from the prompt the surface NOW shows, so the revised wording has
|
|
@@ -1044,9 +1663,9 @@ window.__ModuleLoader__.load({
|
|
|
1044
1663
|
* at all and reads exactly like a dead pencil.
|
|
1045
1664
|
*/
|
|
1046
1665
|
function wireAction(button) {
|
|
1047
|
-
pressable(button, () => {
|
|
1666
|
+
pressable(button, (event) => {
|
|
1048
1667
|
const activate = button[ACTIVATE]
|
|
1049
|
-
if (typeof activate === 'function') activate()
|
|
1668
|
+
if (typeof activate === 'function') activate(event)
|
|
1050
1669
|
})
|
|
1051
1670
|
}
|
|
1052
1671
|
|
|
@@ -1108,7 +1727,8 @@ window.__ModuleLoader__.load({
|
|
|
1108
1727
|
}
|
|
1109
1728
|
const entry = view.editable.get(headRevision(view.revisions.get(revisionSeq), view.revisions))
|
|
1110
1729
|
const text = entry && typeof entry.text === 'string' ? entry.text : ''
|
|
1111
|
-
|
|
1730
|
+
const extraBlocks = entry && Array.isArray(entry.blocks) ? entry.blocks.filter((block) => block.type !== 'text') : []
|
|
1731
|
+
if (text === '' && extraBlocks.length === 0) {
|
|
1112
1732
|
if (existing !== null) existing.remove()
|
|
1113
1733
|
releaseRowCopy(row)
|
|
1114
1734
|
return node.kind !== 'user'
|
|
@@ -1121,7 +1741,30 @@ window.__ModuleLoader__.load({
|
|
|
1121
1741
|
}
|
|
1122
1742
|
}
|
|
1123
1743
|
placeRevisionBubble(row, bubble)
|
|
1124
|
-
|
|
1744
|
+
// A rewritten prompt may still carry images/files. The carrier keeps
|
|
1745
|
+
// those blocks, the model reads them, and the editor lists them - the
|
|
1746
|
+
// standing-in bubble must show them too, or an edited image message looks
|
|
1747
|
+
// like the images were deleted.
|
|
1748
|
+
const signature = text + '|' + extraBlocks.map((block) =>
|
|
1749
|
+
`${String(block.index)}:${String(block.type)}:${String(block.name ?? '')}:${String(block.bytes ?? '')}`).join(',')
|
|
1750
|
+
// The signature carries whether the markdown path was used: installing or
|
|
1751
|
+
// removing the markdown bubble plugin must redraw this bubble too.
|
|
1752
|
+
const markdownSignature = signature + '|md:' + (markdownBubbleInstalled() ? '1' : '0')
|
|
1753
|
+
if (bubble.dataset.dshetRevisionContent !== markdownSignature) {
|
|
1754
|
+
const previous = revisionRoots.get(bubble)
|
|
1755
|
+
if (previous !== undefined && typeof previous.unmount === 'function') previous.unmount()
|
|
1756
|
+
revisionRoots.delete(bubble)
|
|
1757
|
+
bubble.textContent = ''
|
|
1758
|
+
if (!renderRevisionText(bubble, text)) {
|
|
1759
|
+
const textNode = document.createElement('span')
|
|
1760
|
+
textNode.className = 'dshet-revision-text'
|
|
1761
|
+
textNode.textContent = text
|
|
1762
|
+
bubble.appendChild(textNode)
|
|
1763
|
+
}
|
|
1764
|
+
const files = revisionAttachments(entry, controller)
|
|
1765
|
+
if (files !== null) bubble.appendChild(files)
|
|
1766
|
+
bubble.dataset.dshetRevisionContent = markdownSignature
|
|
1767
|
+
}
|
|
1125
1768
|
// The edit entry goes where every other action of this row lives: the
|
|
1126
1769
|
// platform's own bar, which the collapse keeps standing, at its right end
|
|
1127
1770
|
// behind the clock and the copy button - the row the user reads as "this
|
|
@@ -1154,6 +1797,40 @@ window.__ModuleLoader__.load({
|
|
|
1154
1797
|
return true
|
|
1155
1798
|
}
|
|
1156
1799
|
|
|
1800
|
+
/**
|
|
1801
|
+
* The non-text blocks of a rewritten prompt, drawn under its text.
|
|
1802
|
+
*
|
|
1803
|
+
* These are the same blocks the editor lists as chips; the bytes come from
|
|
1804
|
+
* this plugin's attachment route, so a thumbnail works without a host API.
|
|
1805
|
+
*/
|
|
1806
|
+
function revisionAttachments(entry, controller) {
|
|
1807
|
+
if (!entry || !Array.isArray(entry.blocks)) return null
|
|
1808
|
+
const blocks = entry.blocks.filter((block) => block.type !== 'text')
|
|
1809
|
+
if (blocks.length === 0) return null
|
|
1810
|
+
const strip = document.createElement('div')
|
|
1811
|
+
strip.className = 'dshet-revision-files'
|
|
1812
|
+
for (const block of blocks) {
|
|
1813
|
+
const item = document.createElement('div')
|
|
1814
|
+
item.className = 'dshet-revision-file'
|
|
1815
|
+
if (block.preview === true && typeof block.index === 'number') {
|
|
1816
|
+
const image = document.createElement('img')
|
|
1817
|
+
image.className = 'dshet-thumb'
|
|
1818
|
+
image.alt = ''
|
|
1819
|
+
image.src = previewUrl(controller.sessionId, entry.seq, block.index)
|
|
1820
|
+
image.addEventListener('error', () => {
|
|
1821
|
+
if (typeof image.remove === 'function') image.remove()
|
|
1822
|
+
})
|
|
1823
|
+
item.appendChild(image)
|
|
1824
|
+
}
|
|
1825
|
+
const label = document.createElement('span')
|
|
1826
|
+
label.className = 'dshet-chip-label'
|
|
1827
|
+
label.textContent = typeof block.name === 'string' && block.name !== '' ? block.name : block.type
|
|
1828
|
+
item.appendChild(label)
|
|
1829
|
+
strip.appendChild(item)
|
|
1830
|
+
}
|
|
1831
|
+
return strip
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1157
1834
|
// A copy of a live user bubble element, classes and all.
|
|
1158
1835
|
// The key of the row a bubble stands in for. It is what makes a stray
|
|
1159
1836
|
// bubble recognisable after the host's DOM has shifted around it: an
|
|
@@ -1489,9 +2166,39 @@ window.__ModuleLoader__.load({
|
|
|
1489
2166
|
// `_actions` suffix is stable) and hides that strip's ancestors' other
|
|
1490
2167
|
// children up to the row. Returns false when there is no strip to keep, and
|
|
1491
2168
|
// false is what tells the caller to hide the whole row instead.
|
|
2169
|
+
/**
|
|
2170
|
+
* The host's own message markup inside a row - the stack / bubble / markdown
|
|
2171
|
+
* the transcript draws the text in. That subtree is the ONLY thing the
|
|
2172
|
+
* collapse walk may hide: a sibling that does not contain it belongs to
|
|
2173
|
+
* somebody else (another plugin's injected button, a portal), and hiding it
|
|
2174
|
+
* is issue #1 - `dsh-rewind-pro`'s button stayed in the DOM, stayed
|
|
2175
|
+
* clickable, and was `display:none` on screen with no console error to
|
|
2176
|
+
* notice. Null when the host shape is not one we recognise; the walk then
|
|
2177
|
+
* keeps its old "everything but ours" behaviour instead of leaving a
|
|
2178
|
+
* rewritten prompt on screen.
|
|
2179
|
+
*/
|
|
2180
|
+
function messageContentRoot(row) {
|
|
2181
|
+
const selectors = ['[class*="_bubble"]', '[class*="userStack"]', '[class*="_markdown"]', '[class*="_stack"]', '[class*="messageBody"]']
|
|
2182
|
+
for (const selector of selectors) {
|
|
2183
|
+
let found = null
|
|
2184
|
+
try {
|
|
2185
|
+
found = row.querySelector(selector)
|
|
2186
|
+
} catch {
|
|
2187
|
+
found = null
|
|
2188
|
+
}
|
|
2189
|
+
if (found === null || found === undefined) continue
|
|
2190
|
+
if (typeof found.closest === 'function' && found.closest('.dshet-editor') !== null) continue
|
|
2191
|
+
return found
|
|
2192
|
+
}
|
|
2193
|
+
return null
|
|
2194
|
+
}
|
|
2195
|
+
|
|
1492
2196
|
function collapseRowContent(row, hide) {
|
|
1493
2197
|
const strip = row.querySelector('[class*="_actions"]')
|
|
1494
2198
|
if (strip === null) return false
|
|
2199
|
+
// Only consulted while hiding: the restore pass goes by the marker this
|
|
2200
|
+
// plugin wrote, so a node an older build had hidden is still restored.
|
|
2201
|
+
const contentRoot = hide ? messageContentRoot(row) : null
|
|
1495
2202
|
let node = strip
|
|
1496
2203
|
while (node !== null && node !== row) {
|
|
1497
2204
|
const parent = node.parentElement
|
|
@@ -1511,6 +2218,16 @@ window.__ModuleLoader__.load({
|
|
|
1511
2218
|
) {
|
|
1512
2219
|
continue
|
|
1513
2220
|
}
|
|
2221
|
+
// I5 / issue #1: another plugin's node beside the message is not ours
|
|
2222
|
+
// to hide. Only a sibling that holds the message content is.
|
|
2223
|
+
if (
|
|
2224
|
+
hide &&
|
|
2225
|
+
contentRoot !== null &&
|
|
2226
|
+
typeof child.contains === 'function' &&
|
|
2227
|
+
child.contains(contentRoot) !== true
|
|
2228
|
+
) {
|
|
2229
|
+
continue
|
|
2230
|
+
}
|
|
1514
2231
|
if (hide) {
|
|
1515
2232
|
// Written only when the value changes: this runs on every DOM pass,
|
|
1516
2233
|
// and an attribute write the observer can see would never settle.
|
|
@@ -1662,7 +2379,14 @@ window.__ModuleLoader__.load({
|
|
|
1662
2379
|
} else if (button.dataset !== undefined && button.dataset.dshetFallback !== undefined) {
|
|
1663
2380
|
delete button.dataset.dshetFallback
|
|
1664
2381
|
}
|
|
1665
|
-
button[ACTIVATE] = () => controller.open(
|
|
2382
|
+
button[ACTIVATE] = (event) => controller.open({
|
|
2383
|
+
...entry.target,
|
|
2384
|
+
// The editor belongs beside the pencil the user just pressed, not at the
|
|
2385
|
+
// top of a row that may be taller than the screen.
|
|
2386
|
+
anchor: event !== undefined && event !== null && event.currentTarget !== undefined
|
|
2387
|
+
? event.currentTarget
|
|
2388
|
+
: button,
|
|
2389
|
+
})
|
|
1666
2390
|
liveHosts.add(host)
|
|
1667
2391
|
if (button.getAttribute('aria-label') !== label) {
|
|
1668
2392
|
button.setAttribute('aria-label', label)
|
|
@@ -1792,13 +2516,606 @@ window.__ModuleLoader__.load({
|
|
|
1792
2516
|
// Put the box under the row it belongs to: same width, just below its lower
|
|
1793
2517
|
// edge. Called on render and whenever the page scrolls or resizes, since
|
|
1794
2518
|
// the layer is fixed to the viewport.
|
|
1795
|
-
|
|
2519
|
+
// Some host skins mark their editor chrome `!important`; only an inline
|
|
2520
|
+
// `!important` outranks that. The stub DOM has no `setProperty`, so it falls
|
|
2521
|
+
// back to a plain assignment there.
|
|
2522
|
+
function forceStyle(element, property, value) {
|
|
2523
|
+
if (element === null || element === undefined || element.style === undefined) return
|
|
2524
|
+
if (typeof element.style.setProperty === 'function') {
|
|
2525
|
+
element.style.setProperty(property, value, 'important')
|
|
2526
|
+
return
|
|
2527
|
+
}
|
|
2528
|
+
element.style[property] = value
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
/**
|
|
2532
|
+
* The surface an in-place editor paints itself with, so the recorded text
|
|
2533
|
+
* underneath cannot show through the draft.
|
|
2534
|
+
*
|
|
2535
|
+
* The skin's own panel colour comes first. A skin that paints the app on
|
|
2536
|
+
* artwork usually leaves `body` transparent (the artwork is a layer behind
|
|
2537
|
+
* it), and the white fallback then put a white box on a night sky. The
|
|
2538
|
+
* palette keeps the skin's hue and drops its transparency, so what comes back
|
|
2539
|
+
* here is opaque by construction.
|
|
2540
|
+
*
|
|
2541
|
+
* @returns a CSS colour for the editor's own background.
|
|
2542
|
+
*/
|
|
2543
|
+
function pageBackground() {
|
|
2544
|
+
try {
|
|
2545
|
+
if (typeof getComputedStyle === 'function' && typeof document !== 'undefined' && document.body) {
|
|
2546
|
+
const panel = String(getComputedStyle(document.body).getPropertyValue('--dshet-panel') || '').trim()
|
|
2547
|
+
const match = /rgba?\(([^)]+)\)/.exec(panel)
|
|
2548
|
+
if (match !== null) {
|
|
2549
|
+
const parts = match[1].split(',').map((value) => Number.parseFloat(value))
|
|
2550
|
+
const opaque = parts.length >= 3 && parts.every((value) => Number.isFinite(value)) && (parts.length < 4 || parts[3] >= 0.9)
|
|
2551
|
+
if (opaque) return panel
|
|
2552
|
+
}
|
|
2553
|
+
const value = getComputedStyle(document.body).backgroundColor
|
|
2554
|
+
if (value && value !== 'transparent' && !/rgba?\(0, 0, 0, 0\)/.test(value)) return value
|
|
2555
|
+
}
|
|
2556
|
+
} catch {
|
|
2557
|
+
/* fall through */
|
|
2558
|
+
}
|
|
2559
|
+
return '#ffffff'
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
function viewportSize() {
|
|
2563
|
+
const win = typeof window === 'undefined' ? undefined : window
|
|
2564
|
+
const doc = typeof document === 'undefined' ? undefined : document
|
|
2565
|
+
const width = (win !== undefined && win.innerWidth) ||
|
|
2566
|
+
(doc !== undefined && doc.documentElement && doc.documentElement.clientWidth) || 0
|
|
2567
|
+
const height = (win !== undefined && win.innerHeight) ||
|
|
2568
|
+
(doc !== undefined && doc.documentElement && doc.documentElement.clientHeight) || 0
|
|
2569
|
+
return { width, height }
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
// Put the box under the row it belongs to: same width, just below its lower
|
|
2573
|
+
// edge. The layer is fixed to the viewport, so a row near the bottom (or one
|
|
2574
|
+
// taller than the screen) used to push the footer - and its save button -
|
|
2575
|
+
// past the bottom edge where nothing could reach it. Flip above the row when
|
|
2576
|
+
// there is room, otherwise pin the box to the bottom of the viewport and let
|
|
2577
|
+
// its own scrollbar carry the content; the footer stays sticky either way.
|
|
2578
|
+
/**
|
|
2579
|
+
* The bubble the editor stands in for.
|
|
2580
|
+
*
|
|
2581
|
+
* A prompt is a right-aligned input bubble; a reply is the output content
|
|
2582
|
+
* block. The editor takes that element's width and left edge and overlays its
|
|
2583
|
+
* position, so it reads as the message being edited instead of a floating
|
|
2584
|
+
* panel. Class hashes change between builds, so the suffixes are matched.
|
|
2585
|
+
*/
|
|
2586
|
+
function bubbleFor(row, mode, blockKind = null) {
|
|
2587
|
+
let selectors
|
|
2588
|
+
if (mode === 'reply' && blockKind === 'reasoning') {
|
|
2589
|
+
selectors = ['details', '[class*="_reasoning"]', '[class*="_thinking"]', '[class*="reasoning"]']
|
|
2590
|
+
} else if (mode === 'reply') {
|
|
2591
|
+
selectors = ['[class*="_markdown"]', '[class*="_bubble"]', '[class*="messageBody"]']
|
|
2592
|
+
} else {
|
|
2593
|
+
selectors = ['[class*="_bubble"]', '[class*="userStack"]', '[class*="_markdown"]']
|
|
2594
|
+
}
|
|
2595
|
+
for (const selector of selectors) {
|
|
2596
|
+
let nodes = []
|
|
2597
|
+
try {
|
|
2598
|
+
nodes = [...row.querySelectorAll(selector)]
|
|
2599
|
+
} catch {
|
|
2600
|
+
nodes = []
|
|
2601
|
+
}
|
|
2602
|
+
let best = null
|
|
2603
|
+
for (const element of nodes) {
|
|
2604
|
+
if (typeof element.closest === 'function' && element.closest('.dshet-editor') !== null) continue
|
|
2605
|
+
const rect = element.getBoundingClientRect()
|
|
2606
|
+
if (rect.width < 80 || rect.height <= 0) continue
|
|
2607
|
+
if (best === null || rect.width * rect.height > best.rect.width * best.rect.height) best = { element, rect }
|
|
2608
|
+
}
|
|
2609
|
+
if (best !== null) return best
|
|
2610
|
+
}
|
|
2611
|
+
const rect = row.getBoundingClientRect()
|
|
2612
|
+
return { element: row, rect }
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
/**
|
|
2616
|
+
* Which row an editor should attach to when several cover the edited seq.
|
|
2617
|
+
*
|
|
2618
|
+
* A reply's seq appears in both the assistant-step row and the turn-tail
|
|
2619
|
+
* row that carries its action strip; the tail also covers the reply, so a
|
|
2620
|
+
* last-write-wins loop handed the editor the tail's little action bar and
|
|
2621
|
+
* the box came out message-sized instead of bubble-sized. The message row
|
|
2622
|
+
* always wins.
|
|
2623
|
+
*/
|
|
2624
|
+
function editorRowRank(row, mode) {
|
|
2625
|
+
const kind = row.getAttribute('data-chat-flow-kind')
|
|
2626
|
+
if (mode === 'prompt') {
|
|
2627
|
+
if (kind === 'user') return 4
|
|
2628
|
+
if (kind === 'revision' || kind === 'user-revision') return 3
|
|
2629
|
+
if (kind === 'turn-tail' || kind === 'turn-process') return 1
|
|
2630
|
+
return 2
|
|
2631
|
+
}
|
|
2632
|
+
if (kind === 'assistant-step') return 4
|
|
2633
|
+
if (kind === 'turn-tail' || kind === 'turn-process') return 1
|
|
2634
|
+
return 2
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
/**
|
|
2638
|
+
* The vertical band the conversation owns: below its top, above the composer.
|
|
2639
|
+
*
|
|
2640
|
+
* This runs on every scroll frame, so the lookups are cached briefly: the
|
|
2641
|
+
* composer element and the conversation top do not move between frames, and
|
|
2642
|
+
* a global `[class*=...]` scan per frame is what made scrolling feel heavy.
|
|
2643
|
+
*/
|
|
2644
|
+
let conversationBoundsCache = null
|
|
2645
|
+
let conversationBoundsAt = 0
|
|
2646
|
+
let composerElementCache = null
|
|
2647
|
+
function conversationBounds(row = null) {
|
|
2648
|
+
const now = Date.now()
|
|
2649
|
+
if (conversationBoundsCache !== null && now - conversationBoundsAt < 250) return conversationBoundsCache
|
|
2650
|
+
const viewportHeight = typeof window !== 'undefined' && typeof window.innerHeight === 'number' ? window.innerHeight : 0
|
|
2651
|
+
const bounds = { top: 0, bottom: viewportHeight, composerLeft: 0, composerWidth: 0 }
|
|
2652
|
+
try {
|
|
2653
|
+
const conversation = document.querySelector('[data-slot="main.conversation"]')
|
|
2654
|
+
if (conversation !== null && typeof conversation.getBoundingClientRect === 'function') {
|
|
2655
|
+
bounds.top = conversation.getBoundingClientRect().top
|
|
2656
|
+
}
|
|
2657
|
+
} catch {}
|
|
2658
|
+
// The real scroll area is the nearest scrollable ancestor of the edited
|
|
2659
|
+
// row; the slot wrapper can start above the header, which let the box
|
|
2660
|
+
// cover the tab bar.
|
|
2661
|
+
try {
|
|
2662
|
+
let node = row !== null && row !== undefined ? row.parentElement : null
|
|
2663
|
+
while (node !== null && node !== undefined && node !== document.body) {
|
|
2664
|
+
const style = getComputedStyle(node)
|
|
2665
|
+
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && node.getBoundingClientRect().height > 120) {
|
|
2666
|
+
bounds.top = Math.max(bounds.top, node.getBoundingClientRect().top)
|
|
2667
|
+
break
|
|
2668
|
+
}
|
|
2669
|
+
node = node.parentElement
|
|
2670
|
+
}
|
|
2671
|
+
} catch {}
|
|
2672
|
+
// The composer stack has several wrappers with "composer" in the class;
|
|
2673
|
+
// the boundary is the LOWEST one that still sits above the viewport edge,
|
|
2674
|
+
// otherwise the box runs under the input box.
|
|
2675
|
+
try {
|
|
2676
|
+
// The composer CARD is the rounded surface that owns the input, not the
|
|
2677
|
+
// outer seat (which is wider and made the editor overhang). Walk up from
|
|
2678
|
+
// the input element to the first rounded, painted ancestor.
|
|
2679
|
+
let card = document.querySelector('textarea[placeholder], [contenteditable="true"]')
|
|
2680
|
+
while (card !== null && card !== undefined && card !== document.body) {
|
|
2681
|
+
const style = getComputedStyle(card)
|
|
2682
|
+
const radius = Number.parseFloat(style.borderTopLeftRadius)
|
|
2683
|
+
if (radius > 6 && style.backgroundColor !== 'rgba(0, 0, 0, 0)') break
|
|
2684
|
+
card = card.parentElement
|
|
2685
|
+
}
|
|
2686
|
+
let lowest = 0
|
|
2687
|
+
let lowestRect = null
|
|
2688
|
+
if (card !== null && card !== undefined && card !== document.body && typeof card.getBoundingClientRect === 'function') {
|
|
2689
|
+
const rect = card.getBoundingClientRect()
|
|
2690
|
+
if (rect.top > 0 && (viewportHeight === 0 || rect.top < viewportHeight)) {
|
|
2691
|
+
lowest = rect.top
|
|
2692
|
+
lowestRect = rect
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
if (lowestRect === null) {
|
|
2696
|
+
const candidates = document.querySelectorAll('[data-slot="conversation.input"], [class*="composerSeat"], [class*="composer"]')
|
|
2697
|
+
for (const candidate of candidates) {
|
|
2698
|
+
if (typeof candidate.getBoundingClientRect !== 'function') continue
|
|
2699
|
+
const rect = candidate.getBoundingClientRect()
|
|
2700
|
+
if (rect.top > lowest && (viewportHeight === 0 || rect.top < viewportHeight - 40)) {
|
|
2701
|
+
lowest = rect.top
|
|
2702
|
+
lowestRect = rect
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
bounds.bottom = lowest > 0 ? lowest : Math.min(viewportHeight, viewportHeight - 120)
|
|
2707
|
+
if (lowestRect !== null) {
|
|
2708
|
+
bounds.composerLeft = lowestRect.left
|
|
2709
|
+
bounds.composerWidth = lowestRect.width
|
|
2710
|
+
}
|
|
2711
|
+
} catch {
|
|
2712
|
+
bounds.bottom = viewportHeight > 0 ? viewportHeight - 120 : viewportHeight
|
|
2713
|
+
}
|
|
2714
|
+
conversationBoundsCache = bounds
|
|
2715
|
+
conversationBoundsAt = now
|
|
2716
|
+
return bounds
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
function placeEditor(row, box, mode = 'reply', style = 'frame', blockKind = null, blocks = null, controller = null) {
|
|
1796
2720
|
const layer = ensureEditorLayer()
|
|
1797
2721
|
if (box.parentElement !== layer) layer.appendChild(box)
|
|
1798
|
-
const
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
2722
|
+
const placement = readStoredEditorPlacement() ?? 'overlay'
|
|
2723
|
+
const legacy = placement === 'legacy'
|
|
2724
|
+
const inline = placement === 'inline'
|
|
2725
|
+
// Every fixed mode is clamped to the conversation band by default; the
|
|
2726
|
+
// in-page mode keeps its page coordinates and page scroll.
|
|
2727
|
+
const clamped = !inline
|
|
2728
|
+
if (legacy) style = 'frame'
|
|
2729
|
+
if (inline) layer.dataset.dshetInline = '1'
|
|
2730
|
+
else delete layer.dataset.dshetInline
|
|
2731
|
+
const rowRect = row.getBoundingClientRect()
|
|
2732
|
+
const bubble = bubbleFor(row, mode, blockKind)
|
|
2733
|
+
const storedStyle = readStoredEditorStyle()
|
|
2734
|
+
if (storedStyle !== null) style = storedStyle
|
|
2735
|
+
const chipsInBox = typeof box.querySelector === 'function' ? box.querySelector('.dshet-chips') : null
|
|
2736
|
+
const minimumHeight = chipsInBox !== null && chipsInBox !== undefined ? 190 : 150
|
|
2737
|
+
const viewport = viewportSize()
|
|
2738
|
+
const margin = 8
|
|
2739
|
+
const area = typeof box.querySelector === 'function' ? box.querySelector('textarea') : null
|
|
2740
|
+
const frame = typeof box.querySelector === 'function' ? box.querySelector('.dshet-frame') : null
|
|
2741
|
+
const allBox = typeof box.querySelector === 'function' ? box.querySelector('.dshet-all') : null
|
|
2742
|
+
const heightMode = readStoredEditorHeight() ?? 'fill'
|
|
2743
|
+
if (frame !== null && frame !== undefined) frame.style.display = style === 'frame' ? '' : 'none'
|
|
2744
|
+
box.style.boxShadow = 'none'
|
|
2745
|
+
const footerEl = typeof box.querySelector === 'function' ? box.querySelector('.dshet-footer') : null
|
|
2746
|
+
if (footerEl !== null && footerEl !== undefined && !(style === 'center' && !inline)) {
|
|
2747
|
+
footerEl.style.position = ''
|
|
2748
|
+
footerEl.style.height = ''
|
|
2749
|
+
footerEl.style.marginTop = ''
|
|
2750
|
+
footerEl.style.borderRadius = ''
|
|
2751
|
+
}
|
|
2752
|
+
if (style === 'center' && !inline) {
|
|
2753
|
+
// A large centred panel, the first shape that shipped. The textarea
|
|
2754
|
+
// grows with its content; the original message stays where it is.
|
|
2755
|
+
delete box.dataset.dshetInplace
|
|
2756
|
+
if (area !== null && area !== undefined) {
|
|
2757
|
+
area.style.position = ''
|
|
2758
|
+
area.style.left = ''
|
|
2759
|
+
area.style.top = ''
|
|
2760
|
+
area.style.width = ''
|
|
2761
|
+
area.style.height = ''
|
|
2762
|
+
area.style.maxHeight = ''
|
|
2763
|
+
area.style.background = ''
|
|
2764
|
+
area.style.paddingBottom = '16px'
|
|
2765
|
+
fitEditorHeight(area)
|
|
2766
|
+
const centerFooter = typeof box.querySelector === 'function' ? box.querySelector('.dshet-footer') : null
|
|
2767
|
+
if (centerFooter !== null && centerFooter !== undefined) {
|
|
2768
|
+
centerFooter.style.position = 'static'
|
|
2769
|
+
centerFooter.style.height = '46px'
|
|
2770
|
+
centerFooter.style.marginTop = '0'
|
|
2771
|
+
centerFooter.style.borderRadius = '0 0 16px 16px'
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
const width = viewport.width > 0 ? Math.max(320, Math.min(1400, viewport.width - margin * 2)) : Math.max(320, rowRect.width)
|
|
2775
|
+
box.style.boxSizing = 'border-box'
|
|
2776
|
+
box.style.width = `${Math.round(width)}px`
|
|
2777
|
+
box.style.left = `${Math.round(viewport.width > 0 ? (viewport.width - width) / 2 : rowRect.left)}px`
|
|
2778
|
+
const bounds = conversationBounds(row)
|
|
2779
|
+
const topLimit = bounds.top + margin
|
|
2780
|
+
const bottomLimit = (bounds.bottom > 0 ? bounds.bottom : viewport.height) - margin
|
|
2781
|
+
const usable = bottomLimit > topLimit ? bottomLimit - topLimit : 0
|
|
2782
|
+
box.style.height = ''
|
|
2783
|
+
box.style.maxHeight = usable > 0
|
|
2784
|
+
? `${Math.max(180, usable)}px`
|
|
2785
|
+
: viewport.height > 0
|
|
2786
|
+
? `${Math.max(180, viewport.height - margin * 2)}px`
|
|
2787
|
+
: ''
|
|
2788
|
+
const measured = box.getBoundingClientRect()
|
|
2789
|
+
const height = (measured && measured.height) || box.offsetHeight || 0
|
|
2790
|
+
const top = usable > 0 && height > 0
|
|
2791
|
+
? Math.round(Math.max(topLimit, Math.min(topLimit + (usable - height) / 2, bottomLimit - height)))
|
|
2792
|
+
: viewport.height > 0 && height > 0
|
|
2793
|
+
? Math.round(Math.max(margin, Math.min((viewport.height - height) / 2, viewport.height - height - margin)))
|
|
2794
|
+
: Math.round((rowRect.bottom || 0) + 6)
|
|
2795
|
+
box.style.top = `${top}px`
|
|
2796
|
+
return
|
|
2797
|
+
}
|
|
2798
|
+
// `bubble` and `frame` both paint over the message, so the textarea gets
|
|
2799
|
+
// an opaque surface: the recorded text underneath must not show through
|
|
2800
|
+
// the draft. `frame` outlines the whole row; `bubble` outlines just the
|
|
2801
|
+
// message block.
|
|
2802
|
+
box.dataset.dshetInplace = '1'
|
|
2803
|
+
// A full opaque backdrop: the frame covers the whole row, so the host's
|
|
2804
|
+
// status line and action bar underneath must not show through either.
|
|
2805
|
+
const opaque = pageBackground()
|
|
2806
|
+
box.style.background = opaque
|
|
2807
|
+
if (area !== null && area !== undefined) {
|
|
2808
|
+
area.style.background = opaque
|
|
2809
|
+
}
|
|
2810
|
+
// A reply edit is scoped to the selected block (answer text or reasoning),
|
|
2811
|
+
// not to the whole assistant row: the other blocks stay out of the frame.
|
|
2812
|
+
const base = legacy ? rowRect : mode === 'reply' ? bubble.rect : style === 'frame' ? rowRect : bubble.rect
|
|
2813
|
+
let width = base.width
|
|
2814
|
+
let alignedLeft = null
|
|
2815
|
+
const fillAllView = allBox !== null && allBox !== undefined && heightMode !== 'content'
|
|
2816
|
+
if (fillAllView) {
|
|
2817
|
+
// The all-blocks page lines up with the composer card: same left edge,
|
|
2818
|
+
// same width, so the two boxes read as one column.
|
|
2819
|
+
const aligned = conversationBounds(row)
|
|
2820
|
+
if (aligned.composerWidth > 0) {
|
|
2821
|
+
width = aligned.composerWidth
|
|
2822
|
+
alignedLeft = aligned.composerLeft
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
if (!fillAllView && mode === 'reply' && typeof rowRect.width === 'number' && rowRect.width > width) {
|
|
2826
|
+
// A reply block is narrower than the conversation row; the editor gets
|
|
2827
|
+
// the full row width so the text is not squeezed into the bubble.
|
|
2828
|
+
width = rowRect.width
|
|
2829
|
+
}
|
|
2830
|
+
let left = alignedLeft === null ? base.left : alignedLeft
|
|
2831
|
+
if (inline) {
|
|
2832
|
+
const pageX = typeof window !== 'undefined' && typeof window.scrollX === 'number' ? window.scrollX : 0
|
|
2833
|
+
left += pageX
|
|
2834
|
+
if (viewport.width > 0) width = Math.min(width, viewport.width - margin * 2)
|
|
2835
|
+
} else if (viewport.width > 0) {
|
|
2836
|
+
width = Math.min(width, viewport.width - margin * 2)
|
|
2837
|
+
left = Math.max(margin, Math.min(left, viewport.width - width - margin))
|
|
2838
|
+
}
|
|
2839
|
+
box.style.boxSizing = 'border-box'
|
|
2840
|
+
box.style.width = `${Math.round(width)}px`
|
|
2841
|
+
box.style.left = `${Math.round(left)}px`
|
|
2842
|
+
// The host's action strip sits under the reported bubble. Extend the
|
|
2843
|
+
// editor down to the row's bottom so that strip is covered, and the
|
|
2844
|
+
// footer controls end up where those host buttons used to be.
|
|
2845
|
+
const baseBottom = typeof base.bottom === 'number' ? base.bottom : base.top + base.height
|
|
2846
|
+
const rowBottom = typeof rowRect.bottom === 'number' ? rowRect.bottom : rowRect.top + rowRect.height
|
|
2847
|
+
// The action strip can be a sibling of the row (the turn-tail row), not
|
|
2848
|
+
// its child. Find the nearest one from our own injected action buttons
|
|
2849
|
+
// and include its bottom in the cover.
|
|
2850
|
+
let actionBottom = 0
|
|
2851
|
+
if (legacy) try {
|
|
2852
|
+
const actions = document.querySelectorAll('.dshet-action')
|
|
2853
|
+
for (const action of actions) {
|
|
2854
|
+
const bar = action.parentElement
|
|
2855
|
+
if (bar === null || bar === undefined || typeof bar.getBoundingClientRect !== 'function') continue
|
|
2856
|
+
const rect = bar.getBoundingClientRect()
|
|
2857
|
+
if (rect.top >= rowRect.top - 8 && rect.top <= rowRect.bottom + 160) {
|
|
2858
|
+
actionBottom = Math.max(actionBottom, rect.bottom)
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
} catch {}
|
|
2862
|
+
// The action strip is rendered as its own turn-tail row under the
|
|
2863
|
+
// message row. Use that row's bottom; if the host renders it in a shape
|
|
2864
|
+
// we cannot identify, reserve one action-band height so the buttons can
|
|
2865
|
+
// never peek out underneath the opaque editor.
|
|
2866
|
+
let tailBottom = 0
|
|
2867
|
+
if (legacy) try {
|
|
2868
|
+
const tails = document.querySelectorAll('[data-chat-flow-kind="turn-tail"]')
|
|
2869
|
+
for (const tail of tails) {
|
|
2870
|
+
if (typeof tail.getBoundingClientRect !== 'function') continue
|
|
2871
|
+
const rect = tail.getBoundingClientRect()
|
|
2872
|
+
if (rect.top >= rowRect.top - 8 && rect.top <= rowRect.bottom + 200) {
|
|
2873
|
+
tailBottom = Math.max(tailBottom, rect.bottom)
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
} catch {}
|
|
2877
|
+
const coverBottom = Math.max(rowBottom, actionBottom, tailBottom)
|
|
2878
|
+
const coveredHeight = Math.max(base.height, coverBottom - base.top + 48)
|
|
2879
|
+
// Non-legacy modes hug the content: the box ends right after the last
|
|
2880
|
+
// line plus the control band, instead of stretching to the row bottom.
|
|
2881
|
+
const FOOTER_BAND = 46
|
|
2882
|
+
const boxRect0 = typeof box.getBoundingClientRect === 'function' ? box.getBoundingClientRect() : { top: base.top }
|
|
2883
|
+
const chipStrip0 = typeof box.querySelector === 'function' ? box.querySelector('.dshet-chips') : null
|
|
2884
|
+
const chipBottom0 = chipStrip0 === null || chipStrip0 === undefined || typeof chipStrip0.getBoundingClientRect !== 'function'
|
|
2885
|
+
? 0
|
|
2886
|
+
: Math.max(0, chipStrip0.getBoundingClientRect().bottom - boxRect0.top + 8)
|
|
2887
|
+
const headerOffset = Math.max(38, chipBottom0, bubble.rect.top - base.top)
|
|
2888
|
+
let contentHeight = Math.round(base.height)
|
|
2889
|
+
if (area !== null && area !== undefined && (allBox === null || allBox === undefined)) {
|
|
2890
|
+
try {
|
|
2891
|
+
const previousValue = area.value
|
|
2892
|
+
const previousHeight = area.style.height
|
|
2893
|
+
// Assigning `value` drops a textarea's scroll position to the top -
|
|
2894
|
+
// even when the assignment is only a measuring detour. Verified in
|
|
2895
|
+
// Chromium: the SAME string keeps the scroll, a DIFFERENT string
|
|
2896
|
+
// resets it to 0, and putting the original back does not bring it
|
|
2897
|
+
// back. The "turn max" pass below writes every other block's text into
|
|
2898
|
+
// this field, so every cache-miss layout (the first one, a page
|
|
2899
|
+
// switch, and every pass while the turn's blocks change - i.e. while
|
|
2900
|
+
// the model streams) threw the reader back to the top. That is what
|
|
2901
|
+
// "the 思维链/正文 page cannot be scrolled down" was: the wheel worked,
|
|
2902
|
+
// the position did not survive the next layout.
|
|
2903
|
+
const previousScroll = typeof area.scrollTop === 'number' ? area.scrollTop : 0
|
|
2904
|
+
const measure = (text) => {
|
|
2905
|
+
area.value = text
|
|
2906
|
+
// The settled height: one pass reads a line short (see
|
|
2907
|
+
// settleAreaHeight), and this number decides the whole box. The
|
|
2908
|
+
// scroller is the box itself, so its position is kept too.
|
|
2909
|
+
return Math.round(withScrollKept(box, () => settleAreaHeight(area)))
|
|
2910
|
+
}
|
|
2911
|
+
const own = measure(previousValue)
|
|
2912
|
+
if (own > 0) contentHeight = own
|
|
2913
|
+
// "Turn max" pins the box to the tallest block of the turn, so
|
|
2914
|
+
// switching between a short answer and a long thought does not resize
|
|
2915
|
+
// the frame under the user's hands. The measurement is cached: doing
|
|
2916
|
+
// it for every block on every scroll frame is what made the editor
|
|
2917
|
+
// drag while the conversation moved.
|
|
2918
|
+
if (heightMode !== 'content' && Array.isArray(blocks)) {
|
|
2919
|
+
const key = `${Math.round(area.clientWidth || 0)}|${blocks.map((block) => `${block.seq ?? ''}:${block.index}:${typeof block.text === 'string' ? block.text.length : 0}`).join(',')}`
|
|
2920
|
+
if (maxHeightCache.key === key) {
|
|
2921
|
+
contentHeight = Math.max(contentHeight, maxHeightCache.value)
|
|
2922
|
+
} else {
|
|
2923
|
+
let tallest = 0
|
|
2924
|
+
for (const block of blocks) {
|
|
2925
|
+
if (block === null || block === undefined || typeof block.text !== 'string') continue
|
|
2926
|
+
const measured = measure(block.text)
|
|
2927
|
+
if (measured > tallest) tallest = measured
|
|
2928
|
+
}
|
|
2929
|
+
maxHeightCache = { key, value: tallest }
|
|
2930
|
+
if (tallest > contentHeight) contentHeight = tallest
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
area.value = previousValue
|
|
2934
|
+
area.style.height = previousHeight
|
|
2935
|
+
if (typeof area.scrollTop === 'number' && area.scrollTop !== previousScroll) {
|
|
2936
|
+
area.scrollTop = previousScroll
|
|
2937
|
+
}
|
|
2938
|
+
} catch {}
|
|
2939
|
+
}
|
|
2940
|
+
// Every mode hugs the measured content; `coveredHeight` is only a
|
|
2941
|
+
// fallback for a host whose textarea cannot be measured.
|
|
2942
|
+
// While the editor is open its height FOLLOWS the draft: more lines grow
|
|
2943
|
+
// the box, deleting lines shrinks it - but never below the height it had
|
|
2944
|
+
// when it opened, and never past the composer below or the conversation
|
|
2945
|
+
// top above. The floor belongs to the whole edit (a page switch keeps it)
|
|
2946
|
+
// and only a save or a cancel ends it.
|
|
2947
|
+
const baseHeight =
|
|
2948
|
+
controller !== null &&
|
|
2949
|
+
controller !== undefined &&
|
|
2950
|
+
typeof controller.editorBaseHeight === 'number' &&
|
|
2951
|
+
controller.editorBaseHeight > 0
|
|
2952
|
+
? controller.editorBaseHeight
|
|
2953
|
+
: null
|
|
2954
|
+
let height = Math.max(minimumHeight, contentHeight + headerOffset + FOOTER_BAND, baseHeight === null ? 0 : baseHeight)
|
|
2955
|
+
let clampedTop = null
|
|
2956
|
+
if (clamped) {
|
|
2957
|
+
const bounds = conversationBounds(row)
|
|
2958
|
+
const bottomLimit = (bounds.bottom > 0 ? bounds.bottom : viewport.height) - margin
|
|
2959
|
+
const topLimit = bounds.top + margin
|
|
2960
|
+
if (bottomLimit > topLimit) {
|
|
2961
|
+
// All-blocks mode shows one page of blocks behind ONE scrollbar: it
|
|
2962
|
+
// fills the WHOLE band (not just the message's own position), so the
|
|
2963
|
+
// top touches the conversation edge and the bottom the composer.
|
|
2964
|
+
const fillAll = allBox !== null && allBox !== undefined && heightMode !== 'content'
|
|
2965
|
+
const band = bottomLimit - topLimit
|
|
2966
|
+
if (fillAll) {
|
|
2967
|
+
clampedTop = topLimit
|
|
2968
|
+
height = Math.max(minimumHeight, band)
|
|
2969
|
+
} else if (height <= band) {
|
|
2970
|
+
// It fits: grow DOWN from the row while there is room for it, and
|
|
2971
|
+
// when there is not, keep the bottom at the composer edge and grow
|
|
2972
|
+
// UP instead - the direction follows the space, the box never covers
|
|
2973
|
+
// the composer and never leaves the conversation's top.
|
|
2974
|
+
const room = bottomLimit - Math.max(base.top, topLimit)
|
|
2975
|
+
clampedTop = height <= room ? Math.max(base.top, topLimit) : Math.max(topLimit, bottomLimit - height)
|
|
2976
|
+
height = Math.min(height, band)
|
|
2977
|
+
} else {
|
|
2978
|
+
// Taller than the whole band: fill it and let the field scroll.
|
|
2979
|
+
clampedTop = topLimit
|
|
2980
|
+
height = Math.max(minimumHeight, band)
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
if (!inline && !clamped && viewport.height > 0) height = Math.min(height, Math.max(minimumHeight, viewport.height - margin * 2))
|
|
2985
|
+
height = Math.max(minimumHeight, height)
|
|
2986
|
+
if (baseHeight === null && controller !== null && controller !== undefined) {
|
|
2987
|
+
// The first layout of this open fixes the floor: from here on the box
|
|
2988
|
+
// may grow and shrink with the draft, but not below this.
|
|
2989
|
+
controller.editorBaseHeight = height
|
|
2990
|
+
}
|
|
2991
|
+
box.style.height = `${height}px`
|
|
2992
|
+
box.style.maxHeight = ''
|
|
2993
|
+
let top = clampedTop === null ? base.top : clampedTop
|
|
2994
|
+
if (inline) {
|
|
2995
|
+
const pageY = typeof window !== 'undefined' && typeof window.scrollY === 'number' ? window.scrollY : 0
|
|
2996
|
+
top += pageY
|
|
2997
|
+
} else if (viewport.height > 0) {
|
|
2998
|
+
top = Math.max(margin, Math.min(top, viewport.height - height - margin))
|
|
2999
|
+
}
|
|
3000
|
+
box.style.top = `${Math.round(top)}px`
|
|
3001
|
+
if (allBox !== null && allBox !== undefined) {
|
|
3002
|
+
allBox.style.position = 'absolute'
|
|
3003
|
+
allBox.style.left = '8px'
|
|
3004
|
+
allBox.style.right = '8px'
|
|
3005
|
+
// The all-blocks page has its own sticky outline, so it does not need
|
|
3006
|
+
// the 38px control header the single-block editor reserves.
|
|
3007
|
+
allBox.style.top = '6px'
|
|
3008
|
+
allBox.style.bottom = `${FOOTER_BAND + 8}px`
|
|
3009
|
+
allBox.style.overflowY = 'auto'
|
|
3010
|
+
// The container is now laid out; re-measure each block so the document
|
|
3011
|
+
// is exactly as tall as its text and the ONE scrollbar appears when it
|
|
3012
|
+
// overflows the band.
|
|
3013
|
+
if (typeof allBox.querySelectorAll === 'function') {
|
|
3014
|
+
withScrollKept(allBox, () => {
|
|
3015
|
+
for (const blockArea of allBox.querySelectorAll('.dshet-all-area')) settleAreaHeight(blockArea)
|
|
3016
|
+
})
|
|
3017
|
+
}
|
|
3018
|
+
} else if (area !== null && area !== undefined) {
|
|
3019
|
+
const offsetLeft = Math.max(0, bubble.rect.left - base.left)
|
|
3020
|
+
// Reserve a header band for the controls; when the prompt carries image
|
|
3021
|
+
// chips they sit above the text, so the text starts below them too.
|
|
3022
|
+
const offsetTop = headerOffset
|
|
3023
|
+
// The control band is painted over the box's bottom edge, so the field
|
|
3024
|
+
// has to stop above it: the old `height - offsetTop - 8` let the field
|
|
3025
|
+
// run 40px under the footer, and the last lines of a long block were
|
|
3026
|
+
// covered for good (the field scrolled, the footer never moved).
|
|
3027
|
+
const footerHeight =
|
|
3028
|
+
footerEl !== null && footerEl !== undefined && typeof footerEl.offsetHeight === 'number' && footerEl.offsetHeight > 0
|
|
3029
|
+
? footerEl.offsetHeight
|
|
3030
|
+
: FOOTER_BAND
|
|
3031
|
+
const available = Math.max(40, height - offsetTop - footerHeight)
|
|
3032
|
+
area.style.position = 'absolute'
|
|
3033
|
+
area.style.boxSizing = 'border-box'
|
|
3034
|
+
area.style.left = `${Math.round(offsetLeft)}px`
|
|
3035
|
+
area.style.top = `${Math.round(offsetTop)}px`
|
|
3036
|
+
area.style.width = `${Math.round(mode === 'reply' ? Math.max(80, width - offsetLeft - 8) : Math.max(80, Math.min(bubble.rect.width, width - offsetLeft)))}px`
|
|
3037
|
+
// Never let the textarea paint outside the box: when the measured
|
|
3038
|
+
// content is taller than the box (viewport-capped overlay), it scrolls
|
|
3039
|
+
// inside instead of spilling past the border.
|
|
3040
|
+
area.style.height = `${Math.round(Math.max(32, Math.min(contentHeight, available)))}px`
|
|
3041
|
+
area.style.maxHeight = 'none'
|
|
3042
|
+
area.style.padding = ''
|
|
3043
|
+
area.style.paddingBottom = '8px'
|
|
3044
|
+
}
|
|
3045
|
+
box.style.borderRadius = ''
|
|
3046
|
+
try {
|
|
3047
|
+
const skin = getComputedStyle(bubble.element)
|
|
3048
|
+
if (skin.borderRadius && skin.borderRadius !== '0px') box.style.borderRadius = skin.borderRadius
|
|
3049
|
+
} catch {
|
|
3050
|
+
/* a detached node keeps the stylesheet skin */
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
/**
|
|
3055
|
+
* Run `work` without losing the reader's place.
|
|
3056
|
+
*
|
|
3057
|
+
* Measuring a textarea means collapsing it (`height: auto`), which shrinks
|
|
3058
|
+
* the document; the browser then clamps the scroller's `scrollTop` to the
|
|
3059
|
+
* new maximum, and the position is gone even though the heights come back.
|
|
3060
|
+
* The host re-renders constantly (a streaming turn, a state refresh), so
|
|
3061
|
+
* this is what "scroll down and it jumps back up" was. The position is
|
|
3062
|
+
* captured before the work and put back after.
|
|
3063
|
+
*
|
|
3064
|
+
* @param scroller - the scrolling element that holds the textareas.
|
|
3065
|
+
* @param work - the measuring pass.
|
|
3066
|
+
* @returns whatever `work` returned.
|
|
3067
|
+
*/
|
|
3068
|
+
function withScrollKept(scroller, work) {
|
|
3069
|
+
const keep =
|
|
3070
|
+
scroller !== null && scroller !== undefined && typeof scroller.scrollTop === 'number'
|
|
3071
|
+
? scroller.scrollTop
|
|
3072
|
+
: null
|
|
3073
|
+
const result = work()
|
|
3074
|
+
if (keep !== null && scroller.scrollTop !== keep) scroller.scrollTop = keep
|
|
3075
|
+
return result
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
/**
|
|
3079
|
+
* The content height of one textarea, SETTLED and applied.
|
|
3080
|
+
*
|
|
3081
|
+
* One measurement is not enough. With the height reset to `auto`, the first
|
|
3082
|
+
* read comes back exactly one line short - the wrap is resolved after the
|
|
3083
|
+
* style write - and the element is then pinned to that height with
|
|
3084
|
+
* `overflow:hidden`. In the all-blocks page that made the last line of a
|
|
3085
|
+
* long body unreachable: the surrounding scroller's height is computed from
|
|
3086
|
+
* the pinned value, so it cannot scroll to a line the textarea is hiding
|
|
3087
|
+
* (reported from the field: a long 正文 could not be scrolled past).
|
|
3088
|
+
*
|
|
3089
|
+
* Measured live: first read 2392, second read 2415, line height 23px.
|
|
3090
|
+
*
|
|
3091
|
+
* @param area - the textarea to measure and size.
|
|
3092
|
+
* @returns the applied height in CSS pixels, or 0 when unmeasurable.
|
|
3093
|
+
*/
|
|
3094
|
+
function settleAreaHeight(area) {
|
|
3095
|
+
if (area === null || area === undefined || typeof area.scrollHeight !== 'number') return 0
|
|
3096
|
+
area.style.height = 'auto'
|
|
3097
|
+
const first = area.scrollHeight
|
|
3098
|
+
if (!(typeof first === 'number' && first > 0)) return 0
|
|
3099
|
+
area.style.height = `${first}px`
|
|
3100
|
+
const settled = area.scrollHeight
|
|
3101
|
+
const height = typeof settled === 'number' && settled > first ? settled : first
|
|
3102
|
+
area.style.height = `${height}px`
|
|
3103
|
+
return height
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
/**
|
|
3107
|
+
* One block's identity inside the editor's draft store.
|
|
3108
|
+
*
|
|
3109
|
+
* A turn's blocks are addressed by (seq, index): the same block reached from
|
|
3110
|
+
* the 正文 page and from its sub-page inside 全部 must resolve to the SAME
|
|
3111
|
+
* key, or an unsaved edit disappears when the user switches pages.
|
|
3112
|
+
*
|
|
3113
|
+
* @param block - an editor block (seq, index).
|
|
3114
|
+
* @returns the stable key.
|
|
3115
|
+
*/
|
|
3116
|
+
function blockDraftKey(block) {
|
|
3117
|
+
if (block === null || block === undefined) return ''
|
|
3118
|
+
return `${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`
|
|
1802
3119
|
}
|
|
1803
3120
|
|
|
1804
3121
|
// Grow the textarea to its content, the way a normal input box behaves.
|
|
@@ -1806,10 +3123,16 @@ window.__ModuleLoader__.load({
|
|
|
1806
3123
|
// in half an empty box. The stylesheet owns the limits (`min-height`,
|
|
1807
3124
|
// `max-height`); the element only ever gets the measured height, and the
|
|
1808
3125
|
// `+2` is the top and bottom border `scrollHeight` does not count.
|
|
3126
|
+
let maxHeightCache = { key: '', value: 0 }
|
|
3127
|
+
|
|
1809
3128
|
function fitEditorHeight(area) {
|
|
1810
|
-
area.
|
|
1811
|
-
const
|
|
1812
|
-
|
|
3129
|
+
if (typeof area.closest === 'function' && area.closest('[data-dshet-inplace="1"]') !== null) return
|
|
3130
|
+
const scroller =
|
|
3131
|
+
typeof area.closest === 'function'
|
|
3132
|
+
? area.closest('.dshet-all') ?? area.closest('.dshet-editor')
|
|
3133
|
+
: null
|
|
3134
|
+
const height = withScrollKept(scroller, () => settleAreaHeight(area))
|
|
3135
|
+
if (height > 0) area.style.height = `${height + 2}px`
|
|
1813
3136
|
}
|
|
1814
3137
|
|
|
1815
3138
|
// Wire an action so a host re-render cannot eat the click.
|
|
@@ -1828,7 +3151,7 @@ window.__ModuleLoader__.load({
|
|
|
1828
3151
|
pressed = true
|
|
1829
3152
|
event.preventDefault()
|
|
1830
3153
|
event.stopPropagation()
|
|
1831
|
-
action()
|
|
3154
|
+
action(event)
|
|
1832
3155
|
}
|
|
1833
3156
|
button.onclick = (event) => {
|
|
1834
3157
|
event.preventDefault()
|
|
@@ -1837,7 +3160,7 @@ window.__ModuleLoader__.load({
|
|
|
1837
3160
|
pressed = false
|
|
1838
3161
|
return
|
|
1839
3162
|
}
|
|
1840
|
-
action()
|
|
3163
|
+
action(event)
|
|
1841
3164
|
}
|
|
1842
3165
|
}
|
|
1843
3166
|
|
|
@@ -1956,6 +3279,12 @@ window.__ModuleLoader__.load({
|
|
|
1956
3279
|
// saving would write, so both have to redraw the strip - and the chip
|
|
1957
3280
|
// keys are enough to tell one list from another.
|
|
1958
3281
|
view.parts.map((part) => part.key).join('.'),
|
|
3282
|
+
// The mode itself: leaving the all-blocks view must rebuild even when the
|
|
3283
|
+
// single block that comes back is the one that was selected before it.
|
|
3284
|
+
view.allBlocks === true ? 'all' : 'one',
|
|
3285
|
+
// The selected reply block: switching blocks has to rebuild the box.
|
|
3286
|
+
view.blockIndex === null || view.blockIndex === undefined ? '' : String(view.blockIndex),
|
|
3287
|
+
view.targetSeq === null || view.targetSeq === undefined ? '' : String(view.targetSeq),
|
|
1959
3288
|
].join('|')
|
|
1960
3289
|
}
|
|
1961
3290
|
|
|
@@ -1964,9 +3293,10 @@ window.__ModuleLoader__.load({
|
|
|
1964
3293
|
// from drifting apart in wording.
|
|
1965
3294
|
function editorTexts(t, target) {
|
|
1966
3295
|
const reply = target.mode === 'reply'
|
|
3296
|
+
const block = reply && target.patch === true
|
|
1967
3297
|
return {
|
|
1968
3298
|
title: t(reply ? 'editor.title-reply' : 'editor.title'),
|
|
1969
|
-
placeholder: t(reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
|
|
3299
|
+
placeholder: t(block ? 'editor.placeholder-block' : reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
|
|
1970
3300
|
warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
|
|
1971
3301
|
// Saving writes the revision into the context; only the opt-in config asks
|
|
1972
3302
|
// the model again, and the button says which one will happen.
|
|
@@ -2030,6 +3360,16 @@ window.__ModuleLoader__.load({
|
|
|
2030
3360
|
return strip
|
|
2031
3361
|
}
|
|
2032
3362
|
|
|
3363
|
+
/** The FileList of a paste/drop payload, without assuming the event shape. */
|
|
3364
|
+
function filesFromData(data) {
|
|
3365
|
+
if (data === null || data === undefined) return []
|
|
3366
|
+
try {
|
|
3367
|
+
return data.files === null || data.files === undefined ? [] : Array.prototype.slice.call(data.files)
|
|
3368
|
+
} catch {
|
|
3369
|
+
return []
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
|
|
2033
3373
|
/**
|
|
2034
3374
|
* The add/replace entry: a picker that puts a block on the list.
|
|
2035
3375
|
*
|
|
@@ -2058,7 +3398,10 @@ window.__ModuleLoader__.load({
|
|
|
2058
3398
|
const add = document.createElement('button')
|
|
2059
3399
|
add.type = 'button'
|
|
2060
3400
|
add.className = 'dshet-attach'
|
|
2061
|
-
add.
|
|
3401
|
+
add.innerHTML = '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" stroke-width="1.3"><path d="M8 2V14" stroke="currentColor"></path><path d="M2 8H14" stroke="currentColor"></path></svg>'
|
|
3402
|
+
add.setAttribute('aria-haspopup', 'listbox')
|
|
3403
|
+
add.title = t('editor.attach.add')
|
|
3404
|
+
add.setAttribute('aria-label', t('editor.attach.add'))
|
|
2062
3405
|
add.disabled = view.pending
|
|
2063
3406
|
pressable(add, () => {
|
|
2064
3407
|
if (typeof picker.click === 'function') picker.click()
|
|
@@ -2076,7 +3419,7 @@ window.__ModuleLoader__.load({
|
|
|
2076
3419
|
? controller.editorBox
|
|
2077
3420
|
: null
|
|
2078
3421
|
if (existing && existing.dataset.dshetFor === marker) {
|
|
2079
|
-
placeEditor(row, existing)
|
|
3422
|
+
placeEditor(row, existing, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
|
|
2080
3423
|
return
|
|
2081
3424
|
}
|
|
2082
3425
|
|
|
@@ -2098,6 +3441,68 @@ window.__ModuleLoader__.load({
|
|
|
2098
3441
|
box.className = 'dshet-editor'
|
|
2099
3442
|
box.dataset[EDITOR_ATTR] = '1'
|
|
2100
3443
|
box.dataset.dshetFor = marker
|
|
3444
|
+
// The frame is a child, not the box's own border: host skins mark the
|
|
3445
|
+
// editor chrome `!important` and win the cascade against the box itself,
|
|
3446
|
+
// but they cannot name this child. It draws the blue outline of the
|
|
3447
|
+
// reference UI on top of whatever border the skin puts on the box.
|
|
3448
|
+
const frame = document.createElement('span')
|
|
3449
|
+
frame.className = 'dshet-frame'
|
|
3450
|
+
frame.style.position = 'absolute'
|
|
3451
|
+
frame.style.inset = '-1px'
|
|
3452
|
+
frame.style.border = '2px solid #4a7dff'
|
|
3453
|
+
frame.style.borderRadius = 'inherit'
|
|
3454
|
+
frame.style.boxSizing = 'border-box'
|
|
3455
|
+
frame.style.pointerEvents = 'none'
|
|
3456
|
+
frame.style.zIndex = '3'
|
|
3457
|
+
box.appendChild(frame)
|
|
3458
|
+
|
|
3459
|
+
// A reply on the patch path may carry several editable blocks (answer
|
|
3460
|
+
// text and reasoning). The picker chooses which one this save targets;
|
|
3461
|
+
// every option carries the block's CURRENT projected text, so switching
|
|
3462
|
+
// never shows the recorded original over a landed patch.
|
|
3463
|
+
if (target.mode === 'reply' && target.patch === true && Array.isArray(target.blocks) && target.blocks.length >= 1) {
|
|
3464
|
+
const picker = document.createElement('label')
|
|
3465
|
+
picker.className = 'dshet-pick'
|
|
3466
|
+
const caption = document.createElement('span')
|
|
3467
|
+
caption.className = 'dshet-sr-only'
|
|
3468
|
+
caption.textContent = t('editor.block')
|
|
3469
|
+
const select = document.createElement('select')
|
|
3470
|
+
select.className = 'dshet-select'
|
|
3471
|
+
select.disabled = view.pending || view.confirming
|
|
3472
|
+
select.setAttribute('aria-label', t('editor.block'))
|
|
3473
|
+
// A single reply can carry several answer texts and several reasoning
|
|
3474
|
+
// blocks; number the labels so each one is addressable in the picker.
|
|
3475
|
+
const totals = {}
|
|
3476
|
+
for (const block of target.blocks) totals[block.kind] = (totals[block.kind] ?? 0) + 1
|
|
3477
|
+
const seen = {}
|
|
3478
|
+
for (const block of target.blocks) {
|
|
3479
|
+
const option = document.createElement('option')
|
|
3480
|
+
option.value = typeof block.seq === 'number' ? `${block.seq}:${block.index}` : String(block.index)
|
|
3481
|
+
const kind = block.kind === 'reasoning' ? 'editor.block.reasoning' : 'editor.block.text'
|
|
3482
|
+
seen[block.kind] = (seen[block.kind] ?? 0) + 1
|
|
3483
|
+
option.textContent = totals[block.kind] > 1 ? `${t(kind)} ${seen[block.kind]}` : t(kind)
|
|
3484
|
+
option.selected = block.index === view.blockIndex
|
|
3485
|
+
select.appendChild(option)
|
|
3486
|
+
}
|
|
3487
|
+
// "All blocks" is always offered for a multi-block turn, not only once
|
|
3488
|
+
// it is already active - otherwise there is no way to select it.
|
|
3489
|
+
if (target.blocks.length > 1) {
|
|
3490
|
+
const allOption = document.createElement('option')
|
|
3491
|
+
allOption.value = 'all'
|
|
3492
|
+
allOption.textContent = t('editor.block.all')
|
|
3493
|
+
select.appendChild(allOption)
|
|
3494
|
+
}
|
|
3495
|
+
if (view.allBlocks === true) {
|
|
3496
|
+
select.value = 'all'
|
|
3497
|
+
} else if (view.blockIndex !== null && view.blockIndex !== undefined) {
|
|
3498
|
+
const current = target.blocks.find((block) => block.index === view.blockIndex && (view.targetSeq === null || view.targetSeq === undefined || block.seq === undefined || block.seq === view.targetSeq))
|
|
3499
|
+
select.value = current !== undefined && typeof current.seq === 'number' ? `${current.seq}:${current.index}` : String(view.blockIndex)
|
|
3500
|
+
}
|
|
3501
|
+
select.addEventListener('change', () => controller.selectBlock(select.value))
|
|
3502
|
+
picker.appendChild(caption)
|
|
3503
|
+
picker.appendChild(select)
|
|
3504
|
+
box.appendChild(picker)
|
|
3505
|
+
}
|
|
2101
3506
|
|
|
2102
3507
|
const area = document.createElement('textarea')
|
|
2103
3508
|
area.rows = 1
|
|
@@ -2113,8 +3518,16 @@ window.__ModuleLoader__.load({
|
|
|
2113
3518
|
// "eats" pinyin.
|
|
2114
3519
|
if (event.isComposing === true) return
|
|
2115
3520
|
fitEditorHeight(area)
|
|
3521
|
+
const row = controller.editorRow
|
|
3522
|
+
const box = controller.editorBox
|
|
3523
|
+
if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
|
|
3524
|
+
})
|
|
3525
|
+
area.addEventListener('compositionend', () => {
|
|
3526
|
+
fitEditorHeight(area)
|
|
3527
|
+
const row = controller.editorRow
|
|
3528
|
+
const box = controller.editorBox
|
|
3529
|
+
if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
|
|
2116
3530
|
})
|
|
2117
|
-
area.addEventListener('compositionend', () => fitEditorHeight(area))
|
|
2118
3531
|
area.addEventListener('focus', () => {
|
|
2119
3532
|
controller.editorFocus = true
|
|
2120
3533
|
})
|
|
@@ -2128,18 +3541,114 @@ window.__ModuleLoader__.load({
|
|
|
2128
3541
|
if (event.isComposing === true || event.keyCode === 229) return
|
|
2129
3542
|
if (event.key === 'Enter' && event.shiftKey !== true) {
|
|
2130
3543
|
event.preventDefault()
|
|
2131
|
-
|
|
3544
|
+
const live = liveView()
|
|
3545
|
+
if (live !== null && live !== undefined && live.confirming === true) controller.confirm()
|
|
2132
3546
|
else controller.review()
|
|
2133
3547
|
return
|
|
2134
3548
|
}
|
|
2135
3549
|
if (event.key === 'Escape') {
|
|
2136
3550
|
event.preventDefault()
|
|
2137
|
-
|
|
3551
|
+
const live = liveView()
|
|
3552
|
+
if (live !== null && live !== undefined && live.confirming === true) controller.back()
|
|
2138
3553
|
else controller.close()
|
|
2139
3554
|
}
|
|
2140
3555
|
})
|
|
2141
3556
|
box.appendChild(area)
|
|
2142
3557
|
|
|
3558
|
+
// "All blocks": every reasoning and text block of the turn on one page,
|
|
3559
|
+
// each in its own textarea under a light separator.
|
|
3560
|
+
if (view.allBlocks === true && Array.isArray(target.blocks)) {
|
|
3561
|
+
const allBox = document.createElement('div')
|
|
3562
|
+
allBox.className = 'dshet-all'
|
|
3563
|
+
// A sticky outline over the page: one chip per block that scrolls the
|
|
3564
|
+
// container to it, so the document keeps its single scrollbar and still
|
|
3565
|
+
// has navigation.
|
|
3566
|
+
const nav = document.createElement('div')
|
|
3567
|
+
nav.className = 'dshet-all-nav'
|
|
3568
|
+
const totals = {}
|
|
3569
|
+
for (const block of target.blocks) totals[block.kind] = (totals[block.kind] ?? 0) + 1
|
|
3570
|
+
const seen = {}
|
|
3571
|
+
for (const block of target.blocks) {
|
|
3572
|
+
const key = `${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`
|
|
3573
|
+
const section = document.createElement('div')
|
|
3574
|
+
section.className = 'dshet-all-block'
|
|
3575
|
+
const head = document.createElement('div')
|
|
3576
|
+
head.className = 'dshet-all-head'
|
|
3577
|
+
const label = document.createElement('span')
|
|
3578
|
+
seen[block.kind] = (seen[block.kind] ?? 0) + 1
|
|
3579
|
+
const kindKey = block.kind === 'reasoning' ? 'editor.block.reasoning' : 'editor.block.text'
|
|
3580
|
+
const labelText = totals[block.kind] > 1 ? `${t(kindKey)} ${seen[block.kind]}` : t(kindKey)
|
|
3581
|
+
label.textContent = labelText
|
|
3582
|
+
const navButton = document.createElement('button')
|
|
3583
|
+
navButton.type = 'button'
|
|
3584
|
+
navButton.textContent = labelText
|
|
3585
|
+
navButton.addEventListener('click', () => {
|
|
3586
|
+
if (typeof section.scrollIntoView === 'function') section.scrollIntoView({ block: 'start' })
|
|
3587
|
+
else allBox.scrollTop = section.offsetTop ?? 0
|
|
3588
|
+
})
|
|
3589
|
+
nav.appendChild(navButton)
|
|
3590
|
+
const line = document.createElement('span')
|
|
3591
|
+
line.className = 'dshet-all-line'
|
|
3592
|
+
head.appendChild(label)
|
|
3593
|
+
head.appendChild(line)
|
|
3594
|
+
const blockArea = document.createElement('textarea')
|
|
3595
|
+
blockArea.className = 'dshet-all-area'
|
|
3596
|
+
blockArea.rows = 1
|
|
3597
|
+
blockArea.spellcheck = false
|
|
3598
|
+
blockArea.style.overflow = 'hidden'
|
|
3599
|
+
blockArea.style.position = 'static'
|
|
3600
|
+
blockArea.style.left = 'auto'
|
|
3601
|
+
blockArea.style.top = 'auto'
|
|
3602
|
+
blockArea.value = controller.draftOfBlock(block)
|
|
3603
|
+
blockArea.disabled = view.pending || view.confirming
|
|
3604
|
+
const grow = () =>
|
|
3605
|
+
withScrollKept(
|
|
3606
|
+
typeof blockArea.closest === 'function' ? blockArea.closest('.dshet-all') : null,
|
|
3607
|
+
() => settleAreaHeight(blockArea),
|
|
3608
|
+
)
|
|
3609
|
+
blockArea.addEventListener('input', () => {
|
|
3610
|
+
controller.setDraftFor(block, blockArea.value)
|
|
3611
|
+
grow()
|
|
3612
|
+
})
|
|
3613
|
+
section.appendChild(head)
|
|
3614
|
+
section.appendChild(blockArea)
|
|
3615
|
+
allBox.appendChild(section)
|
|
3616
|
+
// Only now is the textarea in the document, so `scrollHeight` is the
|
|
3617
|
+
// real content height; measuring before the append returned 0 and the
|
|
3618
|
+
// block stayed 32px tall with its text clipped.
|
|
3619
|
+
grow()
|
|
3620
|
+
}
|
|
3621
|
+
allBox.insertBefore(nav, allBox.firstChild)
|
|
3622
|
+
box.appendChild(allBox)
|
|
3623
|
+
area.style.display = 'none'
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
// Input parity with the composer: a screenshot pasted from the clipboard
|
|
3627
|
+
// and files dropped onto the box join the same chip list as the picker.
|
|
3628
|
+
// Reply edits cannot carry blocks, so the handlers exist only for prompts
|
|
3629
|
+
// whose host proved it can keep them.
|
|
3630
|
+
if (target.mode === 'prompt' && view.attachmentsOk === true) {
|
|
3631
|
+
area.addEventListener('paste', (event) => {
|
|
3632
|
+
const files = filesFromData(event.clipboardData)
|
|
3633
|
+
if (files.length === 0) return
|
|
3634
|
+
event.preventDefault()
|
|
3635
|
+
controller.attachFiles(files)
|
|
3636
|
+
})
|
|
3637
|
+
box.addEventListener('dragover', (event) => {
|
|
3638
|
+
event.preventDefault()
|
|
3639
|
+
box.dataset.dshetDrop = '1'
|
|
3640
|
+
})
|
|
3641
|
+
box.addEventListener('dragleave', () => {
|
|
3642
|
+
delete box.dataset.dshetDrop
|
|
3643
|
+
})
|
|
3644
|
+
box.addEventListener('drop', (event) => {
|
|
3645
|
+
event.preventDefault()
|
|
3646
|
+
delete box.dataset.dshetDrop
|
|
3647
|
+
const files = filesFromData(event.dataTransfer)
|
|
3648
|
+
if (files.length > 0) controller.attachFiles(files)
|
|
3649
|
+
})
|
|
3650
|
+
}
|
|
3651
|
+
|
|
2143
3652
|
// The blocks this message carries, each one a chip the user can remove,
|
|
2144
3653
|
// and the entry that adds another. Drawn only when the host answered that
|
|
2145
3654
|
// it can admit what a chip promises - otherwise the warning below states
|
|
@@ -2158,33 +3667,61 @@ window.__ModuleLoader__.load({
|
|
|
2158
3667
|
if (view.confirming) {
|
|
2159
3668
|
const note = document.createElement('p')
|
|
2160
3669
|
note.className = 'dshet-note'
|
|
2161
|
-
note.textContent = t('confirm.body')
|
|
3670
|
+
note.textContent = t(target.mode === 'reply' && target.patch === true ? 'confirm.body-block' : 'confirm.body')
|
|
2162
3671
|
box.appendChild(note)
|
|
2163
3672
|
}
|
|
2164
3673
|
// Shown when saving really would drop something: a reply's tool calls
|
|
2165
3674
|
// always, and a prompt's blocks when this host cannot carry them.
|
|
2166
|
-
if (target.attachments > 0 && !chips) {
|
|
3675
|
+
if (target.attachments > 0 && !chips && !(target.mode === 'reply' && target.patch === true)) {
|
|
2167
3676
|
const warn = document.createElement('p')
|
|
2168
3677
|
warn.className = 'dshet-warn'
|
|
2169
3678
|
warn.textContent = texts.warn
|
|
2170
3679
|
box.appendChild(warn)
|
|
2171
3680
|
}
|
|
2172
|
-
|
|
3681
|
+
// A running turn only blocks the paths that move the surface (a prompt
|
|
3682
|
+
// rewrite, or a reply edit on a host without the in-place record). The
|
|
3683
|
+
// in-place record appends one event and changes nothing that is already
|
|
3684
|
+
// on the surface, so waiting for the agent to finish is not required -
|
|
3685
|
+
// the model picks the edit up on its next fold.
|
|
3686
|
+
const inPlaceEdit = target.mode === 'reply' && target.patch === true
|
|
3687
|
+
const busyBlocked = view.busy === true && !inPlaceEdit
|
|
3688
|
+
const failureCode = typeof view.failure === 'string' && view.failure !== ''
|
|
3689
|
+
? view.failure
|
|
3690
|
+
: busyBlocked
|
|
3691
|
+
? 'busy'
|
|
3692
|
+
: null
|
|
3693
|
+
if (failureCode !== null) {
|
|
2173
3694
|
const error = document.createElement('p')
|
|
2174
3695
|
error.className = 'dshet-error'
|
|
2175
3696
|
error.setAttribute('role', 'status')
|
|
2176
|
-
|
|
3697
|
+
const detail = typeof view.failureDetail === 'string' && view.failureDetail !== '' ? view.failureDetail : null
|
|
3698
|
+
error.textContent = (failureCode === 'internal' || failureCode === 'generic') && detail !== null
|
|
3699
|
+
? `${errorText(t, failureCode)}(${detail.slice(0, 300)})`
|
|
3700
|
+
: errorText(t, failureCode)
|
|
2177
3701
|
box.appendChild(error)
|
|
2178
3702
|
}
|
|
2179
3703
|
|
|
3704
|
+
// Handlers must read the CURRENT controller state: the host may not have
|
|
3705
|
+
// rebuilt the button after the first press, and a stale `view.confirming`
|
|
3706
|
+
// made the confirmation step a dead end (press again -> review again).
|
|
3707
|
+
const liveView = () => (typeof controller.getSnapshot === 'function' ? controller.getSnapshot() : view)
|
|
3708
|
+
|
|
2180
3709
|
const footer = document.createElement('div')
|
|
2181
3710
|
footer.className = 'dshet-footer'
|
|
3711
|
+
const footerActions = document.createElement('div')
|
|
3712
|
+
footerActions.className = 'dshet-footer-actions'
|
|
2182
3713
|
|
|
2183
3714
|
const cancel = document.createElement('button')
|
|
2184
3715
|
cancel.type = 'button'
|
|
2185
|
-
cancel.className = 'dshet-btn'
|
|
2186
|
-
|
|
2187
|
-
cancel.
|
|
3716
|
+
cancel.className = 'dshet-btn dshet-btn-icon dshet-btn-cancel'
|
|
3717
|
+
const cancelLabel = t('editor.cancel')
|
|
3718
|
+
cancel.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 3.5l9 9" stroke="currentColor"/><path d="M12.5 3.5l-9 9" stroke="currentColor"/></svg>'
|
|
3719
|
+
const cancelText = document.createElement('span')
|
|
3720
|
+
cancelText.className = 'dshet-sr-only'
|
|
3721
|
+
cancelText.textContent = cancelLabel
|
|
3722
|
+
cancel.appendChild(cancelText)
|
|
3723
|
+
cancel.title = `${cancelLabel} (Esc)`
|
|
3724
|
+
cancel.setAttribute('aria-label', cancelLabel)
|
|
2188
3725
|
cancel.disabled = view.pending
|
|
2189
3726
|
pressable(cancel, () => {
|
|
2190
3727
|
if (view.confirming) controller.back()
|
|
@@ -2193,20 +3730,35 @@ window.__ModuleLoader__.load({
|
|
|
2193
3730
|
|
|
2194
3731
|
const submit = document.createElement('button')
|
|
2195
3732
|
submit.type = 'button'
|
|
2196
|
-
submit.className = 'dshet-btn dshet-btn-primary'
|
|
2197
|
-
|
|
3733
|
+
submit.className = 'dshet-btn dshet-btn-primary dshet-btn-save'
|
|
3734
|
+
const submitLabel = view.pending
|
|
2198
3735
|
? t('editor.pending')
|
|
2199
3736
|
: view.confirming
|
|
2200
3737
|
? t('editor.review')
|
|
2201
3738
|
: texts.save
|
|
2202
|
-
|
|
2203
|
-
|
|
3739
|
+
// The official composer uses a bare double-check for confirm, not a black
|
|
3740
|
+
// pill. The label stays in the accessible name (and in the test-visible
|
|
3741
|
+
// textContent) through a visually hidden span.
|
|
3742
|
+
submit.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12.5l4.5 4.5L18 6.5"/><path d="M10 12.5l4.5 4.5L24 6.5"/></svg>'
|
|
3743
|
+
const submitText = document.createElement('span')
|
|
3744
|
+
submitText.className = 'dshet-sr-only'
|
|
3745
|
+
submitText.textContent = submitLabel
|
|
3746
|
+
submit.appendChild(submitText)
|
|
3747
|
+
submit.title = `${submitLabel} (Enter)`
|
|
3748
|
+
submit.setAttribute('aria-label', submitLabel)
|
|
3749
|
+
submit.disabled = view.pending || busyBlocked
|
|
2204
3750
|
pressable(submit, () => {
|
|
2205
|
-
|
|
3751
|
+
const live = liveView()
|
|
3752
|
+
if (live !== null && live !== undefined && live.confirming === true) controller.confirm()
|
|
2206
3753
|
else controller.review()
|
|
2207
3754
|
})
|
|
3755
|
+
if (view.confirming === true) {
|
|
3756
|
+
// The save label is visually hidden, so the confirmation step needs a
|
|
3757
|
+
// visible state of its own or the first press looks like nothing.
|
|
3758
|
+
submit.classList.add('dshet-btn-confirm')
|
|
3759
|
+
}
|
|
2208
3760
|
|
|
2209
|
-
|
|
3761
|
+
footerActions.appendChild(cancel)
|
|
2210
3762
|
// The re-run button belongs to the sibling (dsh-rerun-turn): it appears
|
|
2211
3763
|
// only when that plugin is mounted, only for a prompt edit, and only
|
|
2212
3764
|
// outside the confirmation step - the intent is carried through it. Save
|
|
@@ -2215,19 +3767,48 @@ window.__ModuleLoader__.load({
|
|
|
2215
3767
|
if (target.mode === 'prompt' && view.siblingRerun === 'present' && view.confirming !== true) {
|
|
2216
3768
|
const rerun = document.createElement('button')
|
|
2217
3769
|
rerun.type = 'button'
|
|
2218
|
-
rerun.className = 'dshet-btn dshet-rerun'
|
|
2219
|
-
|
|
3770
|
+
rerun.className = 'dshet-btn dshet-btn-icon dshet-rerun'
|
|
3771
|
+
const rerunLabel = t('editor.rerun')
|
|
3772
|
+
rerun.innerHTML = '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" stroke-width="1.2"><path d="M13.65 2.6v2.9h-2.9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M13.3 8a5.3 5.3 0 1 1-1.55-3.75l1.9 1.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>'
|
|
3773
|
+
const rerunText = document.createElement('span')
|
|
3774
|
+
rerunText.className = 'dshet-sr-only'
|
|
3775
|
+
rerunText.textContent = rerunLabel
|
|
3776
|
+
rerun.appendChild(rerunText)
|
|
3777
|
+
rerun.title = rerunLabel
|
|
3778
|
+
rerun.setAttribute('aria-label', rerunLabel)
|
|
2220
3779
|
rerun.disabled = view.pending
|
|
2221
3780
|
pressable(rerun, () => controller.reviewRerun())
|
|
2222
|
-
|
|
3781
|
+
footerActions.appendChild(rerun)
|
|
3782
|
+
}
|
|
3783
|
+
footerActions.appendChild(submit)
|
|
3784
|
+
footer.appendChild(footerActions)
|
|
3785
|
+
const attachRow = typeof box.querySelector === 'function' ? box.querySelector('.dshet-attach-row') : null
|
|
3786
|
+
if (attachRow !== null && attachRow !== undefined) {
|
|
3787
|
+
attachRow.style.position = 'static'
|
|
3788
|
+
attachRow.style.left = ''
|
|
3789
|
+
attachRow.style.bottom = ''
|
|
3790
|
+
attachRow.style.margin = '0'
|
|
3791
|
+
footer.insertBefore(attachRow, footer.firstChild)
|
|
3792
|
+
}
|
|
3793
|
+
// The block picker shares the control band and sits at its far right.
|
|
3794
|
+
const pickerEl = typeof box.querySelector === 'function' ? box.querySelector('.dshet-pick') : null
|
|
3795
|
+
if (pickerEl !== null && pickerEl !== undefined) {
|
|
3796
|
+
pickerEl.style.position = 'static'
|
|
3797
|
+
pickerEl.style.left = ''
|
|
3798
|
+
pickerEl.style.top = ''
|
|
3799
|
+
pickerEl.style.marginLeft = 'auto'
|
|
3800
|
+
pickerEl.style.marginRight = '0'
|
|
3801
|
+
pickerEl.style.display = 'flex'
|
|
3802
|
+
pickerEl.style.flex = '0 0 auto'
|
|
3803
|
+
pickerEl.style.visibility = ''
|
|
3804
|
+
footer.appendChild(pickerEl)
|
|
2223
3805
|
}
|
|
2224
|
-
footer.appendChild(submit)
|
|
2225
3806
|
box.appendChild(footer)
|
|
2226
3807
|
|
|
2227
|
-
placeEditor(row, box)
|
|
2228
3808
|
controller.editorBox = box
|
|
2229
3809
|
controller.editorRow = row
|
|
2230
3810
|
controller.editorElement = area
|
|
3811
|
+
placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
|
|
2231
3812
|
fitEditorHeight(area)
|
|
2232
3813
|
if (!view.confirming && !view.pending && (fresh || wasFocused)) {
|
|
2233
3814
|
try {
|
|
@@ -2255,11 +3836,531 @@ window.__ModuleLoader__.load({
|
|
|
2255
3836
|
controller.load(true)
|
|
2256
3837
|
}
|
|
2257
3838
|
|
|
3839
|
+
// --- projected reply display ---------------------------------------------
|
|
3840
|
+
//
|
|
3841
|
+
// The host draws a reply from the recorded event, not from the projected
|
|
3842
|
+
// message, so a landed patch would be invisible in the transcript. The
|
|
3843
|
+
// projection is the model's copy; this is the reader's copy. Text blocks are
|
|
3844
|
+
// replaced in place inside the host's own markdown container (the row,
|
|
3845
|
+
// actions and tool cards stay untouched); a patched reasoning block gets a
|
|
3846
|
+
// small note under the reply, because the host renders reasoning as a
|
|
3847
|
+
// collapsed panel that may not be in the DOM at all.
|
|
3848
|
+
|
|
3849
|
+
const PATCH_ATTR = 'data-dshet-patch'
|
|
3850
|
+
const patchOriginals = new WeakMap()
|
|
3851
|
+
// The text this plugin last painted for a key, so a second edit of the same
|
|
3852
|
+
// block can still find the element after the first text replaced the
|
|
3853
|
+
// recorded original in the DOM.
|
|
3854
|
+
const patchedTexts = new Map()
|
|
3855
|
+
// React may re-render the assistant container and reset its attributes and
|
|
3856
|
+
// style while leaving the text node we replaced. Remember the element by
|
|
3857
|
+
// seq:block so the next pass can restore the marker and, on a later edit,
|
|
3858
|
+
// update it without having to re-find the original text (which is gone).
|
|
3859
|
+
const patchedElements = new Map()
|
|
3860
|
+
|
|
3861
|
+
function normalizedText(value) {
|
|
3862
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim()
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
// Markdown syntax is not part of the rendered textContent. Strip the
|
|
3866
|
+
// structural characters before comparing, so a reply that uses **bold** or
|
|
3867
|
+
// a fenced block still matches its own recorded text.
|
|
3868
|
+
function plainText(value) {
|
|
3869
|
+
return normalizedText(String(value ?? '').replace(/[`*_#>~[\]()!|]/g, ' ').replace(/\s+/g, ' '))
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
// Identity, not display: markdown emphasis (`**1**`) renders as `1。`, and
|
|
3873
|
+
// the spaces left by stripping the markers must not defeat the comparison.
|
|
3874
|
+
// Whitespace carries no block identity here, so remove it entirely.
|
|
3875
|
+
function matchText(value) {
|
|
3876
|
+
return plainText(value).replace(/\s+/g, '')
|
|
3877
|
+
}
|
|
3878
|
+
|
|
3879
|
+
// What the reader sees for a patched block. The model context keeps the
|
|
3880
|
+
// raw Markdown the editor wrote; the transcript should not show the markers
|
|
3881
|
+
// as literal text (`**1**`), which is what made a successful edit look like
|
|
3882
|
+
// an unrendered draft. This is deliberately minimal: bold, inline code and
|
|
3883
|
+
// headings are unwrapped; everything else stays as written.
|
|
3884
|
+
function displayText(value) {
|
|
3885
|
+
return String(value ?? '')
|
|
3886
|
+
.replace(/```[\s\S]*?```/g, (fence) => fence.replace(/^```[^\n]*\n?/gm, '').replace(/```$/gm, ''))
|
|
3887
|
+
.replace(/`([^`]*)`/g, '$1')
|
|
3888
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
3889
|
+
.replace(/__([^_]+)__/g, '$1')
|
|
3890
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
function isMarkdownContainer(el) {
|
|
3894
|
+
if (el === null || el === undefined || !el.children) return false
|
|
3895
|
+
const kids = el.children
|
|
3896
|
+
if (kids.length === 0) return false
|
|
3897
|
+
let blocks = 0
|
|
3898
|
+
for (const kid of kids) {
|
|
3899
|
+
try {
|
|
3900
|
+
if (kid.matches && kid.matches('p, pre, ul, ol, blockquote, h1, h2, h3, h4, h5, h6, table')) blocks += 1
|
|
3901
|
+
} catch {
|
|
3902
|
+
return false
|
|
3903
|
+
}
|
|
3904
|
+
}
|
|
3905
|
+
return blocks >= 1 && blocks * 2 >= kids.length
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
function conversationRootForPatches() {
|
|
3909
|
+
const root = document.querySelector('[data-slot="main.conversation"], [data-slot="conversation"]')
|
|
3910
|
+
return root ?? document.body
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
function restorePatchedBlock(el) {
|
|
3914
|
+
const saved = patchOriginals.get(el)
|
|
3915
|
+
if (saved !== null && saved !== undefined) {
|
|
3916
|
+
try {
|
|
3917
|
+
if (typeof saved.html === 'string' && saved.html !== '') el.innerHTML = saved.html
|
|
3918
|
+
else if (typeof saved.text === 'string') el.textContent = saved.text
|
|
3919
|
+
} catch {
|
|
3920
|
+
/* a detached row is not worth a throw */
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
el.removeAttribute(PATCH_ATTR)
|
|
3924
|
+
el.style.whiteSpace = ''
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
// A short reply can be a bare text node (or a span) rather than the
|
|
3928
|
+
// block-level markdown container the picker looks for. Match the deepest
|
|
3929
|
+
// element in the owning row whose whole text is exactly the recorded text;
|
|
3930
|
+
// never a button, a timestamp, our own chrome, or an ancestor that merely
|
|
3931
|
+
// contains the text.
|
|
3932
|
+
function findPatchTextFallback(owner, wanted, previousText = '') {
|
|
3933
|
+
if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return null
|
|
3934
|
+
const original = matchText(wanted.original)
|
|
3935
|
+
const previous = matchText(previousText)
|
|
3936
|
+
if (original === '' && previous === '') return null
|
|
3937
|
+
let scope = owner
|
|
3938
|
+
try {
|
|
3939
|
+
const node = owner.querySelector('[data-slot="conversation.chat.node"]')
|
|
3940
|
+
if (node !== null && node !== undefined) scope = node
|
|
3941
|
+
} catch {
|
|
3942
|
+
/* keep the row as the scope */
|
|
3943
|
+
}
|
|
3944
|
+
for (const el of scope.querySelectorAll('*')) {
|
|
3945
|
+
if (el === scope) continue
|
|
3946
|
+
if (el.getAttribute(PATCH_ATTR) !== null) continue
|
|
3947
|
+
try {
|
|
3948
|
+
if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
|
|
3949
|
+
} catch {
|
|
3950
|
+
continue
|
|
3951
|
+
}
|
|
3952
|
+
const text = matchText(el.textContent)
|
|
3953
|
+
if (text !== original && !(previous !== '' && text === previous)) continue
|
|
3954
|
+
let deeper = false
|
|
3955
|
+
for (const child of el.children ?? []) {
|
|
3956
|
+
if (matchText(child.textContent) === text) {
|
|
3957
|
+
deeper = true
|
|
3958
|
+
break
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
if (!deeper) return el
|
|
3962
|
+
}
|
|
3963
|
+
return null
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
/**
|
|
3967
|
+
* The collapsed reasoning header draws its own truncation of the RECORDED
|
|
3968
|
+
* text ("思考 · ..."), and when the panel is closed the body is not in the
|
|
3969
|
+
* DOM at all - so a patch of the reasoning body alone leaves the edit
|
|
3970
|
+
* invisible. Rewrite that snippet too, keeping whatever the host appended
|
|
3971
|
+
* (its ellipsis) and its own truncation length.
|
|
3972
|
+
*/
|
|
3973
|
+
function patchReasoningSummary(owner, wanted) {
|
|
3974
|
+
if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return false
|
|
3975
|
+
const original = String(wanted.original ?? '')
|
|
3976
|
+
const patched = String(wanted.text ?? '')
|
|
3977
|
+
if (original === '' || patched === '') return false
|
|
3978
|
+
const head = original.slice(0, 12)
|
|
3979
|
+
let changed = false
|
|
3980
|
+
const apply = (get, set) => {
|
|
3981
|
+
const data = String(get() ?? '')
|
|
3982
|
+
if (data === '' || !data.includes(head)) return
|
|
3983
|
+
// A node holding the whole text gets the whole replacement; a truncated
|
|
3984
|
+
// snippet keeps the host's length and gains an ellipsis when the new
|
|
3985
|
+
// text is longer.
|
|
3986
|
+
const next = data.length >= original.length ? patched : `${patched.slice(0, data.length)}${patched.length > data.length ? '…' : ''}`
|
|
3987
|
+
if (next === data) return
|
|
3988
|
+
set(next)
|
|
3989
|
+
changed = true
|
|
3990
|
+
}
|
|
3991
|
+
const walk = (node) => {
|
|
3992
|
+
for (const child of node.childNodes ?? []) {
|
|
3993
|
+
const type = child.nodeType
|
|
3994
|
+
if (type === 3) {
|
|
3995
|
+
apply(() => child.data, (value) => { child.data = value })
|
|
3996
|
+
continue
|
|
3997
|
+
}
|
|
3998
|
+
if (type !== 1) continue
|
|
3999
|
+
try {
|
|
4000
|
+
if (typeof child.closest === 'function' && child.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
|
|
4001
|
+
} catch {
|
|
4002
|
+
/* a node without closest is walked anyway */
|
|
4003
|
+
}
|
|
4004
|
+
const kids = child.childNodes ?? []
|
|
4005
|
+
if (kids.length === 0) {
|
|
4006
|
+
apply(() => child.textContent, (value) => { child.textContent = value })
|
|
4007
|
+
continue
|
|
4008
|
+
}
|
|
4009
|
+
walk(child)
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
walk(owner)
|
|
4013
|
+
return changed
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
/**
|
|
4017
|
+
* The reasoning body on this host is its own row (the flow key carries a
|
|
4018
|
+
* `,reasoning` group part), so neither the answer row nor the first chat node
|
|
4019
|
+
* the shared fallback picks contains it. Search the whole conversation for
|
|
4020
|
+
* the deepest element whose text is the recorded original (or what we last
|
|
4021
|
+
* painted there).
|
|
4022
|
+
*/
|
|
4023
|
+
/**
|
|
4024
|
+
* EVERY element that shows this reasoning text.
|
|
4025
|
+
*
|
|
4026
|
+
* Plural on purpose: the host keeps more than one copy of a turn's reasoning.
|
|
4027
|
+
* The collapsed process row stays in the DOM with `display: none`, and opening
|
|
4028
|
+
* the panel renders a second, visible body. Patching only the first one left
|
|
4029
|
+
* the copy the user was looking at showing the recorded text.
|
|
4030
|
+
*
|
|
4031
|
+
* @param root - the conversation root to search.
|
|
4032
|
+
* @param wanted - the patch (`original` text, new `text`).
|
|
4033
|
+
* @param previousText - what the patch showed before this pass, if anything.
|
|
4034
|
+
* @returns the deepest matching elements, best match first.
|
|
4035
|
+
*/
|
|
4036
|
+
function findReasoningElements(root, wanted, previousText) {
|
|
4037
|
+
if (root === null || root === undefined || typeof root.querySelectorAll !== 'function') return []
|
|
4038
|
+
const original = matchText(wanted.original)
|
|
4039
|
+
const previous = matchText(previousText)
|
|
4040
|
+
if (original === '' && previous === '') return []
|
|
4041
|
+
const found = []
|
|
4042
|
+
for (const el of root.querySelectorAll('*')) {
|
|
4043
|
+
if (el.getAttribute(PATCH_ATTR) !== null) continue
|
|
4044
|
+
try {
|
|
4045
|
+
if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
|
|
4046
|
+
} catch {
|
|
4047
|
+
/* a detached node is still a candidate */
|
|
4048
|
+
}
|
|
4049
|
+
const text = matchText(el.textContent)
|
|
4050
|
+
if (text === '') continue
|
|
4051
|
+
if (text !== original && !(previous !== '' && text === previous)) continue
|
|
4052
|
+
let deeper = false
|
|
4053
|
+
for (const child of el.children ?? []) {
|
|
4054
|
+
if (matchText(child.textContent) === text) {
|
|
4055
|
+
deeper = true
|
|
4056
|
+
break
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
if (deeper) continue
|
|
4060
|
+
found.push({ el, score: Math.abs(text.length - original.length) })
|
|
4061
|
+
}
|
|
4062
|
+
found.sort((a, b) => a.score - b.score)
|
|
4063
|
+
return found.map((entry) => entry.el)
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
function findReasoningElement(root, wanted, previousText) {
|
|
4067
|
+
if (root === null || root === undefined || typeof root.querySelectorAll !== 'function') return null
|
|
4068
|
+
const original = matchText(wanted.original)
|
|
4069
|
+
const previous = matchText(previousText)
|
|
4070
|
+
if (original === '' && previous === '') return null
|
|
4071
|
+
let best = null
|
|
4072
|
+
let bestScore = Infinity
|
|
4073
|
+
for (const el of root.querySelectorAll('*')) {
|
|
4074
|
+
if (el.getAttribute(PATCH_ATTR) !== null) continue
|
|
4075
|
+
try {
|
|
4076
|
+
if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
|
|
4077
|
+
} catch {
|
|
4078
|
+
/* a detached node is still a candidate */
|
|
4079
|
+
}
|
|
4080
|
+
const text = matchText(el.textContent)
|
|
4081
|
+
if (text === '') continue
|
|
4082
|
+
if (text !== original && !(previous !== '' && text === previous)) continue
|
|
4083
|
+
let deeper = false
|
|
4084
|
+
for (const child of el.children ?? []) {
|
|
4085
|
+
if (matchText(child.textContent) === text) {
|
|
4086
|
+
deeper = true
|
|
4087
|
+
break
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
if (deeper) continue
|
|
4091
|
+
const score = Math.abs(text.length - original.length)
|
|
4092
|
+
if (score < bestScore) {
|
|
4093
|
+
best = el
|
|
4094
|
+
bestScore = score
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
return best
|
|
4098
|
+
}
|
|
4099
|
+
|
|
4100
|
+
function applyPatchDisplay(snapshot, view, t) {
|
|
4101
|
+
// Map a reply seq to the host row that draws it, so a short answer like
|
|
4102
|
+
// "好的" cannot be matched in a different row. Fall back to a global
|
|
4103
|
+
// search when the row is not mounted (virtualised histories).
|
|
4104
|
+
const rowBySeq = new Map()
|
|
4105
|
+
// A turn-process row also covers the turn's assistant seq (seqsFor reads
|
|
4106
|
+
// its anchor), and it comes before the assistant row in document order.
|
|
4107
|
+
// First-win mapping therefore pointed the patch at the process row, whose
|
|
4108
|
+
// subtree has no message body: zero matches. Prefer the row that actually
|
|
4109
|
+
// draws the message; the process/tail rows are only a last resort.
|
|
4110
|
+
const rowRank = new Map()
|
|
4111
|
+
try {
|
|
4112
|
+
for (const row of document.querySelectorAll('[data-chat-flow-key]')) {
|
|
4113
|
+
const key = row.getAttribute('data-chat-flow-key')
|
|
4114
|
+
if (!key || !snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') continue
|
|
4115
|
+
const node = snapshot.nodes.get(key)
|
|
4116
|
+
if (!node) continue
|
|
4117
|
+
const rank = node.kind === 'assistant-step' ? 0 : node.kind === 'user' ? 1 : 2
|
|
4118
|
+
for (const seq of seqsFor(node)) {
|
|
4119
|
+
const previous = rowRank.get(seq)
|
|
4120
|
+
if (previous === undefined || rank < previous) {
|
|
4121
|
+
rowBySeq.set(seq, row)
|
|
4122
|
+
rowRank.set(seq, rank)
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
}
|
|
4126
|
+
} catch {
|
|
4127
|
+
/* a host shape this pass cannot read falls back below */
|
|
4128
|
+
}
|
|
4129
|
+
const root = conversationRootForPatches()
|
|
4130
|
+
if (root === null) return
|
|
4131
|
+
const replies = view.replies instanceof Map ? view.replies : new Map()
|
|
4132
|
+
const wantedText = new Map()
|
|
4133
|
+
const wantedReasoning = new Map()
|
|
4134
|
+
for (const patch of Array.isArray(view.patches) ? view.patches : []) {
|
|
4135
|
+
// Find the block by (seq, index) AND the entry that carries it.
|
|
4136
|
+
//
|
|
4137
|
+
// One reply entry carries its whole TURN's blocks, so several blocks
|
|
4138
|
+
// share an index - the first step's reasoning and the fourth step's
|
|
4139
|
+
// reasoning are both "index 0" - and a later step's block lives in the
|
|
4140
|
+
// entry keyed by the ANSWER's seq, not by its own. Keying the lookup by
|
|
4141
|
+
// `patch.seq` alone found no entry, and `find(index === ...)` alone found
|
|
4142
|
+
// the wrong block: either way the patch matched nothing in the DOM and
|
|
4143
|
+
// the reasoning body kept the recorded text until a reload (reported from
|
|
4144
|
+
// the field for the 思维链 page; a save on another step's reasoning wrote
|
|
4145
|
+
// the record and /state reported it, yet the transcript did not move).
|
|
4146
|
+
let reply = replies.get(patch.seq)
|
|
4147
|
+
let block = Array.isArray(reply?.blocks)
|
|
4148
|
+
? reply.blocks.find(
|
|
4149
|
+
(entry) =>
|
|
4150
|
+
entry.index === patch.blockIndex &&
|
|
4151
|
+
(entry.seq === undefined || patch.seq === undefined || entry.seq === patch.seq),
|
|
4152
|
+
)
|
|
4153
|
+
: undefined
|
|
4154
|
+
if (block === undefined) {
|
|
4155
|
+
for (const candidate of replies.values()) {
|
|
4156
|
+
if (!Array.isArray(candidate?.blocks)) continue
|
|
4157
|
+
const found = candidate.blocks.find(
|
|
4158
|
+
(entry) => entry.index === patch.blockIndex && entry.seq === patch.seq,
|
|
4159
|
+
)
|
|
4160
|
+
if (found !== undefined) {
|
|
4161
|
+
reply = candidate
|
|
4162
|
+
block = found
|
|
4163
|
+
break
|
|
4164
|
+
}
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
4167
|
+
if (block === undefined) continue
|
|
4168
|
+
if (patch.kind === 'text') {
|
|
4169
|
+
wantedText.set(`${patch.seq}:${patch.blockIndex}`, {
|
|
4170
|
+
key: `${patch.seq}:${patch.blockIndex}`,
|
|
4171
|
+
seq: patch.seq,
|
|
4172
|
+
original: String(block.original ?? ''),
|
|
4173
|
+
text: String(patch.text ?? ''),
|
|
4174
|
+
})
|
|
4175
|
+
} else if (patch.kind === 'reasoning') {
|
|
4176
|
+
wantedReasoning.set(`${patch.seq}:${patch.blockIndex}`, {
|
|
4177
|
+
key: `${patch.seq}:${patch.blockIndex}`,
|
|
4178
|
+
seq: patch.seq,
|
|
4179
|
+
original: String(block.original ?? ''),
|
|
4180
|
+
text: String(patch.text ?? ''),
|
|
4181
|
+
reply,
|
|
4182
|
+
block,
|
|
4183
|
+
})
|
|
4184
|
+
}
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
// A patch that no longer exists must give the host's recorded text back,
|
|
4188
|
+
// even when a re-render already stripped our marker from the element.
|
|
4189
|
+
for (const [key, el] of [...patchedElements]) {
|
|
4190
|
+
if (wantedText.has(key) || wantedReasoning.has(key)) continue
|
|
4191
|
+
patchedElements.delete(key)
|
|
4192
|
+
if (el.isConnected === true) restorePatchedBlock(el)
|
|
4193
|
+
}
|
|
4194
|
+
// Remove overrides whose patch is gone (or was superseded by a prompt
|
|
4195
|
+
// rollback). The original HTML is what the host rendered.
|
|
4196
|
+
for (const el of [...root.querySelectorAll(`[${PATCH_ATTR}]`)]) {
|
|
4197
|
+
const key = el.getAttribute(PATCH_ATTR)
|
|
4198
|
+
const wanted = wantedText.get(key)
|
|
4199
|
+
if (wanted === undefined) {
|
|
4200
|
+
restorePatchedBlock(el)
|
|
4201
|
+
continue
|
|
4202
|
+
}
|
|
4203
|
+
const shown = displayText(wanted.text)
|
|
4204
|
+
if (normalizedText(el.textContent) !== normalizedText(shown)) {
|
|
4205
|
+
el.textContent = shown
|
|
4206
|
+
}
|
|
4207
|
+
}
|
|
4208
|
+
if (wantedText.size === 0 && wantedReasoning.size === 0) return
|
|
4209
|
+
|
|
4210
|
+
const nodes = [...root.querySelectorAll('[data-slot="conversation.chat.node"]')]
|
|
4211
|
+
const containers = []
|
|
4212
|
+
for (const node of nodes) {
|
|
4213
|
+
for (const el of node.querySelectorAll('*')) {
|
|
4214
|
+
try {
|
|
4215
|
+
if (isMarkdownContainer(el) && el.closest('.dshet-editor, .dshet-pick') === null) containers.push(el)
|
|
4216
|
+
} catch {
|
|
4217
|
+
/* a detached node is skipped */
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
for (const [key, wanted] of wantedText) {
|
|
4222
|
+
const owner = rowBySeq.get(wanted.seq)
|
|
4223
|
+
const remembered = patchedElements.get(key)
|
|
4224
|
+
if (remembered !== undefined) {
|
|
4225
|
+
const stillThere = remembered.isConnected === true &&
|
|
4226
|
+
(owner === undefined || remembered.closest('[data-chat-flow-key]') === owner)
|
|
4227
|
+
if (!stillThere) {
|
|
4228
|
+
patchedElements.delete(key)
|
|
4229
|
+
} else {
|
|
4230
|
+
const shown = displayText(wanted.text)
|
|
4231
|
+
if (normalizedText(remembered.textContent) !== normalizedText(shown)) {
|
|
4232
|
+
remembered.textContent = shown
|
|
4233
|
+
}
|
|
4234
|
+
if (remembered.getAttribute(PATCH_ATTR) !== key) remembered.setAttribute(PATCH_ATTR, key)
|
|
4235
|
+
remembered.style.whiteSpace = 'pre-wrap'
|
|
4236
|
+
continue
|
|
4237
|
+
}
|
|
4238
|
+
}
|
|
4239
|
+
const done = [...root.querySelectorAll(`[${PATCH_ATTR}]`)].find((el) => el.getAttribute(PATCH_ATTR) === key)
|
|
4240
|
+
if (done !== undefined) {
|
|
4241
|
+
patchedElements.set(key, done)
|
|
4242
|
+
if (done.style.whiteSpace !== 'pre-wrap') done.style.whiteSpace = 'pre-wrap'
|
|
4243
|
+
continue
|
|
4244
|
+
}
|
|
4245
|
+
const original = matchText(wanted.original)
|
|
4246
|
+
const previous = matchText(patchedTexts.get(key) ?? '')
|
|
4247
|
+
if (original === '' && previous === '') continue
|
|
4248
|
+
let best = null
|
|
4249
|
+
let bestScore = Infinity
|
|
4250
|
+
for (const el of containers) {
|
|
4251
|
+
if (el.getAttribute(PATCH_ATTR) !== null) continue
|
|
4252
|
+
if (owner !== undefined) {
|
|
4253
|
+
try {
|
|
4254
|
+
if (el.closest('[data-chat-flow-key]') !== owner) continue
|
|
4255
|
+
} catch {
|
|
4256
|
+
/* no closest on this node: keep the global match */
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4259
|
+
const text = matchText(el.textContent)
|
|
4260
|
+
if (text === '') continue
|
|
4261
|
+
const exact = text === original || (previous !== '' && text === previous)
|
|
4262
|
+
const prefix = text.startsWith(original.slice(0, 60)) || original.startsWith(text.slice(0, 60))
|
|
4263
|
+
if (!exact && !prefix) continue
|
|
4264
|
+
// Prefer the exact match, then the container closest in length: a
|
|
4265
|
+
// nested paragraph and the whole markdown body can both match a
|
|
4266
|
+
// prefix, and the paragraph is the one to rewrite.
|
|
4267
|
+
const score = (exact ? 0 : 1000) + Math.abs(text.length - original.length)
|
|
4268
|
+
if (score < bestScore) {
|
|
4269
|
+
best = el
|
|
4270
|
+
bestScore = score
|
|
4271
|
+
}
|
|
4272
|
+
}
|
|
4273
|
+
if (best === null) best = findPatchTextFallback(owner, wanted, patchedTexts.get(key) ?? '')
|
|
4274
|
+
if (best === null) continue
|
|
4275
|
+
patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
|
|
4276
|
+
best.textContent = displayText(wanted.text)
|
|
4277
|
+
best.setAttribute(PATCH_ATTR, key)
|
|
4278
|
+
best.style.whiteSpace = 'pre-wrap'
|
|
4279
|
+
patchedElements.set(key, best)
|
|
4280
|
+
patchedTexts.set(key, wanted.text)
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
// A reasoning patch is drawn in place when the host renders that
|
|
4284
|
+
// reasoning body; the note is only the fallback for a panel that is not
|
|
4285
|
+
// in the DOM (or is drawn as a short summary without the full text).
|
|
4286
|
+
for (const [key, wanted] of wantedReasoning) {
|
|
4287
|
+
const owner = rowBySeq.get(wanted.seq)
|
|
4288
|
+
const shown = displayText(wanted.text)
|
|
4289
|
+
const previousText = patchedTexts.get(key) ?? ''
|
|
4290
|
+
const original = matchText(wanted.original)
|
|
4291
|
+
const previous = matchText(previousText)
|
|
4292
|
+
// EVERY copy of this reasoning, not just the one this pass remembers.
|
|
4293
|
+
//
|
|
4294
|
+
// The host keeps the collapsed process row in the DOM with `display:
|
|
4295
|
+
// none` and renders a SECOND body when the panel is opened. Trusting the
|
|
4296
|
+
// remembered element because it is still `isConnected` meant the copy the
|
|
4297
|
+
// user was looking at kept the recorded text until a reload - which is
|
|
4298
|
+
// exactly the reported "思维链页保存后不能马上渲染".
|
|
4299
|
+
const targets = new Set()
|
|
4300
|
+
const remembered = patchedElements.get(key)
|
|
4301
|
+
if (remembered !== undefined) {
|
|
4302
|
+
if (remembered.isConnected === true) targets.add(remembered)
|
|
4303
|
+
else patchedElements.delete(key)
|
|
4304
|
+
}
|
|
4305
|
+
for (const el of root.querySelectorAll(`[${PATCH_ATTR}]`)) {
|
|
4306
|
+
if (el.getAttribute(PATCH_ATTR) === key) targets.add(el)
|
|
4307
|
+
}
|
|
4308
|
+
if (original !== '' || previous !== '') {
|
|
4309
|
+
// The owning row first (a host that draws the reasoning inside the
|
|
4310
|
+
// answer row), then the whole conversation: on this host the reasoning
|
|
4311
|
+
// is its own row, so the answer row holds no reasoning text at all.
|
|
4312
|
+
// EVERY scope is merged, never just the first one that matched: the
|
|
4313
|
+
// point of this pass is to catch all copies at once.
|
|
4314
|
+
for (const scope of owner === undefined ? [null] : [owner, null]) {
|
|
4315
|
+
let foundInScope = false
|
|
4316
|
+
for (const el of containers) {
|
|
4317
|
+
if (el.getAttribute(PATCH_ATTR) !== null) continue
|
|
4318
|
+
if (scope !== null) {
|
|
4319
|
+
try {
|
|
4320
|
+
if (el.closest('[data-chat-flow-key]') !== scope) continue
|
|
4321
|
+
} catch {
|
|
4322
|
+
/* keep the global match */
|
|
4323
|
+
}
|
|
4324
|
+
}
|
|
4325
|
+
const text = matchText(el.textContent)
|
|
4326
|
+
if (text === '') continue
|
|
4327
|
+
if (text !== original && !(previous !== '' && text === previous)) continue
|
|
4328
|
+
targets.add(el)
|
|
4329
|
+
foundInScope = true
|
|
4330
|
+
}
|
|
4331
|
+
if (foundInScope) break
|
|
4332
|
+
}
|
|
4333
|
+
const fallback = findPatchTextFallback(owner ?? null, wanted, previousText)
|
|
4334
|
+
if (fallback !== null) targets.add(fallback)
|
|
4335
|
+
for (const el of findReasoningElements(root, wanted, previousText)) targets.add(el)
|
|
4336
|
+
}
|
|
4337
|
+
if (targets.size === 0) {
|
|
4338
|
+
// No body in the DOM (the panel is closed): the header snippet is the
|
|
4339
|
+
// only place the edit can show.
|
|
4340
|
+
if (owner !== undefined) patchReasoningSummary(owner, wanted)
|
|
4341
|
+
patchReasoningSummary(root, wanted)
|
|
4342
|
+
continue
|
|
4343
|
+
}
|
|
4344
|
+
for (const el of targets) {
|
|
4345
|
+
if (!patchOriginals.has(el)) patchOriginals.set(el, { html: el.innerHTML, text: el.textContent })
|
|
4346
|
+
if (normalizedText(el.textContent) !== normalizedText(shown)) el.textContent = shown
|
|
4347
|
+
if (el.getAttribute(PATCH_ATTR) !== key) el.setAttribute(PATCH_ATTR, key)
|
|
4348
|
+
el.style.whiteSpace = 'pre-wrap'
|
|
4349
|
+
patchedElements.set(key, el)
|
|
4350
|
+
}
|
|
4351
|
+
patchedTexts.set(key, wanted.text)
|
|
4352
|
+
if (owner !== undefined) patchReasoningSummary(owner, wanted)
|
|
4353
|
+
patchReasoningSummary(root, wanted)
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
|
|
2258
4357
|
function applyDom(snapshot, view, controller, t) {
|
|
2259
4358
|
if (!snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') return
|
|
2260
4359
|
const animate = controller.consumeAnimate()
|
|
2261
4360
|
const rows = document.querySelectorAll('[data-chat-flow-key]')
|
|
2262
4361
|
syncEditableSet(controller, rows.length)
|
|
4362
|
+
let editorCandidate = null
|
|
4363
|
+
let editorRank = -1
|
|
2263
4364
|
for (const row of rows) {
|
|
2264
4365
|
if (!(row instanceof HTMLElement)) continue
|
|
2265
4366
|
const key = row.getAttribute('data-chat-flow-key')
|
|
@@ -2312,7 +4413,20 @@ window.__ModuleLoader__.load({
|
|
|
2312
4413
|
(seqsFor(node).includes(editing.seq) ||
|
|
2313
4414
|
seqsFor(node).some((seq) => headRevision(seq, view.revisions) === editing.seq))
|
|
2314
4415
|
if (coversEditing && (!hidden || row.style.display !== 'none')) {
|
|
2315
|
-
|
|
4416
|
+
const rank = editorRowRank(row, editing.mode)
|
|
4417
|
+
if (rank > editorRank) {
|
|
4418
|
+
editorRank = rank
|
|
4419
|
+
editorCandidate = row
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
}
|
|
4423
|
+
if (view.editing !== null) {
|
|
4424
|
+
if (editorCandidate !== null) {
|
|
4425
|
+
renderEditor(editorCandidate, controller, view, t)
|
|
4426
|
+
} else {
|
|
4427
|
+
clearEditor()
|
|
4428
|
+
controller.editorBox = null
|
|
4429
|
+
controller.editorRow = null
|
|
2316
4430
|
}
|
|
2317
4431
|
}
|
|
2318
4432
|
if (view.editing === null) {
|
|
@@ -2320,6 +4434,13 @@ window.__ModuleLoader__.load({
|
|
|
2320
4434
|
controller.editorBox = null
|
|
2321
4435
|
controller.editorRow = null
|
|
2322
4436
|
}
|
|
4437
|
+
// Draw the projected reply text last: the host has just rebuilt the rows,
|
|
4438
|
+
// and an override applied before that would be wiped by the rebuild.
|
|
4439
|
+
try {
|
|
4440
|
+
applyPatchDisplay(snapshot, view, t)
|
|
4441
|
+
} catch (error) {
|
|
4442
|
+
console.error('[dsh-edit-turn] patch display failed:', error)
|
|
4443
|
+
}
|
|
2323
4444
|
// Whatever of ours no row of this pass claimed is a ghost of an earlier
|
|
2324
4445
|
// apply: the host rebuilt the row under it, or the bundle was reloaded
|
|
2325
4446
|
// over it. Contract §5 - a button nobody can update must not pile up.
|
|
@@ -2383,16 +4504,18 @@ window.__ModuleLoader__.load({
|
|
|
2383
4504
|
event.preventDefault()
|
|
2384
4505
|
event.stopPropagation()
|
|
2385
4506
|
controller.open({
|
|
4507
|
+
anchor: event.currentTarget,
|
|
2386
4508
|
seq: entry.seq,
|
|
2387
4509
|
mode: 'reply',
|
|
2388
4510
|
turn: entry.turn,
|
|
2389
4511
|
messageId: entry.messageId,
|
|
2390
4512
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
2391
4513
|
attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
|
|
2392
|
-
//
|
|
2393
|
-
//
|
|
2394
|
-
//
|
|
2395
|
-
|
|
4514
|
+
// On the in-place path the reply keeps its tool calls and other
|
|
4515
|
+
// blocks, so the editor can offer each editable block (output text
|
|
4516
|
+
// and reasoning). On the replay path there is no block picker: the
|
|
4517
|
+
// whole reply is replaced.
|
|
4518
|
+
...(Array.isArray(entry.blocks) ? { blocks: entry.blocks, patch: true } : { blocks: [] }),
|
|
2396
4519
|
})
|
|
2397
4520
|
}
|
|
2398
4521
|
// The config object is always an object: this runtime's `jsx` reads
|
|
@@ -2432,23 +4555,51 @@ window.__ModuleLoader__.load({
|
|
|
2432
4555
|
&& target.closest('.dshet-editor') !== null
|
|
2433
4556
|
if (!inside) controller.editorFocus = false
|
|
2434
4557
|
}
|
|
4558
|
+
let placementScheduled = false
|
|
4559
|
+
let lastPlacementKey = ''
|
|
2435
4560
|
const keepPlaced = () => {
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
4561
|
+
if (placementScheduled) return
|
|
4562
|
+
placementScheduled = true
|
|
4563
|
+
const run = () => {
|
|
4564
|
+
placementScheduled = false
|
|
4565
|
+
const row = controller.editorRow
|
|
4566
|
+
const box = controller.editorBox
|
|
4567
|
+
if (row === null || box === null || box.isConnected !== true) return
|
|
4568
|
+
const rect = row.getBoundingClientRect()
|
|
4569
|
+
const viewportWidth = typeof window !== 'undefined' && typeof window.innerWidth === 'number' ? window.innerWidth : 0
|
|
4570
|
+
const viewportHeight = typeof window !== 'undefined' && typeof window.innerHeight === 'number' ? window.innerHeight : 0
|
|
4571
|
+
const key = `${Math.round(rect.top)}:${Math.round(rect.height)}:${Math.round(typeof window !== 'undefined' && typeof window.scrollY === 'number' ? window.scrollY : 0)}:${viewportWidth}x${viewportHeight}:${controller.editorBlockKind}:${controller.editorAll === true ? 'all' : ''}`
|
|
4572
|
+
if (key === lastPlacementKey) return
|
|
4573
|
+
lastPlacementKey = key
|
|
4574
|
+
placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
|
|
4575
|
+
}
|
|
4576
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
|
|
4577
|
+
else window.setTimeout(run, 16)
|
|
4578
|
+
}
|
|
4579
|
+
const onResize = () => {
|
|
4580
|
+
// A window resize (or a fullscreen toggle / zoom) moves the composer
|
|
4581
|
+
// and the conversation edges; the cached band must not survive it.
|
|
4582
|
+
conversationBoundsCache = null
|
|
4583
|
+
keepPlaced()
|
|
2439
4584
|
}
|
|
2440
4585
|
document.addEventListener('pointerdown', onPointerDown, true)
|
|
2441
4586
|
// Scroll events do not bubble; capture reaches the inner scroller too.
|
|
2442
4587
|
document.addEventListener('scroll', keepPlaced, { capture: true, passive: true })
|
|
2443
|
-
window.addEventListener('resize',
|
|
4588
|
+
window.addEventListener('resize', onResize)
|
|
4589
|
+
let resizeObserver = null
|
|
4590
|
+
if (typeof ResizeObserver === 'function') {
|
|
4591
|
+
resizeObserver = new ResizeObserver(onResize)
|
|
4592
|
+
try { resizeObserver.observe(document.body) } catch {}
|
|
4593
|
+
}
|
|
2444
4594
|
return () => {
|
|
2445
4595
|
document.removeEventListener('pointerdown', onPointerDown, true)
|
|
2446
4596
|
document.removeEventListener('scroll', keepPlaced, true)
|
|
2447
|
-
window.removeEventListener('resize',
|
|
4597
|
+
window.removeEventListener('resize', onResize)
|
|
4598
|
+
if (resizeObserver !== null) resizeObserver.disconnect()
|
|
2448
4599
|
}
|
|
2449
4600
|
}, [controller])
|
|
2450
4601
|
|
|
2451
|
-
|
|
4602
|
+
useBrowserLayoutEffect(() => {
|
|
2452
4603
|
if (snapshot === undefined) return undefined
|
|
2453
4604
|
let scheduled = false
|
|
2454
4605
|
const run = () => {
|
|
@@ -2460,7 +4611,33 @@ window.__ModuleLoader__.load({
|
|
|
2460
4611
|
applyDom(snapshot, live, controller, t)
|
|
2461
4612
|
}
|
|
2462
4613
|
run()
|
|
2463
|
-
const observer = new MutationObserver(() => {
|
|
4614
|
+
const observer = new MutationObserver((records) => {
|
|
4615
|
+
// Our own editor writes attributes and text while the user types;
|
|
4616
|
+
// feeding those back into a full document pass is what made typing
|
|
4617
|
+
// and streaming compete for the same frames.
|
|
4618
|
+
let relevant = false
|
|
4619
|
+
let lostOurAction = false
|
|
4620
|
+
for (const record of records) {
|
|
4621
|
+
const target = record.target
|
|
4622
|
+
if (target !== null && target !== undefined && typeof target.closest === 'function' && target.closest('.dshet-layer') !== null) continue
|
|
4623
|
+
relevant = true
|
|
4624
|
+
if (record.removedNodes && record.removedNodes.length > 0) {
|
|
4625
|
+
for (const node of record.removedNodes) {
|
|
4626
|
+
if (node === null || node === undefined) continue
|
|
4627
|
+
if (typeof node.closest === 'function' && node.closest('.dshet-action') !== null) lostOurAction = true
|
|
4628
|
+
else if (typeof node.querySelector === 'function' && node.querySelector('.dshet-action') !== null) lostOurAction = true
|
|
4629
|
+
}
|
|
4630
|
+
}
|
|
4631
|
+
break
|
|
4632
|
+
}
|
|
4633
|
+
if (!relevant) return
|
|
4634
|
+
// The host rebuilt a row and our button went with it: put it back in
|
|
4635
|
+
// the same task instead of waiting a frame for the rAF pass.
|
|
4636
|
+
if (lostOurAction) {
|
|
4637
|
+
scheduled = false
|
|
4638
|
+
run()
|
|
4639
|
+
return
|
|
4640
|
+
}
|
|
2464
4641
|
if (scheduled) return
|
|
2465
4642
|
scheduled = true
|
|
2466
4643
|
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
|
|
@@ -2558,18 +4735,212 @@ window.__ModuleLoader__.load({
|
|
|
2558
4735
|
row.style.paddingTop = ''
|
|
2559
4736
|
row.style.paddingBottom = ''
|
|
2560
4737
|
})
|
|
2561
|
-
// 2. Every node this half created goes with it: the injected pencils, the
|
|
2562
|
-
// bubbles standing in for rewritten prompts, the editor and the layer
|
|
2563
|
-
// it is hosted on. React's own nodes are deliberately not in the
|
|
2564
|
-
// selector - the host unmounts those, and pulling one out from under
|
|
2565
|
-
// React makes it throw on the way out.
|
|
2566
|
-
each(OWN_NODE_SELECTOR, (node) => node.remove())
|
|
2567
|
-
editorLayer = null
|
|
4738
|
+
// 2. Every node this half created goes with it: the injected pencils, the
|
|
4739
|
+
// bubbles standing in for rewritten prompts, the editor and the layer
|
|
4740
|
+
// it is hosted on. React's own nodes are deliberately not in the
|
|
4741
|
+
// selector - the host unmounts those, and pulling one out from under
|
|
4742
|
+
// React makes it throw on the way out.
|
|
4743
|
+
each(OWN_NODE_SELECTOR, (node) => node.remove())
|
|
4744
|
+
editorLayer = null
|
|
4745
|
+
}
|
|
4746
|
+
|
|
4747
|
+
// --- editor-style preference ----------------------------------------------
|
|
4748
|
+
//
|
|
4749
|
+
// The style is a browser preference so the manager page and the editor read
|
|
4750
|
+
// one value with no host round-trip. The host config's editorStyle is the
|
|
4751
|
+
// default when nothing has been chosen here.
|
|
4752
|
+
const EDITOR_STYLE_KEY = 'dsh-edit-turn/editorStyle'
|
|
4753
|
+
const EDITOR_STYLES = ['frame', 'bubble', 'center']
|
|
4754
|
+
|
|
4755
|
+
function readStoredEditorStyle() {
|
|
4756
|
+
try {
|
|
4757
|
+
if (typeof localStorage === 'undefined') return null
|
|
4758
|
+
const value = localStorage.getItem(EDITOR_STYLE_KEY)
|
|
4759
|
+
return EDITOR_STYLES.includes(value) ? value : null
|
|
4760
|
+
} catch {
|
|
4761
|
+
return null
|
|
4762
|
+
}
|
|
4763
|
+
}
|
|
4764
|
+
|
|
4765
|
+
let editorStylePreference = readStoredEditorStyle() ?? 'frame'
|
|
4766
|
+
const EDITOR_PLACEMENT_KEY = 'dsh-edit-turn/editorPlacement'
|
|
4767
|
+
const EDITOR_PLACEMENTS = ['overlay', 'inline', 'legacy']
|
|
4768
|
+
|
|
4769
|
+
function readStoredEditorPlacement() {
|
|
4770
|
+
try {
|
|
4771
|
+
if (typeof localStorage === 'undefined') return null
|
|
4772
|
+
const value = localStorage.getItem(EDITOR_PLACEMENT_KEY)
|
|
4773
|
+
return EDITOR_PLACEMENTS.includes(value) ? value : null
|
|
4774
|
+
} catch {
|
|
4775
|
+
return null
|
|
4776
|
+
}
|
|
4777
|
+
}
|
|
4778
|
+
|
|
4779
|
+
let editorPlacementPreference = readStoredEditorPlacement() ?? 'overlay'
|
|
4780
|
+
const EDITOR_HEIGHT_KEY = 'dsh-edit-turn/editorHeight'
|
|
4781
|
+
const EDITOR_HEIGHTS = ['max', 'content', 'fill']
|
|
4782
|
+
|
|
4783
|
+
function readStoredEditorHeight() {
|
|
4784
|
+
try {
|
|
4785
|
+
if (typeof localStorage === 'undefined') return null
|
|
4786
|
+
const value = localStorage.getItem(EDITOR_HEIGHT_KEY)
|
|
4787
|
+
return EDITOR_HEIGHTS.includes(value) ? value : null
|
|
4788
|
+
} catch {
|
|
4789
|
+
return null
|
|
4790
|
+
}
|
|
4791
|
+
}
|
|
4792
|
+
|
|
4793
|
+
let editorHeightPreference = readStoredEditorHeight() ?? 'fill'
|
|
4794
|
+
const editorHeightListeners = new Set()
|
|
4795
|
+
|
|
4796
|
+
function subscribeEditorHeight(listener) {
|
|
4797
|
+
editorHeightListeners.add(listener)
|
|
4798
|
+
return () => editorHeightListeners.delete(listener)
|
|
4799
|
+
}
|
|
4800
|
+
|
|
4801
|
+
function setEditorHeightPreference(mode) {
|
|
4802
|
+
if (!EDITOR_HEIGHTS.includes(mode)) return
|
|
4803
|
+
editorHeightPreference = mode
|
|
4804
|
+
try {
|
|
4805
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(EDITOR_HEIGHT_KEY, mode)
|
|
4806
|
+
} catch {}
|
|
4807
|
+
for (const listener of [...editorHeightListeners]) {
|
|
4808
|
+
try { listener() } catch {}
|
|
4809
|
+
}
|
|
4810
|
+
}
|
|
4811
|
+
|
|
4812
|
+
const editorPlacementListeners = new Set()
|
|
4813
|
+
|
|
4814
|
+
function subscribeEditorPlacement(listener) {
|
|
4815
|
+
editorPlacementListeners.add(listener)
|
|
4816
|
+
return () => editorPlacementListeners.delete(listener)
|
|
4817
|
+
}
|
|
4818
|
+
|
|
4819
|
+
function setEditorPlacementPreference(placement) {
|
|
4820
|
+
if (!EDITOR_PLACEMENTS.includes(placement)) return
|
|
4821
|
+
editorPlacementPreference = placement
|
|
4822
|
+
try {
|
|
4823
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(EDITOR_PLACEMENT_KEY, placement)
|
|
4824
|
+
} catch {
|
|
4825
|
+
/* private mode keeps the in-memory choice */
|
|
4826
|
+
}
|
|
4827
|
+
for (const listener of [...editorPlacementListeners]) {
|
|
4828
|
+
try {
|
|
4829
|
+
listener()
|
|
4830
|
+
} catch {}
|
|
4831
|
+
}
|
|
4832
|
+
}
|
|
4833
|
+
|
|
4834
|
+
const editorStyleListeners = new Set()
|
|
4835
|
+
|
|
4836
|
+
function subscribeEditorStyle(listener) {
|
|
4837
|
+
editorStyleListeners.add(listener)
|
|
4838
|
+
return () => editorStyleListeners.delete(listener)
|
|
4839
|
+
}
|
|
4840
|
+
|
|
4841
|
+
function setEditorStylePreference(style) {
|
|
4842
|
+
if (!EDITOR_STYLES.includes(style)) return
|
|
4843
|
+
editorStylePreference = style
|
|
4844
|
+
try {
|
|
4845
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(EDITOR_STYLE_KEY, style)
|
|
4846
|
+
} catch {
|
|
4847
|
+
/* private mode keeps the in-memory choice */
|
|
4848
|
+
}
|
|
4849
|
+
for (const listener of [...editorStyleListeners]) {
|
|
4850
|
+
try {
|
|
4851
|
+
listener()
|
|
4852
|
+
} catch {}
|
|
4853
|
+
}
|
|
4854
|
+
}
|
|
4855
|
+
|
|
4856
|
+
/**
|
|
4857
|
+
* The component row's configure page (`plugins.row.config`), the same slot
|
|
4858
|
+
* dsh-delete-turn uses. The manager's summary asks for a one-liner; the page
|
|
4859
|
+
* itself renders the three-way picker.
|
|
4860
|
+
*/
|
|
4861
|
+
function EditorStyleOption({ view, t }) {
|
|
4862
|
+
const tr = typeof t === 'function' ? t : (key) => key
|
|
4863
|
+
const [style, setStyle] = react.useState(editorStylePreference)
|
|
4864
|
+
const [placement, setPlacement] = react.useState(editorPlacementPreference)
|
|
4865
|
+
const [heightMode, setHeightMode] = react.useState(editorHeightPreference)
|
|
4866
|
+
react.useEffect(() => subscribeEditorStyle(() => setStyle(editorStylePreference)), [])
|
|
4867
|
+
react.useEffect(() => subscribeEditorPlacement(() => setPlacement(editorPlacementPreference)), [])
|
|
4868
|
+
react.useEffect(() => subscribeEditorHeight(() => setHeightMode(editorHeightPreference)), [])
|
|
4869
|
+
const summaryKey = style === 'bubble' ? 'config.summary.bubble' : style === 'center' ? 'config.summary.center' : 'config.summary.frame'
|
|
4870
|
+
if (view === 'summary') return `${tr(summaryKey)} · ${tr(placement === 'inline' ? 'config.summary.inline' : placement === 'legacy' ? 'config.summary.legacy' : 'config.summary.overlay')} · ${tr(heightMode === 'content' ? 'config.summary.height.content' : heightMode === 'fill' ? 'config.summary.height.fill' : 'config.summary.height.max')}`
|
|
4871
|
+
const hintKey = style === 'bubble' ? 'config.hint.bubble' : style === 'center' ? 'config.hint.center' : 'config.hint.frame'
|
|
4872
|
+
const placementHint = placement === 'inline' ? 'config.hint.inline' : placement === 'legacy' ? 'config.hint.legacy' : 'config.hint.overlay'
|
|
4873
|
+
const heightHint = heightMode === 'content' ? 'config.hint.height.content' : heightMode === 'fill' ? 'config.hint.height.fill' : 'config.hint.height.max'
|
|
4874
|
+
const choices = [
|
|
4875
|
+
['frame', 'style.frame'],
|
|
4876
|
+
['bubble', 'style.bubble'],
|
|
4877
|
+
['center', 'style.center'],
|
|
4878
|
+
]
|
|
4879
|
+
return jsxs('div', {
|
|
4880
|
+
className: 'dshet-config',
|
|
4881
|
+
children: [
|
|
4882
|
+
jsxs('label', {
|
|
4883
|
+
className: 'dshet-config-row',
|
|
4884
|
+
children: [
|
|
4885
|
+
jsx('span', { children: tr('config.style') }),
|
|
4886
|
+
jsx('select', {
|
|
4887
|
+
className: 'dshet-select',
|
|
4888
|
+
value: style,
|
|
4889
|
+
onChange: (event) => setEditorStylePreference(event.target.value),
|
|
4890
|
+
children: choices.map(([value, key]) => jsx('option', { value, children: tr(key) }, value)),
|
|
4891
|
+
}),
|
|
4892
|
+
],
|
|
4893
|
+
}),
|
|
4894
|
+
jsx('p', { className: 'dshet-config-hint', children: tr(hintKey) }),
|
|
4895
|
+
jsxs('label', {
|
|
4896
|
+
className: 'dshet-config-row',
|
|
4897
|
+
children: [
|
|
4898
|
+
jsx('span', { children: tr('config.placement') }),
|
|
4899
|
+
jsx('select', {
|
|
4900
|
+
className: 'dshet-select',
|
|
4901
|
+
value: placement,
|
|
4902
|
+
onChange: (event) => setEditorPlacementPreference(event.target.value),
|
|
4903
|
+
children: [
|
|
4904
|
+
jsx('option', { value: 'overlay', children: tr('placement.overlay') }),
|
|
4905
|
+
jsx('option', { value: 'inline', children: tr('placement.inline') }),
|
|
4906
|
+
jsx('option', { value: 'legacy', children: tr('placement.legacy') }),
|
|
4907
|
+
],
|
|
4908
|
+
}),
|
|
4909
|
+
],
|
|
4910
|
+
}),
|
|
4911
|
+
jsx('p', { className: 'dshet-config-hint', children: tr(placementHint) }),
|
|
4912
|
+
jsxs('label', {
|
|
4913
|
+
className: 'dshet-config-row',
|
|
4914
|
+
children: [
|
|
4915
|
+
jsx('span', { children: tr('config.height') }),
|
|
4916
|
+
jsx('select', {
|
|
4917
|
+
className: 'dshet-select',
|
|
4918
|
+
value: heightMode,
|
|
4919
|
+
onChange: (event) => setEditorHeightPreference(event.target.value),
|
|
4920
|
+
children: [
|
|
4921
|
+
jsx('option', { value: 'max', children: tr('height.max') }),
|
|
4922
|
+
jsx('option', { value: 'content', children: tr('height.content') }),
|
|
4923
|
+
jsx('option', { value: 'fill', children: tr('height.fill') }),
|
|
4924
|
+
],
|
|
4925
|
+
}),
|
|
4926
|
+
],
|
|
4927
|
+
}),
|
|
4928
|
+
jsx('p', { className: 'dshet-config-hint', children: tr(heightHint) }),
|
|
4929
|
+
],
|
|
4930
|
+
})
|
|
2568
4931
|
}
|
|
2569
4932
|
|
|
2570
4933
|
// --- plugin ---------------------------------------------------------------
|
|
2571
4934
|
|
|
2572
4935
|
function apply(ctx) {
|
|
4936
|
+
// Diagnostic only: the host restart does not reload the page's JS, so a
|
|
4937
|
+
// stale client bundle and a live host can coexist. This marker says which
|
|
4938
|
+
// browser half actually ran (the old bundle has none).
|
|
4939
|
+
try {
|
|
4940
|
+
document.documentElement.dataset.dshetVersion = PLUGIN_VERSION
|
|
4941
|
+
} catch {
|
|
4942
|
+
/* a non-DOM host is not worth failing over */
|
|
4943
|
+
}
|
|
2573
4944
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-edit-turn: dictionaries')
|
|
2574
4945
|
|
|
2575
4946
|
const controllers = new Map()
|
|
@@ -2597,6 +4968,31 @@ window.__ModuleLoader__.load({
|
|
|
2597
4968
|
// A reply's edit entry lives in the official assistant-actions strip, one
|
|
2598
4969
|
// entry per assistant message. order 5 puts it ahead of the feedback
|
|
2599
4970
|
// entries (order 10), so the pencil sits with the leading icons.
|
|
4971
|
+
// The active skin repaints its surfaces at any time (switching one
|
|
4972
|
+
// rewrites body's attributes and inline variables), so the palette is
|
|
4973
|
+
// resolved once and then again on every theme or skin change.
|
|
4974
|
+
ctx.effect(() => {
|
|
4975
|
+
const run = () => {
|
|
4976
|
+
try {
|
|
4977
|
+
resolveSkinPalette()
|
|
4978
|
+
} catch {
|
|
4979
|
+
/* the stylesheet defaults stand */
|
|
4980
|
+
}
|
|
4981
|
+
}
|
|
4982
|
+
run()
|
|
4983
|
+
const observer =
|
|
4984
|
+
typeof MutationObserver === 'function' && document && document.body
|
|
4985
|
+
? new MutationObserver(run)
|
|
4986
|
+
: null
|
|
4987
|
+
if (observer !== null) {
|
|
4988
|
+
observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'style', 'data-ds-dark-theme'] })
|
|
4989
|
+
if (document.documentElement) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] })
|
|
4990
|
+
}
|
|
4991
|
+
return () => {
|
|
4992
|
+
if (observer !== null) observer.disconnect()
|
|
4993
|
+
}
|
|
4994
|
+
}, 'dsh-edit-turn:skin-palette')
|
|
4995
|
+
|
|
2600
4996
|
ctx.slots.inject('conversation.chat.assistant-actions', () =>
|
|
2601
4997
|
ctx.slots.register(
|
|
2602
4998
|
{
|
|
@@ -2613,6 +5009,20 @@ window.__ModuleLoader__.load({
|
|
|
2613
5009
|
),
|
|
2614
5010
|
)
|
|
2615
5011
|
|
|
5012
|
+
// The row's configure arrow in the plugin manager. Without this the
|
|
5013
|
+
// component page is a read-only overview (the card cannot be opened); the
|
|
5014
|
+
// key is the manager's `<bundle package>#<row id>` contract.
|
|
5015
|
+
ctx.slots.inject('plugins.row.config', () =>
|
|
5016
|
+
ctx.slots.register(
|
|
5017
|
+
{
|
|
5018
|
+
name: 'plugins.row.config',
|
|
5019
|
+
key: 'dsh-edit-turn#dsh-edit-turn',
|
|
5020
|
+
locale: NS,
|
|
5021
|
+
},
|
|
5022
|
+
EditorStyleOption,
|
|
5023
|
+
),
|
|
5024
|
+
)
|
|
5025
|
+
|
|
2616
5026
|
ctx.slots.inject('conversation.input.overlay', () =>
|
|
2617
5027
|
ctx.slots.register(
|
|
2618
5028
|
{
|
|
@@ -2628,6 +5038,10 @@ window.__ModuleLoader__.load({
|
|
|
2628
5038
|
}
|
|
2629
5039
|
|
|
2630
5040
|
exports.apply = apply
|
|
5041
|
+
// The settings card, exported so a composing bundle (dsh-as-aistudio) can
|
|
5042
|
+
// render THE SAME component on its own page instead of re-implementing it:
|
|
5043
|
+
// one source of truth for 编辑与重跑 settings.
|
|
5044
|
+
exports.ConfigCard = EditorStyleOption
|
|
2631
5045
|
exports.inject = ['slots', 'locale']
|
|
2632
5046
|
exports.PLUGIN_VERSION = PLUGIN_VERSION
|
|
2633
5047
|
return module.exports
|
|
@@ -2642,22 +5056,27 @@ window.__ModuleLoader__.load({
|
|
|
2642
5056
|
const react = require('react')
|
|
2643
5057
|
const jsxRuntime = require('react/jsx-runtime')
|
|
2644
5058
|
const { jsx, jsxs, Fragment } = jsxRuntime
|
|
5059
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
2645
5060
|
|
|
2646
5061
|
const NS = 'dsh-rerun-turn'
|
|
2647
5062
|
const ROUTE_PREFIX = '/dsh-rerun-turn'
|
|
2648
5063
|
|
|
2649
5064
|
/** Keep in sync with package.json and lib/index.js. */
|
|
2650
|
-
const PLUGIN_VERSION = '0.
|
|
5065
|
+
const PLUGIN_VERSION = '0.2.5'
|
|
2651
5066
|
|
|
2652
5067
|
// --- copy -----------------------------------------------------------------
|
|
2653
5068
|
|
|
2654
5069
|
const zh = {
|
|
2655
5070
|
'action.rerun': '重跑这一轮',
|
|
5071
|
+
'action.resend.below': '顺补:重跑下面第一个有 O 的可见轮',
|
|
5072
|
+
'action.resend.inplace': '自补:重新生成这一轮的回答',
|
|
2656
5073
|
'confirm.title': '重跑这一轮?',
|
|
2657
|
-
'confirm.body': '
|
|
5074
|
+
'confirm.body': '这条回答连同它之后的内容会先从模型上下文移除,再用原提示词重新生成;之后的轮次会原样重放。',
|
|
5075
|
+
'confirm.note': '重跑只影响模型后续看到的内容,不会改写历史日志。',
|
|
2658
5076
|
'confirm.yes': '重跑',
|
|
2659
5077
|
'confirm.no': '取消',
|
|
2660
5078
|
'notice.deferred': '重跑已中断:后续轮次的回放将在会话空闲时自动完成。',
|
|
5079
|
+
'notice.dismiss': '关闭这条提示',
|
|
2661
5080
|
'notice.loadError': '状态加载失败,稍后重试。',
|
|
2662
5081
|
'error.invalid': '请求无效,请刷新后重试。',
|
|
2663
5082
|
'error.session-not-active': '这个会话当前未激活,请先打开该会话再重跑。',
|
|
@@ -2675,16 +5094,53 @@ window.__ModuleLoader__.load({
|
|
|
2675
5094
|
'error.method': '请求方式不被接受,请刷新后重试。',
|
|
2676
5095
|
'error.generic': '重跑失败,请重试。',
|
|
2677
5096
|
'error.internal': '服务器内部错误,请稍后重试。',
|
|
5097
|
+
'config.ask': '重跑时询问',
|
|
5098
|
+
'config.hint.ask': '每次重跑前都会先弹出确认,说明这一轮会怎么重放。',
|
|
5099
|
+
'config.hint.auto': '点一下就直接重跑,不再询问。',
|
|
5100
|
+
'config.summary.ask': '重跑前询问',
|
|
5101
|
+
'config.resend': '缺失 O 的可见轮',
|
|
5102
|
+
'config.terms.title': '本插件约定',
|
|
5103
|
+
'config.terms.short': 'Q = 用户输入;O = 模型输出的所有内容块(O = C + A + T),其中 C = 思维链,A = 正文,T = 工具调用',
|
|
5104
|
+
'config.terms.log': '日志轮',
|
|
5105
|
+
'config.terms.log.def': '日志里记录的轮次',
|
|
5106
|
+
'config.terms.visible': '可见轮',
|
|
5107
|
+
'config.terms.visible.def': '对话视窗里、经插件操作之后应该看到的那些轮次',
|
|
5108
|
+
// The option is the WORD; what it does is the hint right below it.
|
|
5109
|
+
'config.resend.below': '顺补',
|
|
5110
|
+
'config.resend.inplace': '自补',
|
|
5111
|
+
'config.hint.resend.below': '重跑它下面第一个有 O 的可见轮;下面没有,就重发最新那条缺 O 的。',
|
|
5112
|
+
'config.hint.resend.inplace': '重跑这一可见轮自己的 O,后面的轮次不动。',
|
|
5113
|
+
'config.summary.auto': '直接重跑',
|
|
2678
5114
|
}
|
|
2679
5115
|
|
|
2680
5116
|
const en = {
|
|
2681
5117
|
'action.rerun': 'Rerun this turn',
|
|
5118
|
+
'action.resend.below': 'Shunbu: rerun the first visible turn with an O below',
|
|
5119
|
+
'action.resend.inplace': 'Zibu: regenerate this turn\'s reply',
|
|
2682
5120
|
'confirm.title': 'Rerun this turn?',
|
|
2683
|
-
'confirm.body': "The reply and everything after it leave the model context first; the turn's own prompt is sent again, and the later turns are replayed verbatim.
|
|
5121
|
+
'confirm.body': "The reply and everything after it leave the model context first; the turn's own prompt is sent again, and the later turns are replayed verbatim.",
|
|
5122
|
+
'confirm.note': 'A rerun only changes what the model reads next; the session log is never rewritten.',
|
|
2684
5123
|
'confirm.yes': 'Rerun',
|
|
2685
5124
|
'confirm.no': 'Cancel',
|
|
2686
5125
|
'notice.deferred': 'Rerun interrupted: the replay of the later turns will resume automatically once the session is idle.',
|
|
5126
|
+
'notice.dismiss': 'Dismiss this notice',
|
|
2687
5127
|
'notice.loadError': 'Loading the state failed; try again shortly.',
|
|
5128
|
+
'config.ask': 'Ask before rerunning',
|
|
5129
|
+
'config.hint.ask': 'Show the confirmation step before every rerun.',
|
|
5130
|
+
'config.hint.auto': 'Rerun right away without asking.',
|
|
5131
|
+
'config.summary.ask': 'Ask before rerunning',
|
|
5132
|
+
'config.resend': 'Visible turns missing an O',
|
|
5133
|
+
'config.terms.title': 'Conventions this plugin uses',
|
|
5134
|
+
'config.terms.short': 'Q = the user input; O = every content block the model outputs (O = C + A + T): C = reasoning, A = answer text, T = tool calls',
|
|
5135
|
+
'config.terms.log': 'Log turn',
|
|
5136
|
+
'config.terms.log.def': 'a turn recorded in the log',
|
|
5137
|
+
'config.terms.visible': 'Visible turn',
|
|
5138
|
+
'config.terms.visible.def': 'one of the turns the transcript should show after the plugins have done their work',
|
|
5139
|
+
'config.resend.below': 'Shunbu',
|
|
5140
|
+
'config.resend.inplace': 'Zibu',
|
|
5141
|
+
'config.hint.resend.below': 'Reruns the first visible turn below it that has an O; if none, resends the newest one missing an O.',
|
|
5142
|
+
'config.hint.resend.inplace': "Reruns this visible turn's own O; the turns after it are left alone.",
|
|
5143
|
+
'config.summary.auto': 'Rerun without asking',
|
|
2688
5144
|
'error.invalid': 'Invalid request; refresh and try again.',
|
|
2689
5145
|
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
2690
5146
|
'error.session-not-found': 'No session log was found for this id.',
|
|
@@ -2711,6 +5167,110 @@ window.__ModuleLoader__.load({
|
|
|
2711
5167
|
return t(typeof code === 'string' && ERROR_KEYS.has(`error.${code}`) ? `error.${code}` : 'error.generic')
|
|
2712
5168
|
}
|
|
2713
5169
|
|
|
5170
|
+
// A notice derived from live progress is dismissed per phase, rerun and
|
|
5171
|
+
// text: the same interruption stays closed, while a new one still shows.
|
|
5172
|
+
function progressKeyOf(progress) {
|
|
5173
|
+
if (progress === null || typeof progress !== 'object') return null
|
|
5174
|
+
const phase = typeof progress.phase === 'string' ? progress.phase : ''
|
|
5175
|
+
const rerunId = typeof progress.rerunId === 'string' ? progress.rerunId : ''
|
|
5176
|
+
const error = typeof progress.error === 'string' ? progress.error : ''
|
|
5177
|
+
const promptError = typeof progress.promptError === 'string' ? progress.promptError : ''
|
|
5178
|
+
return `${phase}|${rerunId}|${error}|${promptError}`
|
|
5179
|
+
}
|
|
5180
|
+
|
|
5181
|
+
// --- preferences ----------------------------------------------------------
|
|
5182
|
+
|
|
5183
|
+
// One client-side preference: whether a rerun opens its confirmation step
|
|
5184
|
+
// first. It lives in localStorage - the store the official client plugins
|
|
5185
|
+
// keep their view options in - so it survives reloads with no host
|
|
5186
|
+
// round-trip, and a subscription keeps the settings page and the rerun
|
|
5187
|
+
// action on one value. Nothing stored means "follow the host config".
|
|
5188
|
+
const PREFS_KEY = 'dsh-rerun-turn:prefs'
|
|
5189
|
+
|
|
5190
|
+
/** The two answers the "which turn does the button address" setting allows. */
|
|
5191
|
+
const RESEND_MODES = ['below', 'inplace']
|
|
5192
|
+
|
|
5193
|
+
function readStoredPrefs() {
|
|
5194
|
+
try {
|
|
5195
|
+
if (typeof window === 'undefined' || !window.localStorage) return { ask: null, resend: null }
|
|
5196
|
+
const raw = window.localStorage.getItem(PREFS_KEY)
|
|
5197
|
+
if (raw === null) return { ask: null, resend: null }
|
|
5198
|
+
const parsed = JSON.parse(raw)
|
|
5199
|
+
return {
|
|
5200
|
+
ask: parsed && typeof parsed.ask === 'boolean' ? parsed.ask : null,
|
|
5201
|
+
resend: parsed && RESEND_MODES.includes(parsed.resend) ? parsed.resend : null,
|
|
5202
|
+
}
|
|
5203
|
+
} catch {
|
|
5204
|
+
// A missing or malformed entry falls back to the host config rather than
|
|
5205
|
+
// breaking the plugin.
|
|
5206
|
+
return { ask: null, resend: null }
|
|
5207
|
+
}
|
|
5208
|
+
}
|
|
5209
|
+
|
|
5210
|
+
const storedPrefs = readStoredPrefs()
|
|
5211
|
+
let storedAsk = storedPrefs.ask
|
|
5212
|
+
let storedResend = storedPrefs.resend
|
|
5213
|
+
let hostConfirm = false
|
|
5214
|
+
const prefListeners = new Set()
|
|
5215
|
+
|
|
5216
|
+
/**
|
|
5217
|
+
* @returns the effective settings: the stored choices, else the defaults.
|
|
5218
|
+
*
|
|
5219
|
+
* `resend` decides what the button on an UNANSWERED turn addresses:
|
|
5220
|
+
* `below` reruns the reply of the first VISIBLE turn below it that has an A
|
|
5221
|
+
* (and sends the newest unanswered prompt when nothing below has one),
|
|
5222
|
+
* `inplace` regenerates the reply of the visible turn that Q belongs to.
|
|
5223
|
+
* The host replays the turns in between either way. The default is `below`.
|
|
5224
|
+
*/
|
|
5225
|
+
function getPrefs() {
|
|
5226
|
+
return {
|
|
5227
|
+
ask: storedAsk === null ? hostConfirm : storedAsk,
|
|
5228
|
+
resend: storedResend === null ? 'below' : storedResend,
|
|
5229
|
+
}
|
|
5230
|
+
}
|
|
5231
|
+
|
|
5232
|
+
function writePrefs() {
|
|
5233
|
+
try {
|
|
5234
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
5235
|
+
// Only the choices that are set: an untouched preference stays absent
|
|
5236
|
+
// so "follow the host config" keeps meaning what it says.
|
|
5237
|
+
const payload = {}
|
|
5238
|
+
if (storedAsk !== null) payload.ask = storedAsk
|
|
5239
|
+
if (storedResend !== null) payload.resend = storedResend
|
|
5240
|
+
window.localStorage.setItem(PREFS_KEY, JSON.stringify(payload))
|
|
5241
|
+
}
|
|
5242
|
+
} catch {
|
|
5243
|
+
// Storage that refuses writes (private mode) must not break the toggle.
|
|
5244
|
+
}
|
|
5245
|
+
for (const listener of prefListeners) {
|
|
5246
|
+
try {
|
|
5247
|
+
listener()
|
|
5248
|
+
} catch {}
|
|
5249
|
+
}
|
|
5250
|
+
}
|
|
5251
|
+
|
|
5252
|
+
function setResendMode(value) {
|
|
5253
|
+
if (!RESEND_MODES.includes(value) || storedResend === value) return
|
|
5254
|
+
storedResend = value
|
|
5255
|
+
writePrefs()
|
|
5256
|
+
}
|
|
5257
|
+
|
|
5258
|
+
function setAskBeforeRerun(value) {
|
|
5259
|
+
const ask = value === true
|
|
5260
|
+
if (storedAsk === ask) return
|
|
5261
|
+
storedAsk = ask
|
|
5262
|
+
// Through the shared writer: writing `{ ask }` alone would drop the other
|
|
5263
|
+
// stored choice (the two live under one key).
|
|
5264
|
+
writePrefs()
|
|
5265
|
+
}
|
|
5266
|
+
|
|
5267
|
+
function subscribePrefs(listener) {
|
|
5268
|
+
prefListeners.add(listener)
|
|
5269
|
+
return () => {
|
|
5270
|
+
prefListeners.delete(listener)
|
|
5271
|
+
}
|
|
5272
|
+
}
|
|
5273
|
+
|
|
2714
5274
|
// --- style ----------------------------------------------------------------
|
|
2715
5275
|
|
|
2716
5276
|
// Same policy as the sibling plugins: the plugin paints its own surfaces
|
|
@@ -2723,8 +5283,8 @@ window.__ModuleLoader__.load({
|
|
|
2723
5283
|
'body[data-ds-dark-theme]{--dsrr-panel:rgba(22,28,44,.96);--dsrr-field:rgba(14,19,33,.98);--dsrr-line:rgba(255,255,255,.18);--dsrr-ink:#eef2f8;--dsrr-ink-dim:#a6b1c6;--dsrr-hover:rgba(255,255,255,.11);--dsrr-shadow:0 12px 32px rgba(0,0,0,.5)}',
|
|
2724
5284
|
// The rerun icon sits among the platform's own action icons and takes
|
|
2725
5285
|
// their colour (the fallback is what the default theme resolves to).
|
|
2726
|
-
'.dsrr-action{width:28px;height:28px;padding:6px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,#81858c);cursor:pointer;transition:background-color .12s,color .12s}',
|
|
2727
|
-
'.dsrr-action:hover:not(:disabled){background:var(--dsrr-field);
|
|
5286
|
+
'.dsrr-action{width:28px;height:28px;padding:6px;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,#81858c);cursor:pointer;transition:background-color .12s,color .12s}',
|
|
5287
|
+
'.dsrr-action:hover:not(:disabled){background:var(--dsrr-field);color:var(--dsw-alias-label-primary,var(--dsrr-ink))}',
|
|
2728
5288
|
'.dsrr-action:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dsrr-accent));outline-offset:2px}',
|
|
2729
5289
|
'.dsrr-action:disabled{cursor:default;opacity:.4}',
|
|
2730
5290
|
'.dsrr-action svg{width:15px;height:15px}',
|
|
@@ -2739,11 +5299,33 @@ window.__ModuleLoader__.load({
|
|
|
2739
5299
|
// edge, so its children would spill over the input. The stack lifts
|
|
2740
5300
|
// itself by its own height, landing fully above the composer.
|
|
2741
5301
|
'.dsrr-stack{transform:translateY(-100%)}',
|
|
2742
|
-
'.dsrr-notice{margin:4px 0;padding:6px 10px;border:1px solid var(--dsrr-line);border-radius:8px;background:var(--dsrr-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dsrr-shadow);color:var(--dsrr-ink-dim);font-size:12px;line-height:19px}',
|
|
5302
|
+
'.dsrr-notice{display:flex;align-items:flex-start;gap:8px;margin:4px 0;padding:6px 10px;border:1px solid var(--dsrr-line);border-radius:8px;background:var(--dsrr-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dsrr-shadow);color:var(--dsrr-ink-dim);font-size:12px;line-height:19px}',
|
|
5303
|
+
'.dsrr-notice-text{flex:1;min-width:0}',
|
|
5304
|
+
'.dsrr-notice-close{flex:none;width:18px;height:18px;margin:0 -2px 0 0;padding:0;border:none;border-radius:50%;background:transparent;color:inherit;font:inherit;font-size:14px;line-height:18px;cursor:pointer;opacity:.7}',
|
|
5305
|
+
'.dsrr-notice-close:hover{opacity:1;background:var(--dsrr-hover)}',
|
|
5306
|
+
'.dsrr-notice-close:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dsrr-accent));outline-offset:1px}',
|
|
2743
5307
|
'.dsrr-notice.dsrr-notice-error{color:var(--dsw-alias-state-error-primary,#c93a31)}',
|
|
2744
|
-
'
|
|
2745
|
-
|
|
2746
|
-
'.
|
|
5308
|
+
// The confirmation is the platform's own modal (the sibling delete plugin
|
|
5309
|
+
// draws its dialog the same way), so the only copy style here is the body
|
|
5310
|
+
// paragraph - the same 14/22 the platform's dialog text uses.
|
|
5311
|
+
'.dsrr-dialog-text{margin:0;color:var(--dsw-alias-label-primary,var(--dsrr-ink));font-size:14px;line-height:22px}',
|
|
5312
|
+
'.dsrr-dialog-note{margin:10px 0 0;color:var(--dsw-alias-label-tertiary,var(--dsrr-ink-dim));font-size:13px;line-height:20px}',
|
|
5313
|
+
'.dsrr-config{display:flex;flex-direction:column;gap:14px;width:100%;max-width:none}',
|
|
5314
|
+
'.dsrr-config-group{display:flex;flex-direction:column;gap:6px}',
|
|
5315
|
+
'.dsrr-config-row{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:13px;font-weight:500}',
|
|
5316
|
+
// Width follows the choice: the options are two characters each, so a
|
|
5317
|
+
// fixed 190px floor only stretched the control across the card.
|
|
5318
|
+
'.dsrr-select{flex:0 0 auto;width:auto;max-width:100%;padding:5px 8px;border:1px solid var(--dsrr-line);border-radius:8px;background:var(--dsrr-panel);color:inherit;font:inherit;font-size:12.5px;cursor:pointer;transition:border-color .15s ease}',
|
|
5319
|
+
'.dsrr-select:hover{border-color:var(--dsrr-accent)}',
|
|
5320
|
+
'.dsrr-select:focus-visible{outline:none;border-color:var(--dsrr-accent);box-shadow:0 0 0 2px var(--dsrr-hover)}',
|
|
5321
|
+
'.dsrr-config-hint{margin:0;max-width:72ch;color:var(--dsrr-ink-dim);font-size:12px;line-height:19px}',
|
|
5322
|
+
'.dsrr-config-divider{height:1px;background:var(--dsrr-line);opacity:.6}',
|
|
5323
|
+
'.dsrr-terms{display:flex;flex-direction:column;gap:8px;margin:0;padding:12px 14px;border-left:3px solid var(--dsrr-accent);border-radius:0 10px 10px 0;background:var(--dsrr-panel)}',
|
|
5324
|
+
'.dsrr-terms-title{margin:0;font-size:13.5px;font-weight:600;line-height:20px}',
|
|
5325
|
+
'.dsrr-terms-short{margin:0;max-width:88ch;font-size:13px;line-height:21px}',
|
|
5326
|
+
'.dsrr-terms-row{display:grid;grid-template-columns:62px 1fr;gap:4px 12px;align-items:baseline;font-size:13px;line-height:21px}',
|
|
5327
|
+
'.dsrr-terms-term{font-weight:600}',
|
|
5328
|
+
'.dsrr-terms-def{margin:0;color:var(--dsrr-ink-dim)}',
|
|
2747
5329
|
'.dsrr-btn{padding:4px 12px;border:1px solid var(--dsrr-line);border-radius:999px;background:var(--dsrr-field);color:var(--dsrr-ink);font:inherit;font-size:12px;line-height:18px;cursor:pointer;transition:background-color .12s}',
|
|
2748
5330
|
'.dsrr-btn:hover{background:var(--dsrr-hover)}',
|
|
2749
5331
|
'.dsrr-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dsrr-accent));outline-offset:2px}',
|
|
@@ -2809,11 +5391,14 @@ window.__ModuleLoader__.load({
|
|
|
2809
5391
|
progress: null,
|
|
2810
5392
|
pending: false,
|
|
2811
5393
|
failure: null,
|
|
5394
|
+
failureDetail: null,
|
|
2812
5395
|
notice: null,
|
|
2813
5396
|
noticeError: false,
|
|
5397
|
+
dismissedKey: null,
|
|
2814
5398
|
confirming: false,
|
|
2815
5399
|
loaded: false,
|
|
2816
5400
|
loadError: false,
|
|
5401
|
+
loadErrorDetail: null,
|
|
2817
5402
|
confirm: false,
|
|
2818
5403
|
revision: 0,
|
|
2819
5404
|
})
|
|
@@ -2916,6 +5501,7 @@ window.__ModuleLoader__.load({
|
|
|
2916
5501
|
}
|
|
2917
5502
|
}
|
|
2918
5503
|
}
|
|
5504
|
+
hostConfirm = Boolean(data.config && data.config.confirm)
|
|
2919
5505
|
this.publish({
|
|
2920
5506
|
hidden,
|
|
2921
5507
|
replies,
|
|
@@ -2929,6 +5515,7 @@ window.__ModuleLoader__.load({
|
|
|
2929
5515
|
progress: data.progress || null,
|
|
2930
5516
|
loaded: true,
|
|
2931
5517
|
loadError: false,
|
|
5518
|
+
loadErrorDetail: null,
|
|
2932
5519
|
confirm: Boolean(data.config && data.config.confirm),
|
|
2933
5520
|
})
|
|
2934
5521
|
// While a rerun runs in the host, keep asking; one last refresh
|
|
@@ -2936,8 +5523,12 @@ window.__ModuleLoader__.load({
|
|
|
2936
5523
|
if (rerunning) this.ensurePolling()
|
|
2937
5524
|
else this.stopPolling()
|
|
2938
5525
|
})
|
|
2939
|
-
.catch(() => {
|
|
2940
|
-
|
|
5526
|
+
.catch((error) => {
|
|
5527
|
+
// The host says WHY (`invalid session id`, `no session log for this
|
|
5528
|
+
// id`, a 500, a network failure). Swallowing it left the panel with
|
|
5529
|
+
// "状态加载失败,稍后重试。" for every cause alike - including the
|
|
5530
|
+
// causes a user can act on.
|
|
5531
|
+
this.publish({ loadError: true, loadErrorDetail: error instanceof Error ? error.message : String(error) })
|
|
2941
5532
|
})
|
|
2942
5533
|
.finally(() => {
|
|
2943
5534
|
this.inflight = null
|
|
@@ -2974,7 +5565,10 @@ window.__ModuleLoader__.load({
|
|
|
2974
5565
|
rerun(entry) {
|
|
2975
5566
|
if (this.disposed === true) return
|
|
2976
5567
|
if (this.view.pending) return
|
|
2977
|
-
|
|
5568
|
+
// The settings-page checkbox (stored) wins over the host config; with
|
|
5569
|
+
// asking off the click goes straight to the host, and a failure still
|
|
5570
|
+
// surfaces through the notice path.
|
|
5571
|
+
if (getPrefs().ask === true) {
|
|
2978
5572
|
this.confirmTarget = entry
|
|
2979
5573
|
this.publish({ confirming: true, failure: null })
|
|
2980
5574
|
return
|
|
@@ -2995,7 +5589,11 @@ window.__ModuleLoader__.load({
|
|
|
2995
5589
|
}
|
|
2996
5590
|
|
|
2997
5591
|
dismissNotice() {
|
|
2998
|
-
|
|
5592
|
+
// A transient notice (view.notice) is not derived from progress;
|
|
5593
|
+
// closing it must not also dismiss the progress notice underneath.
|
|
5594
|
+
const transient = this.view.notice !== null && this.view.notice !== undefined
|
|
5595
|
+
const key = transient ? this.view.dismissedKey : progressKeyOf(this.view.progress)
|
|
5596
|
+
this.publish({ notice: null, noticeError: false, dismissedKey: key })
|
|
2999
5597
|
}
|
|
3000
5598
|
|
|
3001
5599
|
async send(entry) {
|
|
@@ -3015,11 +5613,11 @@ window.__ModuleLoader__.load({
|
|
|
3015
5613
|
// the rerun that just completed, clicked once more before the
|
|
3016
5614
|
// client refreshed. Not an error: refresh silently and let the
|
|
3017
5615
|
// stale row disappear.
|
|
3018
|
-
this.publish({ pending: false, failure: null })
|
|
5616
|
+
this.publish({ pending: false, failure: null, failureDetail: null })
|
|
3019
5617
|
this.load(true)
|
|
3020
5618
|
return
|
|
3021
5619
|
}
|
|
3022
|
-
this.publish({ pending: false, failure: code })
|
|
5620
|
+
this.publish({ pending: false, failure: code, failureDetail: typeof data.error === 'string' && data.error !== '' ? data.error : null })
|
|
3023
5621
|
return
|
|
3024
5622
|
}
|
|
3025
5623
|
// Do NOT hide the retired rows here: the rerun's replacement (fresh
|
|
@@ -3210,7 +5808,6 @@ window.__ModuleLoader__.load({
|
|
|
3210
5808
|
}
|
|
3211
5809
|
const focus = view.rerunning === true && view.rerunFocus ? view.rerunFocus : null
|
|
3212
5810
|
const infos = []
|
|
3213
|
-
let hasFresh = false
|
|
3214
5811
|
for (const row of rows) {
|
|
3215
5812
|
if (!(row instanceof HTMLElement)) continue
|
|
3216
5813
|
const key = row.getAttribute('data-chat-flow-key')
|
|
@@ -3234,11 +5831,6 @@ window.__ModuleLoader__.load({
|
|
|
3234
5831
|
const freshBySeq = info.seqs.some((seq) => seq > focus.carrierSeq)
|
|
3235
5832
|
const freshByTurn = info.keyTurn !== null && info.keyTurn > focus.turn
|
|
3236
5833
|
info.fresh = !info.inTarget && !info.inTail && (freshBySeq || freshByTurn)
|
|
3237
|
-
// Only a real answer row proves the replacement is on screen. The
|
|
3238
|
-
// fresh turn's process/tail strips mount the moment the rerun starts,
|
|
3239
|
-
// so counting them would retire the old answer while it is still
|
|
3240
|
-
// generating - leaving a hole where the answer was.
|
|
3241
|
-
if (info.fresh && (info.kind === 'assistant-step' || info.kind === 'tool-call')) hasFresh = true
|
|
3242
5834
|
}
|
|
3243
5835
|
infos.push(info)
|
|
3244
5836
|
}
|
|
@@ -3277,9 +5869,11 @@ window.__ModuleLoader__.load({
|
|
|
3277
5869
|
} else {
|
|
3278
5870
|
order = '1'
|
|
3279
5871
|
}
|
|
3280
|
-
} else if (info.inTarget && info.kind !== 'user'
|
|
3281
|
-
// The old answer retires
|
|
3282
|
-
//
|
|
5872
|
+
} else if (info.inTarget && info.kind !== 'user') {
|
|
5873
|
+
// The old answer retires the moment the rerun starts, so the fresh
|
|
5874
|
+
// "thinking" row streams where the answer was instead of below it.
|
|
5875
|
+
// Waiting for an assistant-step row left the old answer on screen
|
|
5876
|
+
// for the whole generation.
|
|
3283
5877
|
decision = true
|
|
3284
5878
|
}
|
|
3285
5879
|
}
|
|
@@ -3299,12 +5893,69 @@ window.__ModuleLoader__.load({
|
|
|
3299
5893
|
}
|
|
3300
5894
|
return found
|
|
3301
5895
|
}
|
|
5896
|
+
// Every user row whose turn has no live reply gets the prompt-side button:
|
|
5897
|
+
// a stopped turn, a failed one, one whose reply was deleted, or the prompt
|
|
5898
|
+
// nothing has answered yet. What the button ADDRESSES is the setting
|
|
5899
|
+
// (`getPrefs().resend`):
|
|
5900
|
+
// below - the reply of the first VISIBLE turn below it that has an A
|
|
5901
|
+
// (a hidden reply is not visible, so it is skipped); when
|
|
5902
|
+
// nothing below has one, the newest unanswered prompt.
|
|
5903
|
+
// inplace - the reply of the visible turn this Q belongs to.
|
|
5904
|
+
// The turns in between are replayed by the host either way.
|
|
5905
|
+
const liveReplyOf = (turn) => {
|
|
5906
|
+
if (!Number.isInteger(turn)) return null
|
|
5907
|
+
const reply = lastReplyForTurn(turn)
|
|
5908
|
+
if (reply === null || view.hidden.has(reply.seq)) return null
|
|
5909
|
+
return reply
|
|
5910
|
+
}
|
|
5911
|
+
let newestUnansweredSeq = null
|
|
3302
5912
|
for (const info of infos) {
|
|
5913
|
+
if (info.kind !== 'user') continue
|
|
5914
|
+
if (liveReplyOf(Number(info.row.dataset.chatTurn)) !== null) continue
|
|
5915
|
+
const seq = Array.isArray(info.seqs) ? info.seqs[0] : undefined
|
|
5916
|
+
if (typeof seq === 'number') newestUnansweredSeq = seq
|
|
5917
|
+
}
|
|
5918
|
+
const resendMode = getPrefs().resend
|
|
5919
|
+
for (let index = 0; index < infos.length; index += 1) {
|
|
5920
|
+
const info = infos[index]
|
|
3303
5921
|
if (info.kind !== 'user') continue
|
|
3304
5922
|
const row = info.row
|
|
3305
5923
|
let entry = promptActions.get(row)
|
|
3306
5924
|
const reply = lastReplyForTurn(Number(row.dataset.chatTurn))
|
|
3307
|
-
|
|
5925
|
+
// A reply that exists but is hidden (delete-turn owns the row, or an
|
|
5926
|
+
// earlier rerun retired it) leaves nothing to rerun.
|
|
5927
|
+
if (reply !== null && view.hidden.has(reply.seq)) {
|
|
5928
|
+
if (entry !== undefined) {
|
|
5929
|
+
entry.host.remove()
|
|
5930
|
+
promptActions.delete(row)
|
|
5931
|
+
}
|
|
5932
|
+
continue
|
|
5933
|
+
}
|
|
5934
|
+
// No live reply at all: a turn the user stopped, one that failed, or the
|
|
5935
|
+
// prompt just sent that nothing has answered yet. The host accepts a
|
|
5936
|
+
// PROMPT target for exactly that shape - planRerun: "a prompt target is
|
|
5937
|
+
// an address, never a licence ... accepted only for a turn that has no
|
|
5938
|
+
// rerunnable reply of its own" - and sends it again as a fresh request.
|
|
5939
|
+
// So the button stays and addresses the prompt instead of a reply.
|
|
5940
|
+
const promptSeq = Array.isArray(info.seqs) ? info.seqs[0] : undefined
|
|
5941
|
+
let unansweredTarget = null
|
|
5942
|
+
if (resendMode === 'inplace') {
|
|
5943
|
+
// The visible turn this Q belongs to: its own prompt is the anchor.
|
|
5944
|
+
if (typeof promptSeq === 'number') unansweredTarget = { seq: promptSeq }
|
|
5945
|
+
} else {
|
|
5946
|
+
// The first visible turn BELOW this Q that has an A.
|
|
5947
|
+
for (let next = index + 1; next < infos.length && unansweredTarget === null; next += 1) {
|
|
5948
|
+
if (infos[next].kind !== 'assistant-step') continue
|
|
5949
|
+
const below = liveReplyOf(infos[next].keyTurn)
|
|
5950
|
+
if (below !== null) unansweredTarget = below
|
|
5951
|
+
}
|
|
5952
|
+
// Nothing below has an A: send the newest unanswered prompt instead.
|
|
5953
|
+
if (unansweredTarget === null && typeof newestUnansweredSeq === 'number') {
|
|
5954
|
+
unansweredTarget = { seq: newestUnansweredSeq }
|
|
5955
|
+
}
|
|
5956
|
+
}
|
|
5957
|
+
const target = reply !== null ? reply : unansweredTarget
|
|
5958
|
+
if (target === null) {
|
|
3308
5959
|
if (entry !== undefined) {
|
|
3309
5960
|
entry.host.remove()
|
|
3310
5961
|
promptActions.delete(row)
|
|
@@ -3341,10 +5992,10 @@ window.__ModuleLoader__.load({
|
|
|
3341
5992
|
host.appendChild(button)
|
|
3342
5993
|
}
|
|
3343
5994
|
if (entry === undefined || entry.host !== host || entry.button !== button) {
|
|
3344
|
-
entry = { host, button, reply, controller }
|
|
5995
|
+
entry = { host, button, reply: target, controller }
|
|
3345
5996
|
promptActions.set(row, entry)
|
|
3346
5997
|
}
|
|
3347
|
-
entry.reply =
|
|
5998
|
+
entry.reply = target
|
|
3348
5999
|
entry.controller = controller
|
|
3349
6000
|
if (!wiredButtons.has(button)) {
|
|
3350
6001
|
// Wired ONCE per button per instance: the pass runs on every snapshot.
|
|
@@ -3360,7 +6011,12 @@ window.__ModuleLoader__.load({
|
|
|
3360
6011
|
current.controller.rerun(current.reply)
|
|
3361
6012
|
})
|
|
3362
6013
|
}
|
|
3363
|
-
|
|
6014
|
+
// The prompt-side button of an unanswered turn says what it will do:
|
|
6015
|
+
// send the newest unanswered prompt again, or regenerate this very turn.
|
|
6016
|
+
const label =
|
|
6017
|
+
t === null
|
|
6018
|
+
? ''
|
|
6019
|
+
: t(reply === null ? (resendMode === 'inplace' ? 'action.resend.inplace' : 'action.resend.below') : 'action.rerun')
|
|
3364
6020
|
if (entry.button.getAttribute('aria-label') !== label) {
|
|
3365
6021
|
entry.button.setAttribute('aria-label', label)
|
|
3366
6022
|
entry.button.setAttribute('title', label)
|
|
@@ -3381,6 +6037,93 @@ window.__ModuleLoader__.load({
|
|
|
3381
6037
|
bar.insertBefore(entry.host, next)
|
|
3382
6038
|
}
|
|
3383
6039
|
}
|
|
6040
|
+
syncTurnRail(view)
|
|
6041
|
+
}
|
|
6042
|
+
|
|
6043
|
+
// --- turn rail ------------------------------------------------------------
|
|
6044
|
+
|
|
6045
|
+
/** The turn a rail mark addresses: its label carries the real turn number. */
|
|
6046
|
+
function turnOfRailMark(mark) {
|
|
6047
|
+
const label = mark.getAttribute('aria-label') || mark.getAttribute('title') || ''
|
|
6048
|
+
const match = /(\d+)/.exec(label)
|
|
6049
|
+
return match === null ? null : Number(match[1])
|
|
6050
|
+
}
|
|
6051
|
+
|
|
6052
|
+
/**
|
|
6053
|
+
* Every turn that has a row in the DOM, mapped to whether any of its rows is
|
|
6054
|
+
* visible. A turn with no row at all is absent here: it is not empty, just
|
|
6055
|
+
* not loaded, and the rail may still jump to it.
|
|
6056
|
+
*/
|
|
6057
|
+
function turnRowVisibility() {
|
|
6058
|
+
const seen = new Map()
|
|
6059
|
+
let rows
|
|
6060
|
+
try {
|
|
6061
|
+
rows = document.querySelectorAll('[data-chat-flow-key][data-chat-turn]')
|
|
6062
|
+
} catch {
|
|
6063
|
+
return seen
|
|
6064
|
+
}
|
|
6065
|
+
for (const row of rows) {
|
|
6066
|
+
const turn = Number(row.getAttribute('data-chat-turn'))
|
|
6067
|
+
if (!Number.isInteger(turn)) continue
|
|
6068
|
+
const style = row.style
|
|
6069
|
+
const hidden =
|
|
6070
|
+
row.dataset.dsrrHidden === '1' ||
|
|
6071
|
+
row.dataset.dshetHidden === '1' ||
|
|
6072
|
+
row.dataset.dshdtHidden === '1' ||
|
|
6073
|
+
(style !== undefined && style !== null && style.display === 'none') ||
|
|
6074
|
+
(typeof row.getBoundingClientRect === 'function' && row.getBoundingClientRect().height === 0)
|
|
6075
|
+
if (seen.get(turn) !== true) seen.set(turn, hidden !== true)
|
|
6076
|
+
}
|
|
6077
|
+
return seen
|
|
6078
|
+
}
|
|
6079
|
+
|
|
6080
|
+
/**
|
|
6081
|
+
* The official rail keeps one mark per turn of the session - including the
|
|
6082
|
+
* turns this plugin retired, whose rows are hidden. So a rerun left marks
|
|
6083
|
+
* for turns the conversation no longer shows, and their slots left a gap.
|
|
6084
|
+
* Drop the marks of turns with no visible row, re-space the rest inside the
|
|
6085
|
+
* host's own ladder and centre them; the container keeps the host's height,
|
|
6086
|
+
* because that height is what the host's virtualisation measures: shrinking
|
|
6087
|
+
* it unmounts the marks past the new window (a ten-turn transcript showed
|
|
6088
|
+
* six marks).
|
|
6089
|
+
*/
|
|
6090
|
+
function syncTurnRail(view) {
|
|
6091
|
+
const rows = turnRowVisibility()
|
|
6092
|
+
const hiddenTurns = []
|
|
6093
|
+
for (const [turn, visible] of rows) if (visible !== true) hiddenTurns.push(turn)
|
|
6094
|
+
let navs
|
|
6095
|
+
try {
|
|
6096
|
+
navs = document.querySelectorAll('nav')
|
|
6097
|
+
} catch {
|
|
6098
|
+
return
|
|
6099
|
+
}
|
|
6100
|
+
for (const nav of navs) {
|
|
6101
|
+
let marks
|
|
6102
|
+
try {
|
|
6103
|
+
marks = [...nav.querySelectorAll('button[data-index]')]
|
|
6104
|
+
} catch {
|
|
6105
|
+
continue
|
|
6106
|
+
}
|
|
6107
|
+
if (marks.length === 0) continue
|
|
6108
|
+
const container = marks[0].parentElement
|
|
6109
|
+
if (container === null || container === undefined) continue
|
|
6110
|
+
const observed = Math.round((Number.parseFloat(container.style.height) - 2) / 10)
|
|
6111
|
+
const total = Math.max(Number.isFinite(observed) && observed > 0 ? observed : 0, marks.length)
|
|
6112
|
+
const inRail = hiddenTurns.filter((turn) => turn <= total)
|
|
6113
|
+
const kept = total - inRail.length
|
|
6114
|
+
const offset = kept > 0 && kept < total ? Math.floor((total - kept) / 2) : 0
|
|
6115
|
+
for (const mark of marks) {
|
|
6116
|
+
const turn = turnOfRailMark(mark)
|
|
6117
|
+
if (turn === null) continue
|
|
6118
|
+
if (hiddenTurns.includes(turn)) {
|
|
6119
|
+
mark.style.display = 'none'
|
|
6120
|
+
continue
|
|
6121
|
+
}
|
|
6122
|
+
mark.style.display = ''
|
|
6123
|
+
const before = hiddenTurns.filter((hidden) => hidden < turn).length
|
|
6124
|
+
mark.style.transform = `translate3d(0px, ${(turn - 1 - before + offset) * 10 + 1}px, 0px)`
|
|
6125
|
+
}
|
|
6126
|
+
}
|
|
3384
6127
|
}
|
|
3385
6128
|
|
|
3386
6129
|
// --- react entries --------------------------------------------------------
|
|
@@ -3455,6 +6198,88 @@ window.__ModuleLoader__.load({
|
|
|
3455
6198
|
|
|
3456
6199
|
// The overlay: hides retired rows, follows the background rerun, and
|
|
3457
6200
|
// shows the confirm step plus any failure the host reported.
|
|
6201
|
+
// The plugin's own page in the manager: the row this bundle inserts
|
|
6202
|
+
// (`cordis.patch.yml`, id `dsh-rerun-turn`) gains a configure arrow that
|
|
6203
|
+
// opens this card. Same shape as the sibling plugins' cards.
|
|
6204
|
+
function AskBeforeRerunOption({ view, t }) {
|
|
6205
|
+
const tr = typeof t === 'function' ? t : (key) => key
|
|
6206
|
+
const [ask, setAsk] = react.useState(getPrefs().ask)
|
|
6207
|
+
const [resend, setResend] = react.useState(getPrefs().resend)
|
|
6208
|
+
react.useEffect(
|
|
6209
|
+
() =>
|
|
6210
|
+
subscribePrefs(() => {
|
|
6211
|
+
setAsk(getPrefs().ask)
|
|
6212
|
+
setResend(getPrefs().resend)
|
|
6213
|
+
}),
|
|
6214
|
+
[],
|
|
6215
|
+
)
|
|
6216
|
+
if (view === 'summary') return tr(ask ? 'config.summary.ask' : 'config.summary.auto')
|
|
6217
|
+
return jsxs('div', {
|
|
6218
|
+
className: 'dsrr-config',
|
|
6219
|
+
children: [
|
|
6220
|
+
// The convention frames both settings, so it leads.
|
|
6221
|
+
jsxs('div', {
|
|
6222
|
+
className: 'dsrr-terms',
|
|
6223
|
+
children: [
|
|
6224
|
+
jsx('p', { className: 'dsrr-terms-title', children: tr('config.terms.title') }),
|
|
6225
|
+
jsx('p', { className: 'dsrr-terms-short', children: tr('config.terms.short') }),
|
|
6226
|
+
jsxs('div', {
|
|
6227
|
+
className: 'dsrr-terms-row',
|
|
6228
|
+
children: [
|
|
6229
|
+
jsx('span', { className: 'dsrr-terms-term', children: tr('config.terms.log') }),
|
|
6230
|
+
jsx('p', { className: 'dsrr-terms-def', children: tr('config.terms.log.def') }),
|
|
6231
|
+
],
|
|
6232
|
+
}),
|
|
6233
|
+
jsxs('div', {
|
|
6234
|
+
className: 'dsrr-terms-row',
|
|
6235
|
+
children: [
|
|
6236
|
+
jsx('span', { className: 'dsrr-terms-term', children: tr('config.terms.visible') }),
|
|
6237
|
+
jsx('p', { className: 'dsrr-terms-def', children: tr('config.terms.visible.def') }),
|
|
6238
|
+
],
|
|
6239
|
+
}),
|
|
6240
|
+
],
|
|
6241
|
+
}),
|
|
6242
|
+
// One setting per group: the control on the row, what it means right
|
|
6243
|
+
// under it, and a hairline between the groups.
|
|
6244
|
+
jsxs('div', {
|
|
6245
|
+
className: 'dsrr-config-group',
|
|
6246
|
+
children: [
|
|
6247
|
+
jsx(primitives.Checkbox, {
|
|
6248
|
+
checked: ask,
|
|
6249
|
+
label: tr('config.ask'),
|
|
6250
|
+
onChange: (checked) => setAskBeforeRerun(checked),
|
|
6251
|
+
}),
|
|
6252
|
+
jsx('p', { className: 'dsrr-config-hint', children: tr(ask ? 'config.hint.ask' : 'config.hint.auto') }),
|
|
6253
|
+
],
|
|
6254
|
+
}),
|
|
6255
|
+
jsx('div', { className: 'dsrr-config-divider' }),
|
|
6256
|
+
// What the button on an UNANSWERED turn addresses: the first visible
|
|
6257
|
+
// turn with an A below it, or the visible turn that Q belongs to.
|
|
6258
|
+
jsxs('div', {
|
|
6259
|
+
className: 'dsrr-config-group',
|
|
6260
|
+
children: [
|
|
6261
|
+
jsxs('label', {
|
|
6262
|
+
className: 'dsrr-config-row',
|
|
6263
|
+
children: [
|
|
6264
|
+
jsx('span', { children: tr('config.resend') }),
|
|
6265
|
+
jsx('select', {
|
|
6266
|
+
className: 'dsrr-select',
|
|
6267
|
+
value: resend,
|
|
6268
|
+
onChange: (event) => setResendMode(event.target.value),
|
|
6269
|
+
children: [
|
|
6270
|
+
jsx('option', { value: 'below', children: tr('config.resend.below') }),
|
|
6271
|
+
jsx('option', { value: 'inplace', children: tr('config.resend.inplace') }),
|
|
6272
|
+
],
|
|
6273
|
+
}),
|
|
6274
|
+
],
|
|
6275
|
+
}),
|
|
6276
|
+
jsx('p', { className: 'dsrr-config-hint', children: tr(resend === 'inplace' ? 'config.hint.resend.inplace' : 'config.hint.resend.below') }),
|
|
6277
|
+
],
|
|
6278
|
+
}),
|
|
6279
|
+
],
|
|
6280
|
+
})
|
|
6281
|
+
}
|
|
6282
|
+
|
|
3458
6283
|
function OverlayEntry({ useChat, useRerunTurn, controller, t }) {
|
|
3459
6284
|
const snapshot = typeof useChat === 'function' ? useChat((state) => state) : undefined
|
|
3460
6285
|
const view = useRerunTurn((state) => state)
|
|
@@ -3499,63 +6324,100 @@ window.__ModuleLoader__.load({
|
|
|
3499
6324
|
}, [view.rerunning, controller])
|
|
3500
6325
|
|
|
3501
6326
|
const progress = view.progress
|
|
6327
|
+
const progressKey = progressKeyOf(progress)
|
|
6328
|
+
const progressDismissed = progressKey !== null && view.dismissedKey === progressKey
|
|
6329
|
+
// `error` is only meaningful while the rerun is interrupted or failed.
|
|
6330
|
+
// Older hosts merged progress records, so a finished rerun could still
|
|
6331
|
+
// carry the interruption text; trusting the phase keeps that stale text
|
|
6332
|
+
// off the screen even before the host is upgraded.
|
|
6333
|
+
const progressError =
|
|
6334
|
+
progressDismissed !== true && progress !== null && typeof progress === 'object' &&
|
|
6335
|
+
(progress.phase === 'interrupted' || progress.phase === 'failed') &&
|
|
6336
|
+
typeof progress.error === 'string' && progress.error !== ''
|
|
6337
|
+
? progress.error
|
|
6338
|
+
: ''
|
|
6339
|
+
const progressPromptError =
|
|
6340
|
+
progressDismissed !== true && progress !== null && typeof progress === 'object' && typeof progress.promptError === 'string' && progress.promptError !== ''
|
|
6341
|
+
? progress.promptError
|
|
6342
|
+
: ''
|
|
3502
6343
|
const notice =
|
|
3503
6344
|
view.notice !== null && view.notice !== undefined
|
|
3504
6345
|
? { text: view.notice, error: view.noticeError === true }
|
|
3505
6346
|
: view.loadError === true
|
|
3506
|
-
? {
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
6347
|
+
? {
|
|
6348
|
+
text:
|
|
6349
|
+
typeof view.loadErrorDetail === 'string' && view.loadErrorDetail !== ''
|
|
6350
|
+
? `${t('notice.loadError')} (${view.loadErrorDetail})`
|
|
6351
|
+
: t('notice.loadError'),
|
|
6352
|
+
error: true,
|
|
6353
|
+
}
|
|
6354
|
+
: progressError !== ''
|
|
6355
|
+
? { text: `${t('notice.deferred')} (${progressError})`, error: true }
|
|
6356
|
+
: progressPromptError !== ''
|
|
6357
|
+
? { text: progressPromptError, error: true }
|
|
3511
6358
|
: null
|
|
3512
6359
|
|
|
3513
6360
|
return jsxs('div', {
|
|
3514
6361
|
className: 'dsrr-stack',
|
|
3515
6362
|
'data-dsrr-overlay': '1',
|
|
3516
6363
|
children: [
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
6364
|
+
jsx(primitives.Modal, {
|
|
6365
|
+
open: view.confirming === true,
|
|
6366
|
+
title: t('confirm.title'),
|
|
6367
|
+
closeLabel: t('confirm.no'),
|
|
6368
|
+
onClose: () => controller.confirmNo(),
|
|
6369
|
+
footer: jsxs(Fragment, {
|
|
6370
|
+
children: [
|
|
6371
|
+
jsx(primitives.Button, {
|
|
6372
|
+
variant: 'ghost',
|
|
6373
|
+
size: 'md',
|
|
6374
|
+
onClick: () => controller.confirmNo(),
|
|
6375
|
+
children: t('confirm.no'),
|
|
6376
|
+
}),
|
|
6377
|
+
jsx(primitives.Button, {
|
|
6378
|
+
variant: 'primary',
|
|
6379
|
+
size: 'md',
|
|
6380
|
+
onClick: () => controller.confirmYes(),
|
|
6381
|
+
children: t('confirm.yes'),
|
|
6382
|
+
}),
|
|
6383
|
+
],
|
|
6384
|
+
}),
|
|
6385
|
+
children: jsxs(Fragment, {
|
|
6386
|
+
children: [
|
|
6387
|
+
jsx('p', { className: 'dsrr-dialog-text', children: t('confirm.body') }),
|
|
6388
|
+
jsx('p', { className: 'dsrr-dialog-note', children: t('confirm.note') }),
|
|
6389
|
+
],
|
|
6390
|
+
}),
|
|
6391
|
+
}),
|
|
3545
6392
|
view.failure !== null && view.failure !== undefined
|
|
3546
6393
|
? jsx('div', {
|
|
3547
6394
|
className: 'dsrr-notice dsrr-notice-error',
|
|
3548
6395
|
role: 'status',
|
|
3549
|
-
onClick: () => controller.publish({ failure: null }),
|
|
3550
|
-
children:
|
|
6396
|
+
onClick: () => controller.publish({ failure: null, failureDetail: null }),
|
|
6397
|
+
children: (view.failure === 'internal' || view.failure === 'generic') && typeof view.failureDetail === 'string' && view.failureDetail !== ''
|
|
6398
|
+
? `${errorText(t, view.failure)}(${view.failureDetail.slice(0, 300)})`
|
|
6399
|
+
: errorText(t, view.failure),
|
|
3551
6400
|
})
|
|
3552
6401
|
: null,
|
|
3553
6402
|
notice !== null
|
|
3554
|
-
?
|
|
6403
|
+
? jsxs('div', {
|
|
3555
6404
|
className: notice.error ? 'dsrr-notice dsrr-notice-error' : 'dsrr-notice',
|
|
3556
6405
|
role: 'status',
|
|
3557
6406
|
onClick: () => controller.dismissNotice(),
|
|
3558
|
-
children:
|
|
6407
|
+
children: [
|
|
6408
|
+
jsx('span', { className: 'dsrr-notice-text', children: notice.text }),
|
|
6409
|
+
jsx('button', {
|
|
6410
|
+
type: 'button',
|
|
6411
|
+
className: 'dsrr-notice-close',
|
|
6412
|
+
'aria-label': t('notice.dismiss'),
|
|
6413
|
+
title: t('notice.dismiss'),
|
|
6414
|
+
onClick: (event) => {
|
|
6415
|
+
event.stopPropagation()
|
|
6416
|
+
controller.dismissNotice()
|
|
6417
|
+
},
|
|
6418
|
+
children: '×',
|
|
6419
|
+
}),
|
|
6420
|
+
],
|
|
3559
6421
|
})
|
|
3560
6422
|
: null,
|
|
3561
6423
|
],
|
|
@@ -3633,9 +6495,27 @@ window.__ModuleLoader__.load({
|
|
|
3633
6495
|
OverlayEntry,
|
|
3634
6496
|
),
|
|
3635
6497
|
)
|
|
6498
|
+
|
|
6499
|
+
// The plugin's own page in the manager. The key is the manager's
|
|
6500
|
+
// `<bundle package>#<row id>` contract (the row this bundle inserts in
|
|
6501
|
+
// cordis.patch.yml), exactly as the sibling plugins declare it.
|
|
6502
|
+
ctx.slots.inject('plugins.row.config', () =>
|
|
6503
|
+
ctx.slots.register(
|
|
6504
|
+
{
|
|
6505
|
+
name: 'plugins.row.config',
|
|
6506
|
+
key: 'dsh-rerun-turn#dsh-rerun-turn',
|
|
6507
|
+
locale: NS,
|
|
6508
|
+
},
|
|
6509
|
+
AskBeforeRerunOption,
|
|
6510
|
+
),
|
|
6511
|
+
)
|
|
3636
6512
|
}
|
|
3637
6513
|
|
|
3638
6514
|
exports.apply = apply
|
|
6515
|
+
// The settings card, exported so a composing bundle (dsh-as-aistudio) can
|
|
6516
|
+
// render THE SAME component on its own page instead of re-implementing it:
|
|
6517
|
+
// one source of truth for 重跑 settings.
|
|
6518
|
+
exports.ConfigCard = AskBeforeRerunOption
|
|
3639
6519
|
exports.inject = ['slots', 'locale']
|
|
3640
6520
|
exports.PLUGIN_VERSION = PLUGIN_VERSION
|
|
3641
6521
|
return module.exports
|
|
@@ -3732,6 +6612,9 @@ window.__ModuleLoader__.load({
|
|
|
3732
6612
|
'dialog.desc.step': '这一步的回复与它请求的工具结果将一并从模型上下文中移除,同一回合的其它步骤保留。',
|
|
3733
6613
|
'dialog.desc.reply': '这条回复连同它的思考、工具调用与注入上下文将从模型上下文中移除,你的提问会保留。',
|
|
3734
6614
|
'dialog.note': '删除只影响模型后续看到的内容,不会改写历史日志。',
|
|
6615
|
+
'dialog.busyWarning': '当前回复正在生成;删除会先停止它,再继续。',
|
|
6616
|
+
'dialog.extraWarning': '这段范围里还夹着别的内容;确认后会一并移除。',
|
|
6617
|
+
'dialog.confirmExtra': '一并删除',
|
|
3735
6618
|
'dialog.cancel': '取消',
|
|
3736
6619
|
'dialog.confirm': '删除',
|
|
3737
6620
|
'dialog.pending': '删除中…',
|
|
@@ -3744,7 +6627,8 @@ window.__ModuleLoader__.load({
|
|
|
3744
6627
|
'error.invalid': '请求无效,请刷新后重试。',
|
|
3745
6628
|
'error.session-not-active': '这个会话当前未激活,请先打开该会话再删除。',
|
|
3746
6629
|
'error.session-not-found': '找不到该会话的日志。',
|
|
3747
|
-
'error.busy': '
|
|
6630
|
+
'error.busy': '该会话正在进行中,且当前无法自动停止(可能正在压缩),请稍后重试。',
|
|
6631
|
+
'error.stop-timeout': '已请求停止当前生成,但该回合没有及时停下;请稍后再试。',
|
|
3748
6632
|
'error.already-deleted': '这条内容已经从上下文中删除了。',
|
|
3749
6633
|
'error.not-deletable': '这个位置不支持删除。',
|
|
3750
6634
|
'error.range-not-clean': '目标区间包含其它内容,已取消删除。',
|
|
@@ -3768,6 +6652,9 @@ window.__ModuleLoader__.load({
|
|
|
3768
6652
|
'dialog.desc.step': 'This step and the tool results it requested leave the model context together; other steps in the same turn stay.',
|
|
3769
6653
|
'dialog.desc.reply': 'This reply leaves the model context together with its reasoning, tool calls and injected context; your prompt stays.',
|
|
3770
6654
|
'dialog.note': 'Deletion only changes what the model sees next; the append-only log is never rewritten.',
|
|
6655
|
+
'dialog.busyWarning': 'A reply is still generating; deleting will stop it first.',
|
|
6656
|
+
'dialog.extraWarning': 'This range also covers other content; confirming removes it as well.',
|
|
6657
|
+
'dialog.confirmExtra': 'Delete all of it',
|
|
3771
6658
|
'dialog.cancel': 'Cancel',
|
|
3772
6659
|
'dialog.confirm': 'Delete',
|
|
3773
6660
|
'dialog.pending': 'Deleting...',
|
|
@@ -3780,7 +6667,8 @@ window.__ModuleLoader__.load({
|
|
|
3780
6667
|
'error.invalid': 'Invalid request; refresh and try again.',
|
|
3781
6668
|
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
3782
6669
|
'error.session-not-found': 'No session log was found for this id.',
|
|
3783
|
-
'error.busy': 'This session is
|
|
6670
|
+
'error.busy': 'This session is working and cannot be stopped right now (a compaction may be running); try again in a moment.',
|
|
6671
|
+
'error.stop-timeout': 'The running turn did not stop in time; try again in a moment.',
|
|
3784
6672
|
'error.already-deleted': 'This content is already removed from the context.',
|
|
3785
6673
|
'error.not-deletable': 'This position cannot be deleted.',
|
|
3786
6674
|
'error.range-not-clean': 'The target window contains unrelated content; the delete was cancelled.',
|
|
@@ -3820,6 +6708,7 @@ window.__ModuleLoader__.load({
|
|
|
3820
6708
|
'[data-dshdt-no-target="1"] .dshdt-action{display:none!important}',
|
|
3821
6709
|
'.dshdt-dialog-text{margin:0;color:var(--dsw-alias-label-primary,inherit);font-size:14px;line-height:22px}',
|
|
3822
6710
|
'.dshdt-dialog-note{margin:10px 0 0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
|
|
6711
|
+
'.dshdt-dialog-warn{margin:10px 0 0;color:var(--dsw-alias-state-warn-primary,#a15c00);font-size:13px;line-height:20px}',
|
|
3823
6712
|
'.dshdt-dialog-error{margin:10px 0 0;color:var(--dsw-alias-state-error-primary,#d54941);font-size:13px;line-height:20px}',
|
|
3824
6713
|
'.dshdt-config{display:flex;flex-direction:column;gap:8px}',
|
|
3825
6714
|
'.dshdt-config-hint{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
|
|
@@ -3905,11 +6794,17 @@ window.__ModuleLoader__.load({
|
|
|
3905
6794
|
markerTurns: new Set(),
|
|
3906
6795
|
surfaceReady: false,
|
|
3907
6796
|
surfaceThrough: -1,
|
|
6797
|
+
// The host reports a running turn (or a compaction) so the dialog can
|
|
6798
|
+
// say that a delete will stop the generation first.
|
|
6799
|
+
busy: false,
|
|
3908
6800
|
loaded: false,
|
|
3909
6801
|
loadError: false,
|
|
3910
6802
|
dialog: null,
|
|
3911
6803
|
pending: false,
|
|
3912
6804
|
failure: null,
|
|
6805
|
+
// How many unrelated nodes the last attempt reported it would also
|
|
6806
|
+
// remove (the forced-confirmation prompt).
|
|
6807
|
+
foreignCount: 0,
|
|
3913
6808
|
revision: 0,
|
|
3914
6809
|
})
|
|
3915
6810
|
}
|
|
@@ -3979,7 +6874,7 @@ window.__ModuleLoader__.load({
|
|
|
3979
6874
|
if (typeof turn === 'number') markerTurns.add(turn)
|
|
3980
6875
|
}
|
|
3981
6876
|
const surfaceThrough = typeof data.lastSeq === 'number' ? data.lastSeq : -1
|
|
3982
|
-
this.publish({ hidden, hiddenVia, surface, turnSeqs, replyTurns, edits, markerTurns, surfaceReady: true, surfaceThrough, loaded: true, loadError: false })
|
|
6877
|
+
this.publish({ hidden, hiddenVia, surface, turnSeqs, replyTurns, edits, markerTurns, busy: data.busy === true, surfaceReady: true, surfaceThrough, loaded: true, loadError: false })
|
|
3983
6878
|
})
|
|
3984
6879
|
.catch(() => {
|
|
3985
6880
|
this.publish({ loadError: true })
|
|
@@ -3998,11 +6893,14 @@ window.__ModuleLoader__.load({
|
|
|
3998
6893
|
|
|
3999
6894
|
close() {
|
|
4000
6895
|
if (this.view.pending) return
|
|
4001
|
-
this.publish({ dialog: null, failure: null })
|
|
6896
|
+
this.publish({ dialog: null, failure: null, foreignCount: 0 })
|
|
4002
6897
|
}
|
|
4003
6898
|
|
|
4004
|
-
|
|
4005
|
-
|
|
6899
|
+
// `force` re-submits after the "the window also swallows N unrelated
|
|
6900
|
+
// nodes" report, which is the only refusal the dialog turns into a second
|
|
6901
|
+
// question instead of a dead end.
|
|
6902
|
+
async confirm(options) {
|
|
6903
|
+
await this.perform(this.view.dialog, { force: options && options.force === true })
|
|
4006
6904
|
}
|
|
4007
6905
|
|
|
4008
6906
|
// The entry every action uses. With "ask" on, it opens the confirmation
|
|
@@ -4019,9 +6917,10 @@ window.__ModuleLoader__.load({
|
|
|
4019
6917
|
if (result.failure !== null) this.publish({ dialog: target, pending: false, failure: result.failure })
|
|
4020
6918
|
}
|
|
4021
6919
|
|
|
4022
|
-
async perform(requested) {
|
|
6920
|
+
async perform(requested, options) {
|
|
4023
6921
|
if (requested === null || this.view.pending) return { failure: null }
|
|
4024
|
-
|
|
6922
|
+
const force = options !== undefined && options.force === true
|
|
6923
|
+
this.publish({ pending: true, failure: null, foreignCount: 0 })
|
|
4025
6924
|
try {
|
|
4026
6925
|
// The live node can move while the dialog sits open: another producer
|
|
4027
6926
|
// (dsh-edit-turn) rewrites the message in place, and the replacement
|
|
@@ -4030,17 +6929,18 @@ window.__ModuleLoader__.load({
|
|
|
4030
6929
|
// first attempt reports content that has already left the context.
|
|
4031
6930
|
await this.load(true)
|
|
4032
6931
|
let target = this.resolveTarget(requested)
|
|
4033
|
-
let result = await this.deleteOnce(target)
|
|
6932
|
+
let result = await this.deleteOnce(target, force)
|
|
4034
6933
|
if (!result.ok && result.code === 'already-deleted') {
|
|
4035
6934
|
await this.load(true)
|
|
4036
6935
|
const next = this.resolveTarget(target)
|
|
4037
6936
|
if (next.seq !== target.seq) {
|
|
4038
6937
|
target = next
|
|
4039
|
-
result = await this.deleteOnce(next)
|
|
6938
|
+
result = await this.deleteOnce(next, force)
|
|
4040
6939
|
}
|
|
4041
6940
|
}
|
|
4042
6941
|
if (!result.ok) {
|
|
4043
|
-
|
|
6942
|
+
const foreignCount = result.details && typeof result.details.foreign === 'number' ? result.details.foreign : 0
|
|
6943
|
+
this.publish({ pending: false, failure: result.code, foreignCount })
|
|
4044
6944
|
return { failure: result.code }
|
|
4045
6945
|
}
|
|
4046
6946
|
const data = result.data
|
|
@@ -4068,15 +6968,19 @@ window.__ModuleLoader__.load({
|
|
|
4068
6968
|
return seq === target.seq ? target : { ...target, seq }
|
|
4069
6969
|
}
|
|
4070
6970
|
|
|
4071
|
-
async deleteOnce(target) {
|
|
6971
|
+
async deleteOnce(target, force) {
|
|
4072
6972
|
const res = await fetch(`${ROUTE_PREFIX}/delete`, {
|
|
4073
6973
|
method: 'POST',
|
|
4074
6974
|
headers: { 'content-type': 'application/json' },
|
|
4075
|
-
body: JSON.stringify({ sessionId: this.sessionId, ...target }),
|
|
6975
|
+
body: JSON.stringify({ sessionId: this.sessionId, ...target, ...(force === true ? { force: true } : {}) }),
|
|
4076
6976
|
})
|
|
4077
6977
|
const data = await res.json().catch(() => ({}))
|
|
4078
6978
|
if (res.ok && data.ok) return { ok: true, data }
|
|
4079
|
-
return {
|
|
6979
|
+
return {
|
|
6980
|
+
ok: false,
|
|
6981
|
+
code: data && data.code ? String(data.code) : 'generic',
|
|
6982
|
+
details: data && data.details ? data.details : undefined,
|
|
6983
|
+
}
|
|
4080
6984
|
}
|
|
4081
6985
|
|
|
4082
6986
|
dispose() {
|
|
@@ -4511,11 +7415,38 @@ window.__ModuleLoader__.load({
|
|
|
4511
7415
|
return Boolean(closing && closing.finalNode && closing.finalNode.messageId !== undefined)
|
|
4512
7416
|
}
|
|
4513
7417
|
|
|
7418
|
+
// Node kinds that carry visible conversation content, and the turn-level
|
|
7419
|
+
// disclosure rows that must go with them. A failure card ("处理失败"), a
|
|
7420
|
+
// process disclosure ("已思考", "用时 N 秒"), a retry notice or a turn tail is
|
|
7421
|
+
// drawn from the TURN, not from a surface node, so shadowing the nodes a
|
|
7422
|
+
// delete aimed at leaves it behind - a deleted failed turn keeps an orphaned
|
|
7423
|
+
// card. A turn is gone once none of its content rows is visible, and its
|
|
7424
|
+
// disclosure rows leave with it.
|
|
7425
|
+
const CONTENT_KINDS = new Set(['user', 'context', 'steering', 'tool-call', 'assistant-step'])
|
|
7426
|
+
const DISCLOSURE_KINDS = new Set(['turn-error', 'model-retry', 'turn-tail', 'turn-process'])
|
|
7427
|
+
|
|
4514
7428
|
function applyDom(snapshot, view, controller, t) {
|
|
4515
7429
|
if (!snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') return
|
|
4516
7430
|
const animate = controller.consumeAnimate()
|
|
4517
7431
|
const rows = document.querySelectorAll('[data-chat-flow-key]')
|
|
4518
7432
|
let maxSeq = -1
|
|
7433
|
+
|
|
7434
|
+
// Turns that still have a visible content row. Computed from the ledger
|
|
7435
|
+
// (not from DOM heights) so it is stable across the pass.
|
|
7436
|
+
const contentTurns = new Set()
|
|
7437
|
+
for (const row of rows) {
|
|
7438
|
+
const key = row.getAttribute('data-chat-flow-key')
|
|
7439
|
+
if (!key) continue
|
|
7440
|
+
const node = snapshot.nodes.get(key)
|
|
7441
|
+
if (!node || !CONTENT_KINDS.has(node.kind)) continue
|
|
7442
|
+
const turn = row.getAttribute('data-chat-turn')
|
|
7443
|
+
if (turn === null || turn === '') continue
|
|
7444
|
+
if (row.dataset.dshdtHidden === '1' || foreignHideOn(row, 'dshdt')) continue
|
|
7445
|
+
const seqs = expandSeqs(seqsFor(node), view.edits)
|
|
7446
|
+
if (isRowHidden(view.hidden, view.hiddenVia, seqs)) continue
|
|
7447
|
+
contentTurns.add(turn)
|
|
7448
|
+
}
|
|
7449
|
+
|
|
4519
7450
|
for (const row of rows) {
|
|
4520
7451
|
if (!(row instanceof HTMLElement)) continue
|
|
4521
7452
|
const key = row.getAttribute('data-chat-flow-key')
|
|
@@ -4545,7 +7476,10 @@ window.__ModuleLoader__.load({
|
|
|
4545
7476
|
}
|
|
4546
7477
|
const seqs = expandSeqs(seqsFor(node), view.edits)
|
|
4547
7478
|
for (const seq of seqs) if (typeof seq === 'number' && seq > maxSeq) maxSeq = seq
|
|
4548
|
-
|
|
7479
|
+
// A disclosure row follows its turn: with no visible content row left,
|
|
7480
|
+
// it is an orphan and leaves too.
|
|
7481
|
+
const orphaned = DISCLOSURE_KINDS.has(node.kind) && turn !== null && turn !== '' && !contentTurns.has(turn)
|
|
7482
|
+
const hidden = orphaned || isRowHidden(view.hidden, view.hiddenVia, seqs)
|
|
4549
7483
|
setRowHidden(row, hidden, animate)
|
|
4550
7484
|
const target = hidden ? null : liveTarget(targetFor(node), view.edits)
|
|
4551
7485
|
const covered = target !== null || slotCoversRow(node)
|
|
@@ -4579,41 +7513,66 @@ window.__ModuleLoader__.load({
|
|
|
4579
7513
|
updateTurnNavigation()
|
|
4580
7514
|
}
|
|
4581
7515
|
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
7516
|
+
/** The turn a rail mark addresses: its label carries the real turn number. */
|
|
7517
|
+
function turnOfRailMark(mark) {
|
|
7518
|
+
const label = mark.getAttribute('aria-label') || mark.getAttribute('title') || ''
|
|
7519
|
+
const match = /(\d+)/.exec(label)
|
|
7520
|
+
return match === null ? null : Number(match[1])
|
|
7521
|
+
}
|
|
7522
|
+
|
|
7523
|
+
/**
|
|
7524
|
+
* Every turn with a row in the DOM, mapped to whether any row is visible. A
|
|
7525
|
+
* turn with no row at all is absent: not empty, just not loaded.
|
|
7526
|
+
*/
|
|
7527
|
+
function turnRowVisibility() {
|
|
4590
7528
|
const seen = new Map()
|
|
4591
7529
|
for (const row of document.querySelectorAll('[data-chat-flow-key][data-chat-turn]')) {
|
|
4592
|
-
const turn = row.getAttribute('data-chat-turn')
|
|
4593
|
-
if (turn
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
7530
|
+
const turn = Number(row.getAttribute('data-chat-turn'))
|
|
7531
|
+
if (!Number.isInteger(turn)) continue
|
|
7532
|
+
const hidden =
|
|
7533
|
+
row.dataset.dshdtHidden === '1' ||
|
|
7534
|
+
foreignHideOn(row, 'dshdt') ||
|
|
7535
|
+
(row.style !== undefined && row.style !== null && row.style.display === 'none') ||
|
|
7536
|
+
(typeof row.getBoundingClientRect === 'function' && row.getBoundingClientRect().height === 0)
|
|
7537
|
+
if (seen.get(turn) !== true) seen.set(turn, hidden !== true)
|
|
7538
|
+
}
|
|
7539
|
+
return seen
|
|
7540
|
+
}
|
|
7541
|
+
|
|
7542
|
+
/**
|
|
7543
|
+
* The official rail keeps one mark per turn of the session, including the
|
|
7544
|
+
* turns whose rows are gone, so deleting a turn left a mark pointing at
|
|
7545
|
+
* nothing and an empty slot. Drop the marks of turns with no visible row and
|
|
7546
|
+
* re-space the rest inside the host's own ladder, centred; a turn that is
|
|
7547
|
+
* merely not loaded keeps its mark. The container keeps the host's height:
|
|
7548
|
+
* that height is what the host's virtualisation measures, so shrinking it
|
|
7549
|
+
* unmounts the marks past the new window.
|
|
7550
|
+
*/
|
|
7551
|
+
function updateTurnNavigation() {
|
|
7552
|
+
const rows = turnRowVisibility()
|
|
7553
|
+
const hiddenTurns = []
|
|
7554
|
+
for (const [turn, visible] of rows) if (visible !== true) hiddenTurns.push(turn)
|
|
7555
|
+
for (const nav of document.querySelectorAll('nav')) {
|
|
7556
|
+
const marks = [...nav.querySelectorAll('button[data-index]')]
|
|
7557
|
+
if (marks.length === 0) continue
|
|
7558
|
+
const container = marks[0].parentElement
|
|
7559
|
+
if (container === null || container === undefined) continue
|
|
7560
|
+
const observed = Math.round((Number.parseFloat(container.style.height) - 2) / 10)
|
|
7561
|
+
const total = Math.max(Number.isFinite(observed) && observed > 0 ? observed : 0, marks.length)
|
|
7562
|
+
const kept = total - hiddenTurns.filter((turn) => turn <= total).length
|
|
7563
|
+
const offset = kept > 0 && kept < total ? Math.floor((total - kept) / 2) : 0
|
|
7564
|
+
for (const mark of marks) {
|
|
7565
|
+
const turn = turnOfRailMark(mark)
|
|
7566
|
+
if (turn === null) continue
|
|
7567
|
+
if (hiddenTurns.includes(turn)) {
|
|
7568
|
+
mark.dataset.dshdtHidden = '1'
|
|
7569
|
+
continue
|
|
7570
|
+
}
|
|
4606
7571
|
delete mark.dataset.dshdtHidden
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
mark.dataset.dshdtHidden = '1'
|
|
7572
|
+
const before = hiddenTurns.filter((hidden) => hidden < turn).length
|
|
7573
|
+
mark.style.transform = `translate3d(0px, ${(turn - 1 - before + offset) * 10 + 1}px, 0px)`
|
|
4610
7574
|
}
|
|
4611
7575
|
}
|
|
4612
|
-
const nav = marks[0].closest('nav')
|
|
4613
|
-
if (nav !== null) {
|
|
4614
|
-
if (visibleMarks === 0) nav.dataset.dshdtHidden = '1'
|
|
4615
|
-
else delete nav.dataset.dshdtHidden
|
|
4616
|
-
}
|
|
4617
7576
|
}
|
|
4618
7577
|
|
|
4619
7578
|
// --- react entries --------------------------------------------------------
|
|
@@ -4636,6 +7595,10 @@ window.__ModuleLoader__.load({
|
|
|
4636
7595
|
const target = view.dialog
|
|
4637
7596
|
const mode = target && typeof target.mode === 'string' ? target.mode : 'message'
|
|
4638
7597
|
const failure = view.failure
|
|
7598
|
+
// The one refusal that is a question rather than a dead end: the window
|
|
7599
|
+
// also swallows N unrelated nodes. The button becomes an explicit
|
|
7600
|
+
// "delete them too", and confirming re-submits with `force`.
|
|
7601
|
+
const forced = failure === 'range-not-clean'
|
|
4639
7602
|
return jsx(primitives.Modal, {
|
|
4640
7603
|
open: target !== null,
|
|
4641
7604
|
title: t(`dialog.title.${mode}`),
|
|
@@ -4655,8 +7618,14 @@ window.__ModuleLoader__.load({
|
|
|
4655
7618
|
size: 'md',
|
|
4656
7619
|
className: 'dshdt-danger',
|
|
4657
7620
|
disabled: view.pending,
|
|
4658
|
-
onClick: () => controller.confirm(),
|
|
4659
|
-
children: view.pending
|
|
7621
|
+
onClick: () => controller.confirm(forced ? { force: true } : undefined),
|
|
7622
|
+
children: view.pending
|
|
7623
|
+
? t('dialog.pending')
|
|
7624
|
+
: forced
|
|
7625
|
+
? t('dialog.confirmExtra')
|
|
7626
|
+
: failure
|
|
7627
|
+
? t('dialog.retry')
|
|
7628
|
+
: t('dialog.confirm'),
|
|
4660
7629
|
}),
|
|
4661
7630
|
],
|
|
4662
7631
|
}),
|
|
@@ -4664,7 +7633,14 @@ window.__ModuleLoader__.load({
|
|
|
4664
7633
|
children: [
|
|
4665
7634
|
jsx('p', { className: 'dshdt-dialog-text', children: t(`dialog.desc.${mode}`) }),
|
|
4666
7635
|
jsx('p', { className: 'dshdt-dialog-note', children: t('dialog.note') }),
|
|
4667
|
-
|
|
7636
|
+
view.busy ? jsx('p', { className: 'dshdt-dialog-warn', children: t('dialog.busyWarning') }) : null,
|
|
7637
|
+
forced
|
|
7638
|
+
? jsx('p', {
|
|
7639
|
+
className: 'dshdt-dialog-warn',
|
|
7640
|
+
children: t('dialog.extraWarning') + (view.foreignCount > 0 ? ' ×' + String(view.foreignCount) : ''),
|
|
7641
|
+
})
|
|
7642
|
+
: null,
|
|
7643
|
+
failure === null || forced
|
|
4668
7644
|
? null
|
|
4669
7645
|
: jsx('p', { className: 'dshdt-dialog-error', role: 'status', children: t(`error.${failure}`) }),
|
|
4670
7646
|
],
|
|
@@ -4805,6 +7781,10 @@ window.__ModuleLoader__.load({
|
|
|
4805
7781
|
}
|
|
4806
7782
|
|
|
4807
7783
|
exports.apply = apply
|
|
7784
|
+
// The settings card, exported so a composing bundle (dsh-as-aistudio) can
|
|
7785
|
+
// render THE SAME component on its own page instead of re-implementing it:
|
|
7786
|
+
// one source of truth for 删除 settings.
|
|
7787
|
+
exports.ConfigCard = AskBeforeDeleteOption
|
|
4808
7788
|
exports.inject = ['slots', 'locale']
|
|
4809
7789
|
return module.exports
|
|
4810
7790
|
}
|
|
@@ -4822,7 +7802,7 @@ window.__ModuleLoader__.load({
|
|
|
4822
7802
|
const NS = 'dsh-markdown-bubble'
|
|
4823
7803
|
|
|
4824
7804
|
/** Keep in sync with package.json and src/index.js. */
|
|
4825
|
-
const PLUGIN_VERSION = '0.1.
|
|
7805
|
+
const PLUGIN_VERSION = '0.1.5'
|
|
4826
7806
|
|
|
4827
7807
|
/** Presence marker the live verifier reads. */
|
|
4828
7808
|
const DEBUG_KEY = '__DSH_MARKDOWN_BUBBLE__'
|
|
@@ -4962,7 +7942,27 @@ window.__ModuleLoader__.load({
|
|
|
4962
7942
|
* @param text - raw logged message text.
|
|
4963
7943
|
* @returns markdown source for the renderer.
|
|
4964
7944
|
*/
|
|
7945
|
+
// Prepared markdown source, keyed by the raw text. A bubble repaints on
|
|
7946
|
+
// every transcript update (a streaming turn re-renders constantly) and the
|
|
7947
|
+
// preparation is a split/join plus a regex per line, so the same message was
|
|
7948
|
+
// re-walked dozens of times while an unrelated row streamed. Cleared whole
|
|
7949
|
+
// when it grows past the cap: the entries are strings, and a long transcript
|
|
7950
|
+
// has far fewer distinct message texts than it has repaints.
|
|
7951
|
+
const markdownSourceCache = new Map()
|
|
7952
|
+
const MARKDOWN_CACHE_LIMIT = 64
|
|
7953
|
+
|
|
4965
7954
|
function toMarkdown(text) {
|
|
7955
|
+
const key = typeof text === 'string' ? text : ''
|
|
7956
|
+
const cached = markdownSourceCache.get(key)
|
|
7957
|
+
if (cached !== undefined) return cached
|
|
7958
|
+
const source = prepareMarkdown(key)
|
|
7959
|
+
if (markdownSourceCache.size >= MARKDOWN_CACHE_LIMIT) markdownSourceCache.clear()
|
|
7960
|
+
markdownSourceCache.set(key, source)
|
|
7961
|
+
return source
|
|
7962
|
+
}
|
|
7963
|
+
|
|
7964
|
+
/** The uncached preparation: CRLF, session wire links, soft breaks. */
|
|
7965
|
+
function prepareMarkdown(text) {
|
|
4966
7966
|
const normalized = text.replace(/\r\n?/gu, '\n')
|
|
4967
7967
|
return walkOutsideFences(normalized, (line, next) => hardBreakLine(line.replace(SESSION_WIRE_RE, '$1'), next))
|
|
4968
7968
|
}
|
|
@@ -5040,12 +8040,24 @@ window.__ModuleLoader__.load({
|
|
|
5040
8040
|
* @param text - the message text.
|
|
5041
8041
|
* @param referenceLabels - session recall labels.
|
|
5042
8042
|
* @param skillNames - skill names.
|
|
5043
|
-
* @param
|
|
8043
|
+
* @param renderFactorySlot - the seat's Factory binding (chips + plain runs).
|
|
5044
8044
|
* @returns the degraded body, never null and never a throw.
|
|
5045
8045
|
*/
|
|
5046
|
-
function degradedBody(text, referenceLabels, skillNames,
|
|
8046
|
+
function degradedBody(text, referenceLabels, skillNames, renderFactorySlot) {
|
|
5047
8047
|
try {
|
|
5048
|
-
|
|
8048
|
+
if (typeof renderFactorySlot !== 'function') throw new Error('the seat handed over no renderFactorySlot')
|
|
8049
|
+
// The projection moved into the host's own Factory in 0.2.1-rc.2
|
|
8050
|
+
// (`conversation.user.text`, registered by dsh-client-ui-conversation;
|
|
8051
|
+
// it owns the keyed child slot that renders a reference chip). The old
|
|
8052
|
+
// public primitive `primitives.projectUserText` was REMOVED in the same
|
|
8053
|
+
// release, which is why this call used to throw and chips degraded to
|
|
8054
|
+
// plain text.
|
|
8055
|
+
return renderFactorySlot('conversation.user.text', {
|
|
8056
|
+
text,
|
|
8057
|
+
facts: { sessionLabels: referenceLabels, skillNames },
|
|
8058
|
+
interactive: true,
|
|
8059
|
+
ellipsis: false,
|
|
8060
|
+
})
|
|
5049
8061
|
} catch (error) {
|
|
5050
8062
|
warnOnce('the host user-text projection failed; showing raw text', error)
|
|
5051
8063
|
return h('span', { className: 'dshmb-plain' }, text)
|
|
@@ -5069,13 +8081,12 @@ window.__ModuleLoader__.load({
|
|
|
5069
8081
|
* @param t - the injected chat translator.
|
|
5070
8082
|
* @param referenceLabels - session recall labels.
|
|
5071
8083
|
* @param skillNames - skill names.
|
|
5072
|
-
* @param
|
|
8084
|
+
* @param renderFactorySlot - the seat's Factory binding.
|
|
5073
8085
|
* @returns the composed body, or null when markdown cannot be produced.
|
|
5074
8086
|
*/
|
|
5075
|
-
function safeMarkdownBody(text, t, referenceLabels, skillNames,
|
|
8087
|
+
function safeMarkdownBody(text, t, referenceLabels, skillNames, renderFactorySlot, labels) {
|
|
5076
8088
|
try {
|
|
5077
|
-
|
|
5078
|
-
return composeUserMarkdown(text, labels, referenceLabels, skillNames, references)
|
|
8089
|
+
return composeUserMarkdown(text, labels, referenceLabels, skillNames, renderFactorySlot)
|
|
5079
8090
|
} catch (error) {
|
|
5080
8091
|
warnOnce('markdown composition failed; showing the degraded projection', error)
|
|
5081
8092
|
return null
|
|
@@ -5114,14 +8125,14 @@ window.__ModuleLoader__.load({
|
|
|
5114
8125
|
* @param labels - markdown chrome labels.
|
|
5115
8126
|
* @param referenceLabels - session recall labels the host associates with the message.
|
|
5116
8127
|
* @param skillNames - skill names the host loaded for the message.
|
|
5117
|
-
* @param
|
|
8128
|
+
* @param renderFactorySlot - the seat's Factory binding.
|
|
5118
8129
|
* @returns the composed body, or null for empty text.
|
|
5119
8130
|
*/
|
|
5120
|
-
function composeUserMarkdown(text, labels, referenceLabels, skillNames,
|
|
8131
|
+
function composeUserMarkdown(text, labels, referenceLabels, skillNames, renderFactorySlot) {
|
|
5121
8132
|
if (typeof text !== 'string' || text === '') return null
|
|
5122
8133
|
// The same single degraded projection, so a projection that throws inside
|
|
5123
8134
|
// composition degrades exactly as one that throws on the healthy path.
|
|
5124
|
-
const projected = degradedBody(text, referenceLabels, skillNames,
|
|
8135
|
+
const projected = degradedBody(text, referenceLabels, skillNames, renderFactorySlot)
|
|
5125
8136
|
const pieces =
|
|
5126
8137
|
react.isValidElement(projected) && projected.type === Fragment && Array.isArray(projected.props.children)
|
|
5127
8138
|
? projected.props.children
|
|
@@ -5298,7 +8309,12 @@ window.__ModuleLoader__.load({
|
|
|
5298
8309
|
}
|
|
5299
8310
|
|
|
5300
8311
|
render() {
|
|
5301
|
-
|
|
8312
|
+
if (!this.state.failed) return this.props.children
|
|
8313
|
+
const { fallback } = this.props
|
|
8314
|
+
// A thunk is called only when it is actually needed: building the
|
|
8315
|
+
// degraded projection on every healthy render is wasted work (and, on a
|
|
8316
|
+
// host whose projection is gone, a caught throw every time).
|
|
8317
|
+
return typeof fallback === 'function' ? fallback() : fallback
|
|
5302
8318
|
}
|
|
5303
8319
|
}
|
|
5304
8320
|
|
|
@@ -5388,9 +8404,24 @@ window.__ModuleLoader__.load({
|
|
|
5388
8404
|
}
|
|
5389
8405
|
}
|
|
5390
8406
|
|
|
5391
|
-
function renderAttachmentEntry({ attachment,
|
|
8407
|
+
function renderAttachmentEntry({ attachment, renderFactorySlot, loadImage, compactImages }) {
|
|
5392
8408
|
if (attachment.type === 'image') {
|
|
5393
|
-
|
|
8409
|
+
// The host's image gallery, through the SAME Factory seam as the text
|
|
8410
|
+
// projection. The three routes that look obvious are all closed on
|
|
8411
|
+
// 0.2.1-rc.2: the `renderMessageImages` seat prop is gone, the child
|
|
8412
|
+
// slot `conversation.chat.user.images` cannot be declared by a plugin
|
|
8413
|
+
// ("already declared (by factory conversation.chat.user.content)",
|
|
8414
|
+
// verified live), and `@deepseek-ai/dsh-client-ui-attachment` exports
|
|
8415
|
+
// only apply/inject. The gallery Factory is the one that works: the
|
|
8416
|
+
// attachment plugin registers `conversation.images.content` at root
|
|
8417
|
+
// scope and its own `MessageImages` is nothing but a call to it.
|
|
8418
|
+
if (typeof renderFactorySlot !== 'function') throw new Error('the seat handed over no renderFactorySlot')
|
|
8419
|
+
return h(Fragment, null, renderFactorySlot('conversation.images.content', {
|
|
8420
|
+
images: [attachment.image],
|
|
8421
|
+
loadImage,
|
|
8422
|
+
align: 'end',
|
|
8423
|
+
compact: compactImages,
|
|
8424
|
+
}))
|
|
5394
8425
|
}
|
|
5395
8426
|
const file = attachment.file ?? {}
|
|
5396
8427
|
const name = typeof file.name === 'string' ? file.name : ''
|
|
@@ -5441,23 +8472,31 @@ window.__ModuleLoader__.load({
|
|
|
5441
8472
|
function renderMarkdownBubble(props) {
|
|
5442
8473
|
const safeProps = props !== null && typeof props === 'object' ? props : {}
|
|
5443
8474
|
const node = safeProps.node !== null && typeof safeProps.node === 'object' ? safeProps.node : {}
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
8475
|
+
// The seat's props on 0.2.1-rc.2: the two renderer bindings and the image
|
|
8476
|
+
// loader. The old `renderMessageImages` / `openFile` / `openSkill` pair is
|
|
8477
|
+
// gone; images now go through the declared child slot and reference chips
|
|
8478
|
+
// through the `conversation.user.text` Factory.
|
|
8479
|
+
const renderFactorySlot = safeProps.renderFactorySlot
|
|
8480
|
+
const renderSlot = safeProps.renderSlot
|
|
8481
|
+
const loadImage = safeProps.loadImage
|
|
5447
8482
|
const t = safeProps.t
|
|
5448
8483
|
const data = node.data !== null && typeof node.data === 'object' ? node.data : {}
|
|
5449
8484
|
const { text, attachments, rest } = contentParts(Array.isArray(data.content) ? data.content : [])
|
|
5450
8485
|
const referenceLabels = Array.isArray(data.referenceLabels) ? data.referenceLabels : []
|
|
5451
8486
|
const skillNames = Array.isArray(data.skillNames) ? data.skillNames : []
|
|
5452
|
-
const references = react.useMemo(() => ({ openFile, openSkill }), [openFile, openSkill])
|
|
5453
8487
|
const compactImages = attachments.length > 1
|
|
5454
8488
|
const showBubble = text !== '' || rest.length > 0
|
|
8489
|
+
// The host memoizes its own label object before handing it to MarkdownText
|
|
8490
|
+
// (`ReasoningContent` does exactly this); a fresh object every render
|
|
8491
|
+
// defeats that memoization and re-renders the whole markdown tree.
|
|
8492
|
+
const labels = react.useMemo(() => buildMarkdownLabels(t), [t])
|
|
5455
8493
|
|
|
5456
8494
|
// Never let composition throw out of this render — see safeMarkdownBody.
|
|
5457
|
-
//
|
|
5458
|
-
// boundary's
|
|
5459
|
-
|
|
5460
|
-
const
|
|
8495
|
+
// ONE degraded body per render: it is the composition guard's projection
|
|
8496
|
+
// and the boundary's fallback, so building it eagerly (as this used to)
|
|
8497
|
+
// ran the projection twice per render and, on 0.2.1-rc.2, threw twice.
|
|
8498
|
+
const body = safeMarkdownBody(text, t, referenceLabels, skillNames, renderFactorySlot, labels)
|
|
8499
|
+
const degraded = () => degradedBody(text, referenceLabels, skillNames, renderFactorySlot)
|
|
5461
8500
|
|
|
5462
8501
|
// The action strip leads the row in DOM order and is pushed below the
|
|
5463
8502
|
// bubble by flex `order`. Sibling plugins pick their bar with
|
|
@@ -5481,7 +8520,8 @@ window.__ModuleLoader__.load({
|
|
|
5481
8520
|
h(AttachmentEntry, {
|
|
5482
8521
|
key: `${attachment.type}:${index}`,
|
|
5483
8522
|
attachment,
|
|
5484
|
-
|
|
8523
|
+
renderFactorySlot,
|
|
8524
|
+
loadImage,
|
|
5485
8525
|
compactImages,
|
|
5486
8526
|
}),
|
|
5487
8527
|
),
|
|
@@ -5490,7 +8530,7 @@ window.__ModuleLoader__.load({
|
|
|
5490
8530
|
h(
|
|
5491
8531
|
'div',
|
|
5492
8532
|
{ className: 'dshmb-bubble' },
|
|
5493
|
-
h(MarkdownBoundary, { key: text, fallback: degraded }, body === null ? degraded : body),
|
|
8533
|
+
h(MarkdownBoundary, { key: text, fallback: degraded }, body === null ? degraded() : body),
|
|
5494
8534
|
rest.map((block, index) =>
|
|
5495
8535
|
h(primitives.JsonBlock, {
|
|
5496
8536
|
key: 'rest' + index,
|
|
@@ -5535,7 +8575,7 @@ window.__ModuleLoader__.load({
|
|
|
5535
8575
|
text,
|
|
5536
8576
|
Array.isArray(data.referenceLabels) ? data.referenceLabels : [],
|
|
5537
8577
|
Array.isArray(data.skillNames) ? data.skillNames : [],
|
|
5538
|
-
|
|
8578
|
+
safeProps.renderFactorySlot,
|
|
5539
8579
|
)
|
|
5540
8580
|
return h(
|
|
5541
8581
|
'div',
|
|
@@ -5692,7 +8732,7 @@ window.__ModuleLoader__.load({
|
|
|
5692
8732
|
const NS = 'dsh-as-aistudio'
|
|
5693
8733
|
|
|
5694
8734
|
/** Keep in sync with package.json and src/components.js. */
|
|
5695
|
-
const PLUGIN_VERSION = '0.
|
|
8735
|
+
const PLUGIN_VERSION = '0.3.0'
|
|
5696
8736
|
|
|
5697
8737
|
/** Presence marker the live verifier reads. */
|
|
5698
8738
|
const DEBUG_KEY = '__DSH_AS_AISTUDIO__'
|
|
@@ -5706,6 +8746,20 @@ window.__ModuleLoader__.load({
|
|
|
5706
8746
|
/** The four vendored factory names, in mount order. */
|
|
5707
8747
|
const VENDORED = ['dsh-edit-turn', 'dsh-rerun-turn', 'dsh-delete-turn', 'dsh-markdown-bubble']
|
|
5708
8748
|
|
|
8749
|
+
/**
|
|
8750
|
+
* Which components export a settings card, and under what name.
|
|
8751
|
+
*
|
|
8752
|
+
* The components own their settings; this package only has to show them. A
|
|
8753
|
+
* component exports its card (`exports.ConfigCard`) and the studio renders
|
|
8754
|
+
* THAT component - never a copy - so the two surfaces cannot drift. A
|
|
8755
|
+
* component with no settings simply is not in this table.
|
|
8756
|
+
*/
|
|
8757
|
+
const CONFIG_CARDS = [
|
|
8758
|
+
{ id: 'dsh-edit-turn', title: { zh: '编辑与重跑', en: 'Edit and re-run' } },
|
|
8759
|
+
{ id: 'dsh-rerun-turn', title: { zh: '重跑', en: 'Re-run' } },
|
|
8760
|
+
{ id: 'dsh-delete-turn', title: { zh: '删除', en: 'Delete' } },
|
|
8761
|
+
]
|
|
8762
|
+
|
|
5709
8763
|
const h = react.createElement
|
|
5710
8764
|
|
|
5711
8765
|
// --- copy ------------------------------------------------------------------
|
|
@@ -5733,6 +8787,8 @@ window.__ModuleLoader__.load({
|
|
|
5733
8787
|
host: '宿主 DSH {version}',
|
|
5734
8788
|
hostUnknown: '宿主版本未知',
|
|
5735
8789
|
note: '四个组件由这个包的四个补丁行挂载(dsh-as-aistudio/edit 等),插件列表里仍然只有 AI Studio 一行;它们仍是独立 npm 包,单独安装时自己出现一行。宿主未挂载的组件不会画到界面上。',
|
|
8790
|
+
summaryShort: '改词重跑 · 原位重跑 · 删这一轮 · 提示词渲染 Markdown',
|
|
8791
|
+
settingsHeading: '组件设置',
|
|
5736
8792
|
}
|
|
5737
8793
|
|
|
5738
8794
|
const en = {
|
|
@@ -5758,6 +8814,8 @@ window.__ModuleLoader__.load({
|
|
|
5758
8814
|
host: 'host DSH {version}',
|
|
5759
8815
|
hostUnknown: 'host version unknown',
|
|
5760
8816
|
note: 'The four components are mounted by the four patch rows of this package (dsh-as-aistudio/edit and friends), so the plugin list still shows only AI Studio; they remain independent npm packages that show their own row when installed standalone. A component whose host half is not mounted is not drawn at all.',
|
|
8817
|
+
summaryShort: 'revise and re-run, re-run in place, delete the turn, render prompts as Markdown',
|
|
8818
|
+
settingsHeading: 'Component settings',
|
|
5761
8819
|
}
|
|
5762
8820
|
|
|
5763
8821
|
const COMPONENT_COPY = {
|
|
@@ -5829,7 +8887,11 @@ window.__ModuleLoader__.load({
|
|
|
5829
8887
|
'.dsas-error{color:var(--dsw-alias-state-danger-primary,#d94040);font-size:var(--dsh-content-font-size-secondary,13px);margin:0}',
|
|
5830
8888
|
'.dsas-interop{border-top:.5px solid var(--dsw-alias-border-l2,#0000001f);flex-direction:column;gap:6px;display:flex;padding-top:12px}',
|
|
5831
8889
|
'.dsas-interopTitle{color:var(--dsw-alias-label-primary);font-size:var(--dsh-content-font-size-secondary,13px);font-weight:600;margin:0}',
|
|
5832
|
-
|
|
8890
|
+
'.dsas-settings{display:flex;flex-direction:column;gap:10px;margin:18px 0 0;padding-top:14px;border-top:1px solid var(--dsas-line,rgba(128,128,128,.25))}',
|
|
8891
|
+
'.dsas-settingsTitle{margin:0;font-size:13px;font-weight:600;line-height:20px}',
|
|
8892
|
+
'.dsas-settingCard{display:flex;flex-direction:column;gap:8px;padding:12px 14px;border:1px solid var(--dsas-line,rgba(128,128,128,.25));border-radius:10px}',
|
|
8893
|
+
'.dsas-settingName{margin:0;font-size:12.5px;font-weight:600;line-height:18px;opacity:.75}',
|
|
8894
|
+
'.dsas-note{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.7;margin:0}',
|
|
5833
8895
|
].join('')
|
|
5834
8896
|
|
|
5835
8897
|
function injectStyles() {
|
|
@@ -5872,6 +8934,30 @@ window.__ModuleLoader__.load({
|
|
|
5872
8934
|
return typeof factory === 'function' ? factory : null
|
|
5873
8935
|
}
|
|
5874
8936
|
|
|
8937
|
+
/**
|
|
8938
|
+
* The settings card a vendored component exports, or null.
|
|
8939
|
+
*
|
|
8940
|
+
* Read from the component's own module namespace: the factory runs once per
|
|
8941
|
+
* read here (it is cheap - it only builds the module object), and `ConfigCard`
|
|
8942
|
+
* is the component's own component function, so its state, storage keys and
|
|
8943
|
+
* copy are the ones its own page uses.
|
|
8944
|
+
*
|
|
8945
|
+
* @param id - component id.
|
|
8946
|
+
* @returns the component function, or null when it exports none.
|
|
8947
|
+
*/
|
|
8948
|
+
function vendoredConfigCard(id) {
|
|
8949
|
+
try {
|
|
8950
|
+
const factory = vendoredFactory(id)
|
|
8951
|
+
if (factory === null) return null
|
|
8952
|
+
const plugin = factory(require)
|
|
8953
|
+
const card = plugin === null || plugin === undefined ? null : plugin.ConfigCard
|
|
8954
|
+
return typeof card === 'function' ? card : null
|
|
8955
|
+
} catch (error) {
|
|
8956
|
+
console.error('[' + NS + '] vendored ' + id + " 's settings card could not be read:", error)
|
|
8957
|
+
return null
|
|
8958
|
+
}
|
|
8959
|
+
}
|
|
8960
|
+
|
|
5875
8961
|
function applyVendored(ctx, id) {
|
|
5876
8962
|
try {
|
|
5877
8963
|
const factory = vendoredFactory(id)
|
|
@@ -5996,7 +9082,12 @@ window.__ModuleLoader__.load({
|
|
|
5996
9082
|
* The studio tab: one row per component, plus the interop allocation.
|
|
5997
9083
|
* @param props - slot props: t (locale-bound) and currentLocale.
|
|
5998
9084
|
*/
|
|
5999
|
-
function StudioPanel({ t, currentLocale }) {
|
|
9085
|
+
function StudioPanel({ view: slotView, t, currentLocale, bindComponentLocale }) {
|
|
9086
|
+
// `plugins.row.config` asks twice: once for the row's one-liner and once
|
|
9087
|
+
// for the page. The one-liner must not fetch - it renders on every plugin
|
|
9088
|
+
// row paint - so it is the static description of what the four components
|
|
9089
|
+
// do, and the page below carries the live state and the settings.
|
|
9090
|
+
if (slotView === 'summary') return h('span', null, t('summaryShort'))
|
|
6000
9091
|
const [request, setRequest] = react.useState(0)
|
|
6001
9092
|
const [view, setView] = react.useState({ status: 'loading' })
|
|
6002
9093
|
|
|
@@ -6047,6 +9138,15 @@ window.__ModuleLoader__.load({
|
|
|
6047
9138
|
? t('hostUnknown')
|
|
6048
9139
|
: ''
|
|
6049
9140
|
const rows = ready ? view.body.components.map((component) => renderRow(component, copy, t)) : []
|
|
9141
|
+
// The settings each component owns, rendered from the component itself.
|
|
9142
|
+
// Only for components the host reports as up: a card whose host half is off
|
|
9143
|
+
// would write preferences nothing reads.
|
|
9144
|
+
const mountedIds = new Set(
|
|
9145
|
+
ready ? view.body.components.filter((component) => stateOf(component) === 'mounted').map((component) => component.id) : [],
|
|
9146
|
+
)
|
|
9147
|
+
const cards = CONFIG_CARDS.filter((entry) => mountedIds.has(entry.id))
|
|
9148
|
+
.map((entry) => ({ entry, Card: vendoredConfigCard(entry.id) }))
|
|
9149
|
+
.filter((pair) => pair.Card !== null)
|
|
6050
9150
|
return h('div', { className: 'dsas-root' }, [
|
|
6051
9151
|
h('p', { className: 'dsas-intro', key: 'intro' }, t('intro')),
|
|
6052
9152
|
h('div', { className: 'dsas-bar', key: 'bar' }, [
|
|
@@ -6068,6 +9168,22 @@ window.__ModuleLoader__.load({
|
|
|
6068
9168
|
? h('p', { className: 'dsas-error', key: 'error', role: 'status' }, fill(t('error'), { reason: view.reason }))
|
|
6069
9169
|
: null,
|
|
6070
9170
|
ready ? h('ul', { className: 'dsas-list', key: 'list' }, rows) : null,
|
|
9171
|
+
cards.length === 0
|
|
9172
|
+
? null
|
|
9173
|
+
: h('div', { className: 'dsas-settings', key: 'settings' }, [
|
|
9174
|
+
h('p', { className: 'dsas-settingsTitle', key: 'title' }, t('settingsHeading')),
|
|
9175
|
+
...cards.map((pair) =>
|
|
9176
|
+
h('div', { className: 'dsas-settingCard', key: pair.entry.id, 'data-component': pair.entry.id }, [
|
|
9177
|
+
h('p', { className: 'dsas-settingName', key: 'name' }, pair.entry.title[chinese ? 'zh' : 'en']),
|
|
9178
|
+
h(pair.Card, {
|
|
9179
|
+
key: 'card',
|
|
9180
|
+
view: 'page',
|
|
9181
|
+
// The card's own words come from the card's own namespace.
|
|
9182
|
+
t: typeof bindComponentLocale === 'function' ? bindComponentLocale(pair.entry.id) : t,
|
|
9183
|
+
}),
|
|
9184
|
+
]),
|
|
9185
|
+
),
|
|
9186
|
+
]),
|
|
6071
9187
|
h('p', { className: 'dsas-note', key: 'note' }, t('note')),
|
|
6072
9188
|
renderInterop(t),
|
|
6073
9189
|
])
|
|
@@ -6134,6 +9250,24 @@ window.__ModuleLoader__.load({
|
|
|
6134
9250
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-as-aistudio: dictionaries')
|
|
6135
9251
|
|
|
6136
9252
|
const t = ctx.locale.bind(NS)
|
|
9253
|
+
/**
|
|
9254
|
+
* The translator for a COMPONENT's own namespace.
|
|
9255
|
+
*
|
|
9256
|
+
* A component's settings card is written against its own dictionary
|
|
9257
|
+
* (`config.style`, `config.ask`, ...), which its own apply() registers under
|
|
9258
|
+
* its package name. Rendering that card with this package's `t` would spell
|
|
9259
|
+
* the keys out - the card is not ours, so neither are its words.
|
|
9260
|
+
*
|
|
9261
|
+
* @param ns - the component's locale namespace (its package name).
|
|
9262
|
+
* @returns a translator bound to it, or this package's own as a fallback.
|
|
9263
|
+
*/
|
|
9264
|
+
const bindComponentLocale = (ns) => {
|
|
9265
|
+
try {
|
|
9266
|
+
return ctx.locale.bind(ns)
|
|
9267
|
+
} catch {
|
|
9268
|
+
return t
|
|
9269
|
+
}
|
|
9270
|
+
}
|
|
6137
9271
|
const currentLocale = () => {
|
|
6138
9272
|
try {
|
|
6139
9273
|
const snapshot = ctx.locale.getLocale()
|
|
@@ -6145,15 +9279,18 @@ window.__ModuleLoader__.load({
|
|
|
6145
9279
|
}
|
|
6146
9280
|
}
|
|
6147
9281
|
|
|
6148
|
-
|
|
9282
|
+
// The row's configure control, keyed the way the plugin manager dispatches
|
|
9283
|
+
// it: `<package name>#<row id>`. This package's own row is the bare package
|
|
9284
|
+
// name (see cordis.patch.yml), so the key is the package name twice, and the
|
|
9285
|
+
// configure arrow appears on OUR row - the same surface every other plugin
|
|
9286
|
+
// in this profile has, instead of a separate settings tab.
|
|
9287
|
+
ctx.slots.inject('plugins.row.config', () =>
|
|
6149
9288
|
ctx.slots.register(
|
|
6150
9289
|
{
|
|
6151
|
-
name: '
|
|
6152
|
-
|
|
6153
|
-
order: 100,
|
|
6154
|
-
label: () => t('tab'),
|
|
9290
|
+
name: 'plugins.row.config',
|
|
9291
|
+
key: 'dsh-as-aistudio#dsh-as-aistudio',
|
|
6155
9292
|
locale: NS,
|
|
6156
|
-
inject: () => ({ currentLocale }),
|
|
9293
|
+
inject: () => ({ currentLocale, bindComponentLocale }),
|
|
6157
9294
|
},
|
|
6158
9295
|
StudioPanel,
|
|
6159
9296
|
),
|