dsh-edit-turn 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/lib/client.js +425 -78
- package/lib/index.js +60 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -252,8 +252,56 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
|
|
|
252
252
|
|
|
253
253
|
**「点了保存没反应」先看这里**:记录里有没有 `apply`。没有 → 点击丢在浏览器半边(此前的成因是宿主在 mousedown/mouseup 之间重建了行,按钮已改为 pointerdown 激活);有 `apply` 但 `ok:false` → 宿主拒绝了,`code` 就是原因(`busy`/`stale`/...)。
|
|
254
254
|
|
|
255
|
+
### 为什么没有 `replyMode: replace`(把回复也走 surface 替换)
|
|
256
|
+
|
|
257
|
+
试过:像 `dsh-chat-thinking-editor` 那样 append 一条新的 `assistant/message`、用 replace op 遮蔽旧的那条,让宿主原生渲染。**当前宿主(0.2.1-rc.2)的 surface 校验直接拒绝**,两条独立规则:
|
|
258
|
+
|
|
259
|
+
1. `session event "assistant/message" carries an invalid replace surfaceOp` —— replace op 的键必须是 `{op, startSeq, endSeq}`(对方的代码用 `{start, end}`)
|
|
260
|
+
2. `assistant/message embeds its source stream and cannot carry sourceEventSeqs`,而同一校验又要求 `surface replace: sourceEventSeqs must include every shadowed surface node; missing N` —— 两条互相矛盾 ⇒ **assistant 消息不能被另一条 assistant 消息替换**
|
|
261
|
+
|
|
262
|
+
### 为什么没有走 `conversation.chat.reasoning.body` 槽位渲染
|
|
263
|
+
|
|
264
|
+
也试过:注册官方 `conversation.chat.reasoning.body`(assistant-step 的子槽,`kind: single`、`scope: session`,宿主默认实现 `priority: 100`,并转交工厂槽 `conversation.chat.reasoning.content` 画 markdown),用它原生渲染改写后的思维链。
|
|
265
|
+
|
|
266
|
+
实测(真页面,2026-10-22):**注册成功但拿不到槽位**——
|
|
267
|
+
|
|
268
|
+
- 注入回调确实执行(在 `document.documentElement.dataset` 上留了标记,读到 `1`)
|
|
269
|
+
- 组件从不渲染:`[data-dshet-reason-body]` 计数恒为 0
|
|
270
|
+
- `priority: 200` / `priority: 50` / 不传(由 runner 分配负值)三种都试过,宿主默认实现始终是渲染者
|
|
271
|
+
- 没有出现任何「registration refused」告警,说明注册被接受、但 `single` 槽位仍由宿主持有
|
|
272
|
+
|
|
273
|
+
结论:**当前版本下第三方插件无法接管这个 single 子槽位**。所以「原生渲染」的两条路都封着:assistant 替换被 session 校验拒绝;思维链正文槽位不可顶替。界面侧只能继续用本插件的 DOM 补丁(已验证可用:`[data-dshet-patch]` 落在 `_text_*` 思维链节点上,内容为投影后的文本)。
|
|
274
|
+
|
|
275
|
+
要真正摆脱 DOM 补丁,需要上游给投影一个原生渲染口,或者允许 assistant 替换、或允许插件 shadow 一个 single 子槽位。
|
|
276
|
+
|
|
255
277
|
### 更新日志
|
|
256
278
|
|
|
279
|
+
**0.5.2(工作区)** —— 回复编辑不再等这一轮回复结束;编辑器跟着当前皮肤走。
|
|
280
|
+
|
|
281
|
+
- **回复编辑不再被「正在回复中」挡住**:就地记录(patch)只 append 一条 `ignorable` 事件,不改任何 surface 节点,所以它可以在 agent 还在写的时候落盘 —— 模型下一次折叠就读到。宿主侧只在**会移动 surface** 的路径上保留忙时拒绝:改写提示词(surface replace)和 replay 兜底(遮蔽窗口 + 重放尾部)。客户端同步:回复就地编辑不再禁用保存、不再显示「该会话正在回复中」横幅;提示词编辑与 replay 路径照旧拦下并说明。
|
|
282
|
+
- **测试**:回复编辑在未闭合的 turn 中保存成功、提示词改写仍被拒、整轮多块在忙时保存成功。
|
|
283
|
+
|
|
284
|
+
- **颜色/圆角/阴影改用主题令牌**:文字、次级文字、三级文字、描边、主色、圆角(`--dsw-radius-panel/xl/lg/md/sm`)、阴影(`--dsw-shadow-lv3`)都读当前主题的令牌,取不到时退回本插件原来的值;悬停底色用 `color-mix` 从主题墨色算出来。
|
|
285
|
+
- **面板底色取皮肤的“色相”,不取它的透明**:皮肤把界面画在插画上时会把表面色做成半透明(靠自己的文字描边保证可读),插件元素没有描边。现在运行时探测 `--dsw-alias-bg-layer-1/2`:把它的**颜色**原样拿来、透明度强制为 1,于是「中秋」这类**深色插画皮肤**得到的是同色系的深色面板,而不是插件自带的浅色面板(这类皮肤不设 `data-ds-dark-theme`,旧代码只能退回浅色)。
|
|
286
|
+
- **调色板挂在 `body` 上**:主题令牌声明在 `body`,而自定义属性是在**定义处**作用域内做替换的 —— 原来挂在 `:root` 上,所有 `var(--dsw-*)` 都静默取了 fallback(实测:改了等于没改)。
|
|
287
|
+
- **就地编辑器不再刷白底**:`pageBackground()` 原来读 `body` 背景,插画皮肤下 `body` 是透明的 → 退回 `#ffffff`,于是深色皮肤上出现一个白框。现在优先用已解析的 `--dshet-panel`(按上面规则一定不透明)。
|
|
288
|
+
- **改写后的提示词气泡用皮肤自己的气泡色**(`--dsw-specific-bubble`),圆角用 `--dsw-radius-xl`,与真实用户气泡一致。
|
|
289
|
+
- **测试**:`npm test` **241/241**、`npm run verify:contract` **96** 项、`npm run verify:client` **192** 项。
|
|
290
|
+
|
|
291
|
+
**0.5.1(工作区)** —— 思维链就地画出来、保存不再“点不动”、同一个块可以反复编辑。
|
|
292
|
+
|
|
293
|
+
- **删掉「思维链已改写(模型可见)」卡片**:reasoning 正文只要在 DOM 里就就地改写;没有正文时不再插入任何占位卡片。
|
|
294
|
+
- **无限次编辑**:记录「上一次画上去的文本」,第二、第三次编辑同一个块时仍能找到元素并更新。
|
|
295
|
+
- **projection 缺失不再 500**:宿主用 `... was removed or replaced` 拒绝时,单块回复保存自动降级走 replay 路径;「全部」多块保存返回 `projection-missing`,提示重启 DSH。
|
|
296
|
+
- **忙时不再假死**:客户端从 `/state` 读 `busy`,会话还在回复时直接禁用保存并显示整条提示「该会话正在回复中…」,每 3 秒复查一次,回复结束自动解除。
|
|
297
|
+
- **失败提示变醒目**:in-place 的 `.dshet-error` 从底栏左侧小字改成整条横幅(左右 8px、底 52px、带边框/阴影)。
|
|
298
|
+
- **确认步不再死循环**:保存键处理函数改读实时 controller 状态;确认步给按钮加可见强调圈;`/apply` 增加 20 秒超时,宿主挂住时恢复按钮并报错。
|
|
299
|
+
- **从「全部」切回单块不再残留**:选择器切回某一块时没有把 `allBlocks` 置回 false,重绘又画成「全部」视图、选择器却写着「正文」(两个输入框 + 单块选择)。现在切回单块会明确退出全部视图,`allBlocks` 也进了编辑器重建标记;选到不存在的块时同样退出全部视图并保留原先选中的块。
|
|
300
|
+
- **思考行找对了**:官方把 reasoning 画在**它自己那一行**(flow key 带 `,reasoning` 分组),而按 seq 找到的行是**正文行** —— 旧代码只在那一行里找,所以思维链改写永远落不到 DOM(正文改写正常,因为正文就在那一行)。现在思维链的搜索改为**在整个会话里找“文本等于记录原文”的最深节点**,并和其它块一样登记 `data-dshet-patch`(可回退)。
|
|
301
|
+
- **折叠的思考也能看到改写**:思考面板收起时正文根本不在 DOM 里,而头部那行 `思考 · <截断片段>` 是宿主按**记录的原文**画的 —— 于是改了模型上下文、界面却像没生效。现在保存后连**头部片段**一起按改写后的文本重画(保留宿主的省略号与截断长度、不碰它自己的标签)。
|
|
302
|
+
- **长内容不再被底栏压住**:输入框高度原来只减了顶栏(`height - offsetTop - 8`),底部 40px 落在控制带下面 —— 框内能滚动,但最后几行永远被 X/保存那条带子盖住。现在按底栏实际高度(`offsetHeight`,取不到时用 46px 常量)预留,输入框正好停在控制带上方;内容更高时在框内滚动。
|
|
303
|
+
- **测试**:`npm test` **235/235**、`npm run verify:contract` **96** 项、`npm run verify:client` **184** 项。
|
|
304
|
+
|
|
257
305
|
**0.5.0** —— 从「一个气泡的编辑器」长成「整轮可编辑 + 可导航的文档」。
|
|
258
306
|
|
|
259
307
|
- **整轮所有块**:一条回答里每个 step 的 reasoning 与每个 text 都进 picker,同类自动编号(思维链 1 / 正文 1 …),每块带自己的 seq,保存时 patch 到它所在的消息。
|
package/lib/client.js
CHANGED
|
@@ -45,7 +45,7 @@ window.__ModuleLoader__.load({
|
|
|
45
45
|
const RERUN_PREFIX = '/dsh-rerun-turn'
|
|
46
46
|
|
|
47
47
|
/** Keep in sync with package.json and lib/index.js. */
|
|
48
|
-
const PLUGIN_VERSION = '0.5.
|
|
48
|
+
const PLUGIN_VERSION = '0.5.2'
|
|
49
49
|
|
|
50
50
|
// --- copy -----------------------------------------------------------------
|
|
51
51
|
|
|
@@ -103,7 +103,6 @@ window.__ModuleLoader__.load({
|
|
|
103
103
|
'editor.block.reasoning': '思维链',
|
|
104
104
|
'editor.placeholder-block': '只改这一块;工具调用、其余内容和后续对话保持不变。',
|
|
105
105
|
'confirm.body-block': '确认后只替换这一块,工具调用、其他内容和后续对话保持不变。原始会话日志不会改写。',
|
|
106
|
-
'editor.patch-note': '思维链已改写(模型可见)',
|
|
107
106
|
'editor.review': '确认执行',
|
|
108
107
|
'editor.cancel': '取消',
|
|
109
108
|
'editor.pending': '正在保存…',
|
|
@@ -132,6 +131,7 @@ window.__ModuleLoader__.load({
|
|
|
132
131
|
'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
|
|
133
132
|
'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
|
|
134
133
|
'error.internal': '服务器内部错误,请稍后重试。',
|
|
134
|
+
'error.projection-missing': '编辑投影未注册,请重启 DSH 后重试。',
|
|
135
135
|
'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
|
|
136
136
|
'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
|
|
137
137
|
'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
|
|
@@ -189,7 +189,6 @@ window.__ModuleLoader__.load({
|
|
|
189
189
|
'editor.block.reasoning': 'Reasoning',
|
|
190
190
|
'editor.placeholder-block': 'Only this block changes; tool calls, the rest of the reply and later turns stay.',
|
|
191
191
|
'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.',
|
|
192
|
-
'editor.patch-note': 'Reasoning patched (model-visible)',
|
|
193
192
|
'editor.review': 'Confirm',
|
|
194
193
|
'editor.cancel': 'Cancel',
|
|
195
194
|
'editor.pending': 'Working...',
|
|
@@ -218,6 +217,7 @@ window.__ModuleLoader__.load({
|
|
|
218
217
|
'error.already-retired': 'This turn is no longer part of the current context.',
|
|
219
218
|
'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
|
|
220
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.',
|
|
221
221
|
'error.attachment-refused': 'The attachment could not be stored, so nothing was changed.',
|
|
222
222
|
'error.attachments-dropped': 'Saved, but the images or files on this message could not be kept: this deployment mounts no attachment store.',
|
|
223
223
|
'error.attachment-unreadable': 'That attachment could not be read; try another file.',
|
|
@@ -237,6 +237,92 @@ window.__ModuleLoader__.load({
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
// --- style ----------------------------------------------------------------
|
|
240
|
+
/**
|
|
241
|
+
* Take the active skin's own surfaces when they are actually opaque.
|
|
242
|
+
*
|
|
243
|
+
* A skin may paint its surfaces translucent so artwork shows through, and it
|
|
244
|
+
* compensates with its own text halo - which a plugin element does not have.
|
|
245
|
+
* So the theme's surfaces are adopted only after resolving them to a real
|
|
246
|
+
* colour and finding them opaque; a translucent skin leaves this editor on
|
|
247
|
+
* its own opaque palette. Colours, radii and shadows need no such guard:
|
|
248
|
+
* those are hue and geometry, and they survive any skin.
|
|
249
|
+
*
|
|
250
|
+
* The result is written as inline variables on `body`, which beats both the
|
|
251
|
+
* `:root` defaults and the dark-theme rule below them.
|
|
252
|
+
*
|
|
253
|
+
* @returns nothing; the stylesheet defaults stand when no probe is possible.
|
|
254
|
+
*/
|
|
255
|
+
function resolveSkinPalette() {
|
|
256
|
+
if (typeof document === 'undefined' || !document.body || typeof document.createElement !== 'function') return
|
|
257
|
+
if (typeof getComputedStyle !== 'function') return
|
|
258
|
+
const probe = document.createElement('span')
|
|
259
|
+
probe.setAttribute('aria-hidden', 'true')
|
|
260
|
+
probe.style.cssText = 'position:absolute;left:-9999px;top:0;width:0;height:0;pointer-events:none'
|
|
261
|
+
document.body.appendChild(probe)
|
|
262
|
+
const read = (name) => {
|
|
263
|
+
try {
|
|
264
|
+
probe.style.color = `var(${name})`
|
|
265
|
+
const match = /rgba?\(([^)]+)\)/.exec(getComputedStyle(probe).color)
|
|
266
|
+
if (match === null) return null
|
|
267
|
+
const parts = match[1].split(',').map((value) => Number.parseFloat(value))
|
|
268
|
+
if (parts.length < 3 || parts.some((value) => !Number.isFinite(value))) return null
|
|
269
|
+
return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 }
|
|
270
|
+
} catch {
|
|
271
|
+
return null
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const css = (color) => `rgba(${Math.round(color.r)},${Math.round(color.g)},${Math.round(color.b)},${color.a})`
|
|
275
|
+
const style = document.body.style
|
|
276
|
+
const setVar = (name, value) => {
|
|
277
|
+
if (style === undefined || style === null) return
|
|
278
|
+
if (typeof style.setProperty === 'function') style.setProperty(name, value)
|
|
279
|
+
else style[name] = value
|
|
280
|
+
}
|
|
281
|
+
const clearVar = (name) => {
|
|
282
|
+
if (style === undefined || style === null) return
|
|
283
|
+
if (typeof style.removeProperty === 'function') style.removeProperty(name)
|
|
284
|
+
else delete style[name]
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const panel = read('--dsw-alias-bg-layer-1')
|
|
288
|
+
const layer2 = read('--dsw-alias-bg-layer-2')
|
|
289
|
+
const accent = read('--dsw-alias-button-primary-fill')
|
|
290
|
+
// The skin's surface HUE is taken; its transparency is not. A skin paints
|
|
291
|
+
// its own elements translucent over artwork and gives them a text halo -
|
|
292
|
+
// a plugin element has none, so the same colour is adopted at full
|
|
293
|
+
// opacity. That keeps a dark artwork skin dark (instead of falling back
|
|
294
|
+
// to this plugin's light palette, which is what a skin that never sets
|
|
295
|
+
// `data-ds-dark-theme` used to get) without ever painting text on art.
|
|
296
|
+
const solid = (color) => (color === null ? null : { r: color.r, g: color.g, b: color.b, a: 1 })
|
|
297
|
+
const panelSolid = solid(panel)
|
|
298
|
+
if (panelSolid === null) {
|
|
299
|
+
clearVar('--dshet-panel')
|
|
300
|
+
clearVar('--dshet-chip')
|
|
301
|
+
clearVar('--dshet-field')
|
|
302
|
+
} else {
|
|
303
|
+
const fieldSolid = solid(layer2) ?? panelSolid
|
|
304
|
+
setVar('--dshet-panel', css(panelSolid))
|
|
305
|
+
setVar('--dshet-chip', css(panelSolid))
|
|
306
|
+
setVar('--dshet-field', css(fieldSolid))
|
|
307
|
+
}
|
|
308
|
+
if (accent === null) {
|
|
309
|
+
clearVar('--dshet-on-accent')
|
|
310
|
+
} else {
|
|
311
|
+
// The label that sits ON the accent fill: the platform exposes no
|
|
312
|
+
// inverse token here, so it is picked from the fill's own luminance.
|
|
313
|
+
const channel = (value) => {
|
|
314
|
+
const v = value / 255
|
|
315
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
|
|
316
|
+
}
|
|
317
|
+
const luminance = 0.2126 * channel(accent.r) + 0.7152 * channel(accent.g) + 0.0722 * channel(accent.b)
|
|
318
|
+
setVar('--dshet-on-accent', luminance > 0.35 ? '#0f1115' : '#fff')
|
|
319
|
+
}
|
|
320
|
+
} finally {
|
|
321
|
+
if (typeof probe.remove === 'function') probe.remove()
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
240
326
|
|
|
241
327
|
// A skin is free to make the theme's surface colours translucent - that is
|
|
242
328
|
// the point of one: the artwork shows through, and the skin compensates by
|
|
@@ -251,8 +337,21 @@ window.__ModuleLoader__.load({
|
|
|
251
337
|
// UI packages use. Theme variables are still used for *colours* (hue,
|
|
252
338
|
// semantic tones) - those survive a skin; it is alpha that does not.
|
|
253
339
|
const CSS = [
|
|
254
|
-
|
|
255
|
-
|
|
340
|
+
// Colours, radii and shadows come from the active theme's own tokens, so a
|
|
341
|
+
// skin restyles this editor the way it restyles the rest of the app; every
|
|
342
|
+
// one of them falls back to the value this plugin shipped with. The palette
|
|
343
|
+
// hangs off `body`, not `:root`: a custom property substitutes against the
|
|
344
|
+
// values in scope where it is DEFINED, and the theme declares its tokens on
|
|
345
|
+
// body - on `:root` every reference would silently fall back. Only the
|
|
346
|
+
// SURFACES stay namespaced: a skin is free to make its own surfaces
|
|
347
|
+
// translucent (that is the point of one), and a plugin's element has no
|
|
348
|
+
// artwork-aware halo to stay readable behind. The surfaces are taken from
|
|
349
|
+
// the theme too, but only when a runtime probe finds them opaque (see
|
|
350
|
+
// resolveSkinPalette).
|
|
351
|
+
'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)}',
|
|
352
|
+
'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))}',
|
|
353
|
+
// A hover wash that follows the skin's ink instead of a fixed grey.
|
|
354
|
+
'@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)}}',
|
|
256
355
|
// The pencil sits among the platform's own action icons, so it takes the
|
|
257
356
|
// platform's icon colour: our own `--dshet-ink-dim` is a blue-grey and
|
|
258
357
|
// read as a different, "active" icon next to the neutral grey ones. The
|
|
@@ -278,7 +377,7 @@ window.__ModuleLoader__.load({
|
|
|
278
377
|
// inherit picks up the row's 16px, fixed ignores the reader's content
|
|
279
378
|
// font setting, and either way the rewritten text changed size against
|
|
280
379
|
// the bubble it replaces.
|
|
281
|
-
'.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:
|
|
380
|
+
'.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}',
|
|
282
381
|
'.dshet-revision:empty{display:none}',
|
|
283
382
|
'.dshet-revision{position:relative}',
|
|
284
383
|
// Fallback only: a row with no action bar to keep is displayed away
|
|
@@ -289,7 +388,7 @@ window.__ModuleLoader__.load({
|
|
|
289
388
|
'.dshet-revision.dshet-revision-action{padding-inline-end:40px}',
|
|
290
389
|
'.dshet-revision-text{display:block}',
|
|
291
390
|
'.dshet-revision-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}',
|
|
292
|
-
'.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:
|
|
391
|
+
'.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}',
|
|
293
392
|
'.dshet-revision .dshet-action-host{position:absolute;inset-inline-end:6px;top:6px;opacity:0;transition:opacity .12s ease}',
|
|
294
393
|
'.dshet-revision:hover .dshet-action-host,.dshet-revision:focus-within .dshet-action-host{opacity:1}',
|
|
295
394
|
// A sibling plugin can append its own button (dsh-delete-turn's bin) into
|
|
@@ -331,15 +430,16 @@ window.__ModuleLoader__.load({
|
|
|
331
430
|
'.dshet-layer[data-dshet-inline="1"]{position:absolute;left:0;top:0;right:auto;bottom:auto;width:100%;height:0}',
|
|
332
431
|
'.dshet-layer{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:40}',
|
|
333
432
|
'.dshet-layer .dshet-editor{position:absolute;pointer-events:auto}',
|
|
334
|
-
'.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:
|
|
433
|
+
'.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}',
|
|
335
434
|
'.dshet-editor[data-dshet-drop="1"]{outline:2px dashed #4a7dff;outline-offset:-4px}',
|
|
336
435
|
'.dshet-editor:focus-within{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
|
|
337
|
-
'.dshet-editor[data-dshet-inplace="1"]{overflow:hidden;border:1.5px solid var(--dsw-alias-button-primary-fill,#4a7dff);border-radius:
|
|
436
|
+
'.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}',
|
|
338
437
|
'.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}',
|
|
339
|
-
'.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
|
|
438
|
+
'.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}',
|
|
340
439
|
'.dshet-editor[data-dshet-inplace="1"] .dshet-btn{padding:2px 8px;font-size:12px;line-height:18px;border-radius:8px}',
|
|
341
440
|
'.dshet-editor[data-dshet-inplace="1"] .dshet-pick{position:static;top:auto;left:auto;padding:0;background:transparent;border-radius:0}',
|
|
342
|
-
'.dshet-editor[data-dshet-inplace="1"] .dshet-note,.dshet-editor[data-dshet-inplace="1"] .dshet-warn
|
|
441
|
+
'.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}',
|
|
442
|
+
'.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}',
|
|
343
443
|
|
|
344
444
|
'.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}',
|
|
345
445
|
'.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
|
|
@@ -368,7 +468,7 @@ window.__ModuleLoader__.load({
|
|
|
368
468
|
'.dshet-note{color:var(--dshet-ink-dim)}',
|
|
369
469
|
'.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
|
|
370
470
|
'.dshet-error{color:var(--dsw-alias-state-error-primary,#c93a31)}',
|
|
371
|
-
'.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
|
|
471
|
+
'.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)}',
|
|
372
472
|
'.dshet-footer-actions{display:flex;align-items:center;gap:6px}',
|
|
373
473
|
'.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}',
|
|
374
474
|
'.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}',
|
|
@@ -392,10 +492,11 @@ window.__ModuleLoader__.load({
|
|
|
392
492
|
'.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}',
|
|
393
493
|
'.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))}',
|
|
394
494
|
'.dshet-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-fill,var(--dshet-accent));filter:brightness(1.08)}',
|
|
395
|
-
'.dshet-btn.dshet-btn-save{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color
|
|
495
|
+
'.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}',
|
|
396
496
|
'.dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
|
|
497
|
+
'.dshet-btn.dshet-btn-save.dshet-btn-confirm{background:var(--dshet-hover);box-shadow:inset 0 0 0 1.5px var(--dshet-accent)}',
|
|
397
498
|
'.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}',
|
|
398
|
-
'.dshet-editor .dshet-footer .dshet-btn.dshet-btn-icon:hover:not(:disabled){background:var(--dshet-hover);color
|
|
499
|
+
'.dshet-editor .dshet-footer .dshet-btn.dshet-btn-icon:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink-soft);filter:none}',
|
|
399
500
|
'.dshet-btn-icon svg{width:15px;height:15px;display:block;fill:none;stroke:currentColor;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round}',
|
|
400
501
|
'.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}',
|
|
401
502
|
'.dshet-editor[data-dshet-inplace="1"] .dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
|
|
@@ -403,13 +504,10 @@ window.__ModuleLoader__.load({
|
|
|
403
504
|
'.dshet-config{display:flex;flex-direction:column;gap:10px}',
|
|
404
505
|
'.dshet-config-row{display:flex;align-items:center;justify-content:space-between;gap:12px}',
|
|
405
506
|
'.dshet-config-hint{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
|
|
406
|
-
'.dshet-select{padding:3px 8px;border:1px solid var(--dshet-line);border-radius:
|
|
507
|
+
'.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}',
|
|
407
508
|
'.dshet-pick .dshet-select{height:28px;padding:0 6px;border:0;border-radius:8px;background:transparent;line-height:1;cursor:pointer;outline:none}',
|
|
408
509
|
'.dshet-pick .dshet-select:hover{background:var(--dshet-hover)}',
|
|
409
510
|
'[data-dshet-patch]{white-space:pre-wrap}',
|
|
410
|
-
'.dshet-patch-note{margin:6px 0;padding:6px 10px;border:1px dashed var(--dshet-line);border-radius:10px;background:var(--dshet-panel);color:var(--dshet-ink-dim);font-size:12px;line-height:18px}',
|
|
411
|
-
'.dshet-patch-note summary{cursor:pointer}',
|
|
412
|
-
'.dshet-patch-note-body{margin:6px 0 0;white-space:pre-wrap;color:var(--dshet-ink);font-size:12px;line-height:18px}',
|
|
413
511
|
'.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}',
|
|
414
512
|
'@media (prefers-reduced-motion:reduce){.dshet-collapsing{transition:none}.dshet-floating{transition:none}}',
|
|
415
513
|
].join('')
|
|
@@ -634,6 +732,7 @@ window.__ModuleLoader__.load({
|
|
|
634
732
|
? data.config.editorStyle
|
|
635
733
|
: 'frame',
|
|
636
734
|
surfaceReady: true,
|
|
735
|
+
busy: data.busy === true,
|
|
637
736
|
loaded: true,
|
|
638
737
|
loadError: false,
|
|
639
738
|
confirmStep: !(data.config && data.config.confirm === false),
|
|
@@ -648,6 +747,14 @@ window.__ModuleLoader__.load({
|
|
|
648
747
|
})
|
|
649
748
|
.finally(() => {
|
|
650
749
|
this.inflight = null
|
|
750
|
+
// While the session is replying, the editor stays open but cannot
|
|
751
|
+
// save; re-check so the banner clears itself when the turn ends.
|
|
752
|
+
if (this.view.busy === true && this.editor !== null) {
|
|
753
|
+
const token = (this.busyToken = (this.busyToken ?? 0) + 1)
|
|
754
|
+
window.setTimeout(() => {
|
|
755
|
+
if (this.busyToken === token && this.editor !== null) this.load()
|
|
756
|
+
}, 3000)
|
|
757
|
+
}
|
|
651
758
|
})
|
|
652
759
|
this.inflight = pending
|
|
653
760
|
return pending
|
|
@@ -769,19 +876,27 @@ window.__ModuleLoader__.load({
|
|
|
769
876
|
this.publish({ allBlocks: true, blockIndex: null, blockKind: null, targetSeq: null, failure: null })
|
|
770
877
|
return
|
|
771
878
|
}
|
|
772
|
-
this.editorAll = false
|
|
773
879
|
const split = raw.includes(':') ? raw.split(':') : ['', raw]
|
|
774
880
|
const seq = split[0] === '' ? null : Number(split[0])
|
|
775
881
|
const index = Number(split[1])
|
|
776
882
|
const block = target.blocks.find((entry) =>
|
|
777
883
|
entry.index === index && (seq === null || entry.seq === undefined || entry.seq === seq))
|
|
778
|
-
|
|
884
|
+
// Leaving the all-blocks view has to say so: the picker change alone left
|
|
885
|
+
// `allBlocks` true, so the box was redrawn as the all view again with a
|
|
886
|
+
// picker that said "正文" - two fields over a single-block selection.
|
|
887
|
+
this.editorAll = false
|
|
888
|
+
if (block === undefined || block.kind === undefined) {
|
|
889
|
+
// Nothing matched: still leave the all view, keeping the block that was
|
|
890
|
+
// selected before it, so the picker describes the box again.
|
|
891
|
+
this.publish({ allBlocks: false, failure: null })
|
|
892
|
+
return
|
|
893
|
+
}
|
|
779
894
|
this.editorBlockIndex = block.index
|
|
780
895
|
this.editorBlockKind = block.kind
|
|
781
896
|
this.editorTargetSeq = typeof block.seq === 'number' ? block.seq : this.editorTargetSeq
|
|
782
897
|
this.editorDraft = block.text
|
|
783
898
|
this.editorElement = null
|
|
784
|
-
this.publish({ draft: block.text, blockIndex: block.index, blockKind: block.kind, targetSeq: this.editorTargetSeq, failure: null })
|
|
899
|
+
this.publish({ draft: block.text, blockIndex: block.index, blockKind: block.kind, targetSeq: this.editorTargetSeq, allBlocks: false, failure: null })
|
|
785
900
|
}
|
|
786
901
|
|
|
787
902
|
close() {
|
|
@@ -905,16 +1020,21 @@ window.__ModuleLoader__.load({
|
|
|
905
1020
|
this.publish({ editing: null })
|
|
906
1021
|
return
|
|
907
1022
|
}
|
|
908
|
-
this.publish({ pending: true, failure: null })
|
|
1023
|
+
this.publish({ pending: true, failure: null, failureDetail: null })
|
|
909
1024
|
try {
|
|
910
1025
|
const res = await fetch(`${ROUTE_PREFIX}/apply`, {
|
|
911
1026
|
method: 'POST',
|
|
912
1027
|
headers: { 'content-type': 'application/json' },
|
|
1028
|
+
...(typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
|
1029
|
+
? { signal: AbortSignal.timeout(20000) }
|
|
1030
|
+
: {}),
|
|
913
1031
|
body: JSON.stringify({ sessionId: this.sessionId, targets }),
|
|
914
1032
|
})
|
|
915
1033
|
const data = await res.json().catch(() => ({}))
|
|
916
1034
|
if (!res.ok || !data.ok) {
|
|
917
|
-
|
|
1035
|
+
const code = data && data.code ? String(data.code) : 'generic'
|
|
1036
|
+
const detail = data && typeof data.error === 'string' && data.error !== '' ? data.error : null
|
|
1037
|
+
this.publish({ pending: false, failure: code, failureDetail: detail })
|
|
918
1038
|
return
|
|
919
1039
|
}
|
|
920
1040
|
this.editor = null
|
|
@@ -983,11 +1103,16 @@ window.__ModuleLoader__.load({
|
|
|
983
1103
|
const parts = target.mode === 'prompt' && this.view.attachmentsOk === true
|
|
984
1104
|
? this.editorParts.map((part) => (part.keep !== undefined ? { keep: part.keep } : { add: part.add }))
|
|
985
1105
|
: undefined
|
|
986
|
-
this.publish({ pending: true, failure: null })
|
|
1106
|
+
this.publish({ pending: true, failure: null, failureDetail: null })
|
|
987
1107
|
try {
|
|
988
1108
|
const res = await fetch(`${ROUTE_PREFIX}/apply`, {
|
|
989
1109
|
method: 'POST',
|
|
990
1110
|
headers: { 'content-type': 'application/json' },
|
|
1111
|
+
// A hung host must not leave the editor pending forever with a
|
|
1112
|
+
// button that looks dead.
|
|
1113
|
+
...(typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
|
1114
|
+
? { signal: AbortSignal.timeout(20000) }
|
|
1115
|
+
: {}),
|
|
991
1116
|
body: JSON.stringify({
|
|
992
1117
|
sessionId: this.sessionId,
|
|
993
1118
|
seq: target.seq,
|
|
@@ -1007,7 +1132,9 @@ window.__ModuleLoader__.load({
|
|
|
1007
1132
|
})
|
|
1008
1133
|
const data = await res.json().catch(() => ({}))
|
|
1009
1134
|
if (!res.ok || !data.ok) {
|
|
1010
|
-
|
|
1135
|
+
const code = data && data.code ? String(data.code) : 'generic'
|
|
1136
|
+
const detail = data && typeof data.error === 'string' && data.error !== '' ? data.error : null
|
|
1137
|
+
this.publish({ pending: false, failure: code, failureDetail: detail })
|
|
1011
1138
|
return
|
|
1012
1139
|
}
|
|
1013
1140
|
const shadowed = Array.isArray(data.shadowed) ? data.shadowed : []
|
|
@@ -2229,9 +2356,28 @@ window.__ModuleLoader__.load({
|
|
|
2229
2356
|
element.style[property] = value
|
|
2230
2357
|
}
|
|
2231
2358
|
|
|
2359
|
+
/**
|
|
2360
|
+
* The surface an in-place editor paints itself with, so the recorded text
|
|
2361
|
+
* underneath cannot show through the draft.
|
|
2362
|
+
*
|
|
2363
|
+
* The skin's own panel colour comes first. A skin that paints the app on
|
|
2364
|
+
* artwork usually leaves `body` transparent (the artwork is a layer behind
|
|
2365
|
+
* it), and the white fallback then put a white box on a night sky. The
|
|
2366
|
+
* palette keeps the skin's hue and drops its transparency, so what comes back
|
|
2367
|
+
* here is opaque by construction.
|
|
2368
|
+
*
|
|
2369
|
+
* @returns a CSS colour for the editor's own background.
|
|
2370
|
+
*/
|
|
2232
2371
|
function pageBackground() {
|
|
2233
2372
|
try {
|
|
2234
|
-
if (typeof getComputedStyle === 'function' && typeof document !== 'undefined') {
|
|
2373
|
+
if (typeof getComputedStyle === 'function' && typeof document !== 'undefined' && document.body) {
|
|
2374
|
+
const panel = String(getComputedStyle(document.body).getPropertyValue('--dshet-panel') || '').trim()
|
|
2375
|
+
const match = /rgba?\(([^)]+)\)/.exec(panel)
|
|
2376
|
+
if (match !== null) {
|
|
2377
|
+
const parts = match[1].split(',').map((value) => Number.parseFloat(value))
|
|
2378
|
+
const opaque = parts.length >= 3 && parts.every((value) => Number.isFinite(value)) && (parts.length < 4 || parts[3] >= 0.9)
|
|
2379
|
+
if (opaque) return panel
|
|
2380
|
+
}
|
|
2235
2381
|
const value = getComputedStyle(document.body).backgroundColor
|
|
2236
2382
|
if (value && value !== 'transparent' && !/rgba?\(0, 0, 0, 0\)/.test(value)) return value
|
|
2237
2383
|
}
|
|
@@ -2659,7 +2805,15 @@ window.__ModuleLoader__.load({
|
|
|
2659
2805
|
// Reserve a header band for the controls; when the prompt carries image
|
|
2660
2806
|
// chips they sit above the text, so the text starts below them too.
|
|
2661
2807
|
const offsetTop = headerOffset
|
|
2662
|
-
|
|
2808
|
+
// The control band is painted over the box's bottom edge, so the field
|
|
2809
|
+
// has to stop above it: the old `height - offsetTop - 8` let the field
|
|
2810
|
+
// run 40px under the footer, and the last lines of a long block were
|
|
2811
|
+
// covered for good (the field scrolled, the footer never moved).
|
|
2812
|
+
const footerHeight =
|
|
2813
|
+
footerEl !== null && footerEl !== undefined && typeof footerEl.offsetHeight === 'number' && footerEl.offsetHeight > 0
|
|
2814
|
+
? footerEl.offsetHeight
|
|
2815
|
+
: FOOTER_BAND
|
|
2816
|
+
const available = Math.max(40, height - offsetTop - footerHeight)
|
|
2663
2817
|
area.style.position = 'absolute'
|
|
2664
2818
|
area.style.boxSizing = 'border-box'
|
|
2665
2819
|
area.style.left = `${Math.round(offsetLeft)}px`
|
|
@@ -2840,6 +2994,9 @@ window.__ModuleLoader__.load({
|
|
|
2840
2994
|
// saving would write, so both have to redraw the strip - and the chip
|
|
2841
2995
|
// keys are enough to tell one list from another.
|
|
2842
2996
|
view.parts.map((part) => part.key).join('.'),
|
|
2997
|
+
// The mode itself: leaving the all-blocks view must rebuild even when the
|
|
2998
|
+
// single block that comes back is the one that was selected before it.
|
|
2999
|
+
view.allBlocks === true ? 'all' : 'one',
|
|
2843
3000
|
// The selected reply block: switching blocks has to rebuild the box.
|
|
2844
3001
|
view.blockIndex === null || view.blockIndex === undefined ? '' : String(view.blockIndex),
|
|
2845
3002
|
view.targetSeq === null || view.targetSeq === undefined ? '' : String(view.targetSeq),
|
|
@@ -3099,13 +3256,15 @@ window.__ModuleLoader__.load({
|
|
|
3099
3256
|
if (event.isComposing === true || event.keyCode === 229) return
|
|
3100
3257
|
if (event.key === 'Enter' && event.shiftKey !== true) {
|
|
3101
3258
|
event.preventDefault()
|
|
3102
|
-
|
|
3259
|
+
const live = liveView()
|
|
3260
|
+
if (live !== null && live !== undefined && live.confirming === true) controller.confirm()
|
|
3103
3261
|
else controller.review()
|
|
3104
3262
|
return
|
|
3105
3263
|
}
|
|
3106
3264
|
if (event.key === 'Escape') {
|
|
3107
3265
|
event.preventDefault()
|
|
3108
|
-
|
|
3266
|
+
const live = liveView()
|
|
3267
|
+
if (live !== null && live !== undefined && live.confirming === true) controller.back()
|
|
3109
3268
|
else controller.close()
|
|
3110
3269
|
}
|
|
3111
3270
|
})
|
|
@@ -3234,14 +3393,34 @@ window.__ModuleLoader__.load({
|
|
|
3234
3393
|
warn.textContent = texts.warn
|
|
3235
3394
|
box.appendChild(warn)
|
|
3236
3395
|
}
|
|
3237
|
-
|
|
3396
|
+
// A running turn only blocks the paths that move the surface (a prompt
|
|
3397
|
+
// rewrite, or a reply edit on a host without the in-place record). The
|
|
3398
|
+
// in-place record appends one event and changes nothing that is already
|
|
3399
|
+
// on the surface, so waiting for the agent to finish is not required -
|
|
3400
|
+
// the model picks the edit up on its next fold.
|
|
3401
|
+
const inPlaceEdit = target.mode === 'reply' && target.patch === true
|
|
3402
|
+
const busyBlocked = view.busy === true && !inPlaceEdit
|
|
3403
|
+
const failureCode = typeof view.failure === 'string' && view.failure !== ''
|
|
3404
|
+
? view.failure
|
|
3405
|
+
: busyBlocked
|
|
3406
|
+
? 'busy'
|
|
3407
|
+
: null
|
|
3408
|
+
if (failureCode !== null) {
|
|
3238
3409
|
const error = document.createElement('p')
|
|
3239
3410
|
error.className = 'dshet-error'
|
|
3240
3411
|
error.setAttribute('role', 'status')
|
|
3241
|
-
|
|
3412
|
+
const detail = typeof view.failureDetail === 'string' && view.failureDetail !== '' ? view.failureDetail : null
|
|
3413
|
+
error.textContent = (failureCode === 'internal' || failureCode === 'generic') && detail !== null
|
|
3414
|
+
? `${errorText(t, failureCode)}(${detail.slice(0, 300)})`
|
|
3415
|
+
: errorText(t, failureCode)
|
|
3242
3416
|
box.appendChild(error)
|
|
3243
3417
|
}
|
|
3244
3418
|
|
|
3419
|
+
// Handlers must read the CURRENT controller state: the host may not have
|
|
3420
|
+
// rebuilt the button after the first press, and a stale `view.confirming`
|
|
3421
|
+
// made the confirmation step a dead end (press again -> review again).
|
|
3422
|
+
const liveView = () => (typeof controller.getSnapshot === 'function' ? controller.getSnapshot() : view)
|
|
3423
|
+
|
|
3245
3424
|
const footer = document.createElement('div')
|
|
3246
3425
|
footer.className = 'dshet-footer'
|
|
3247
3426
|
const footerActions = document.createElement('div')
|
|
@@ -3282,11 +3461,17 @@ window.__ModuleLoader__.load({
|
|
|
3282
3461
|
submit.appendChild(submitText)
|
|
3283
3462
|
submit.title = `${submitLabel} (Enter)`
|
|
3284
3463
|
submit.setAttribute('aria-label', submitLabel)
|
|
3285
|
-
submit.disabled = view.pending
|
|
3464
|
+
submit.disabled = view.pending || busyBlocked
|
|
3286
3465
|
pressable(submit, () => {
|
|
3287
|
-
|
|
3466
|
+
const live = liveView()
|
|
3467
|
+
if (live !== null && live !== undefined && live.confirming === true) controller.confirm()
|
|
3288
3468
|
else controller.review()
|
|
3289
3469
|
})
|
|
3470
|
+
if (view.confirming === true) {
|
|
3471
|
+
// The save label is visually hidden, so the confirmation step needs a
|
|
3472
|
+
// visible state of its own or the first press looks like nothing.
|
|
3473
|
+
submit.classList.add('dshet-btn-confirm')
|
|
3474
|
+
}
|
|
3290
3475
|
|
|
3291
3476
|
footerActions.appendChild(cancel)
|
|
3292
3477
|
// The re-run button belongs to the sibling (dsh-rerun-turn): it appears
|
|
@@ -3377,8 +3562,11 @@ window.__ModuleLoader__.load({
|
|
|
3377
3562
|
// collapsed panel that may not be in the DOM at all.
|
|
3378
3563
|
|
|
3379
3564
|
const PATCH_ATTR = 'data-dshet-patch'
|
|
3380
|
-
const NOTE_ATTR = 'data-dshet-patch-note'
|
|
3381
3565
|
const patchOriginals = new WeakMap()
|
|
3566
|
+
// The text this plugin last painted for a key, so a second edit of the same
|
|
3567
|
+
// block can still find the element after the first text replaced the
|
|
3568
|
+
// recorded original in the DOM.
|
|
3569
|
+
const patchedTexts = new Map()
|
|
3382
3570
|
// React may re-render the assistant container and reset its attributes and
|
|
3383
3571
|
// style while leaving the text node we replaced. Remember the element by
|
|
3384
3572
|
// seq:block so the next pass can restore the marker and, on a later edit,
|
|
@@ -3456,10 +3644,11 @@ window.__ModuleLoader__.load({
|
|
|
3456
3644
|
// element in the owning row whose whole text is exactly the recorded text;
|
|
3457
3645
|
// never a button, a timestamp, our own chrome, or an ancestor that merely
|
|
3458
3646
|
// contains the text.
|
|
3459
|
-
function findPatchTextFallback(owner, wanted) {
|
|
3647
|
+
function findPatchTextFallback(owner, wanted, previousText = '') {
|
|
3460
3648
|
if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return null
|
|
3461
3649
|
const original = matchText(wanted.original)
|
|
3462
|
-
|
|
3650
|
+
const previous = matchText(previousText)
|
|
3651
|
+
if (original === '' && previous === '') return null
|
|
3463
3652
|
let scope = owner
|
|
3464
3653
|
try {
|
|
3465
3654
|
const node = owner.querySelector('[data-slot="conversation.chat.node"]')
|
|
@@ -3471,12 +3660,12 @@ window.__ModuleLoader__.load({
|
|
|
3471
3660
|
if (el === scope) continue
|
|
3472
3661
|
if (el.getAttribute(PATCH_ATTR) !== null) continue
|
|
3473
3662
|
try {
|
|
3474
|
-
if (el.closest('.dshet-editor, .dshet-action, .dshet-pick,
|
|
3663
|
+
if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
|
|
3475
3664
|
} catch {
|
|
3476
3665
|
continue
|
|
3477
3666
|
}
|
|
3478
3667
|
const text = matchText(el.textContent)
|
|
3479
|
-
if (text !== original) continue
|
|
3668
|
+
if (text !== original && !(previous !== '' && text === previous)) continue
|
|
3480
3669
|
let deeper = false
|
|
3481
3670
|
for (const child of el.children ?? []) {
|
|
3482
3671
|
if (matchText(child.textContent) === text) {
|
|
@@ -3489,6 +3678,97 @@ window.__ModuleLoader__.load({
|
|
|
3489
3678
|
return null
|
|
3490
3679
|
}
|
|
3491
3680
|
|
|
3681
|
+
/**
|
|
3682
|
+
* The collapsed reasoning header draws its own truncation of the RECORDED
|
|
3683
|
+
* text ("思考 · ..."), and when the panel is closed the body is not in the
|
|
3684
|
+
* DOM at all - so a patch of the reasoning body alone leaves the edit
|
|
3685
|
+
* invisible. Rewrite that snippet too, keeping whatever the host appended
|
|
3686
|
+
* (its ellipsis) and its own truncation length.
|
|
3687
|
+
*/
|
|
3688
|
+
function patchReasoningSummary(owner, wanted) {
|
|
3689
|
+
if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return false
|
|
3690
|
+
const original = String(wanted.original ?? '')
|
|
3691
|
+
const patched = String(wanted.text ?? '')
|
|
3692
|
+
if (original === '' || patched === '') return false
|
|
3693
|
+
const head = original.slice(0, 12)
|
|
3694
|
+
let changed = false
|
|
3695
|
+
const apply = (get, set) => {
|
|
3696
|
+
const data = String(get() ?? '')
|
|
3697
|
+
if (data === '' || !data.includes(head)) return
|
|
3698
|
+
// A node holding the whole text gets the whole replacement; a truncated
|
|
3699
|
+
// snippet keeps the host's length and gains an ellipsis when the new
|
|
3700
|
+
// text is longer.
|
|
3701
|
+
const next = data.length >= original.length ? patched : `${patched.slice(0, data.length)}${patched.length > data.length ? '…' : ''}`
|
|
3702
|
+
if (next === data) return
|
|
3703
|
+
set(next)
|
|
3704
|
+
changed = true
|
|
3705
|
+
}
|
|
3706
|
+
const walk = (node) => {
|
|
3707
|
+
for (const child of node.childNodes ?? []) {
|
|
3708
|
+
const type = child.nodeType
|
|
3709
|
+
if (type === 3) {
|
|
3710
|
+
apply(() => child.data, (value) => { child.data = value })
|
|
3711
|
+
continue
|
|
3712
|
+
}
|
|
3713
|
+
if (type !== 1) continue
|
|
3714
|
+
try {
|
|
3715
|
+
if (typeof child.closest === 'function' && child.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
|
|
3716
|
+
} catch {
|
|
3717
|
+
/* a node without closest is walked anyway */
|
|
3718
|
+
}
|
|
3719
|
+
const kids = child.childNodes ?? []
|
|
3720
|
+
if (kids.length === 0) {
|
|
3721
|
+
apply(() => child.textContent, (value) => { child.textContent = value })
|
|
3722
|
+
continue
|
|
3723
|
+
}
|
|
3724
|
+
walk(child)
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
walk(owner)
|
|
3728
|
+
return changed
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
/**
|
|
3732
|
+
* The reasoning body on this host is its own row (the flow key carries a
|
|
3733
|
+
* `,reasoning` group part), so neither the answer row nor the first chat node
|
|
3734
|
+
* the shared fallback picks contains it. Search the whole conversation for
|
|
3735
|
+
* the deepest element whose text is the recorded original (or what we last
|
|
3736
|
+
* painted there).
|
|
3737
|
+
*/
|
|
3738
|
+
function findReasoningElement(root, wanted, previousText) {
|
|
3739
|
+
if (root === null || root === undefined || typeof root.querySelectorAll !== 'function') return null
|
|
3740
|
+
const original = matchText(wanted.original)
|
|
3741
|
+
const previous = matchText(previousText)
|
|
3742
|
+
if (original === '' && previous === '') return null
|
|
3743
|
+
let best = null
|
|
3744
|
+
let bestScore = Infinity
|
|
3745
|
+
for (const el of root.querySelectorAll('*')) {
|
|
3746
|
+
if (el.getAttribute(PATCH_ATTR) !== null) continue
|
|
3747
|
+
try {
|
|
3748
|
+
if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
|
|
3749
|
+
} catch {
|
|
3750
|
+
/* a detached node is still a candidate */
|
|
3751
|
+
}
|
|
3752
|
+
const text = matchText(el.textContent)
|
|
3753
|
+
if (text === '') continue
|
|
3754
|
+
if (text !== original && !(previous !== '' && text === previous)) continue
|
|
3755
|
+
let deeper = false
|
|
3756
|
+
for (const child of el.children ?? []) {
|
|
3757
|
+
if (matchText(child.textContent) === text) {
|
|
3758
|
+
deeper = true
|
|
3759
|
+
break
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
3762
|
+
if (deeper) continue
|
|
3763
|
+
const score = Math.abs(text.length - original.length)
|
|
3764
|
+
if (score < bestScore) {
|
|
3765
|
+
best = el
|
|
3766
|
+
bestScore = score
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
return best
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3492
3772
|
function applyPatchDisplay(snapshot, view, t) {
|
|
3493
3773
|
// Map a reply seq to the host row that draws it, so a short answer like
|
|
3494
3774
|
// "好的" cannot be matched in a different row. Fall back to a global
|
|
@@ -3522,7 +3802,7 @@ window.__ModuleLoader__.load({
|
|
|
3522
3802
|
if (root === null) return
|
|
3523
3803
|
const replies = view.replies instanceof Map ? view.replies : new Map()
|
|
3524
3804
|
const wantedText = new Map()
|
|
3525
|
-
const
|
|
3805
|
+
const wantedReasoning = new Map()
|
|
3526
3806
|
for (const patch of Array.isArray(view.patches) ? view.patches : []) {
|
|
3527
3807
|
const reply = replies.get(patch.seq)
|
|
3528
3808
|
if (reply === undefined || !Array.isArray(reply.blocks)) continue
|
|
@@ -3536,8 +3816,10 @@ window.__ModuleLoader__.load({
|
|
|
3536
3816
|
text: String(patch.text ?? ''),
|
|
3537
3817
|
})
|
|
3538
3818
|
} else if (patch.kind === 'reasoning') {
|
|
3539
|
-
|
|
3819
|
+
wantedReasoning.set(`${patch.seq}:${patch.blockIndex}`, {
|
|
3540
3820
|
key: `${patch.seq}:${patch.blockIndex}`,
|
|
3821
|
+
seq: patch.seq,
|
|
3822
|
+
original: String(block.original ?? ''),
|
|
3541
3823
|
text: String(patch.text ?? ''),
|
|
3542
3824
|
reply,
|
|
3543
3825
|
block,
|
|
@@ -3548,7 +3830,7 @@ window.__ModuleLoader__.load({
|
|
|
3548
3830
|
// A patch that no longer exists must give the host's recorded text back,
|
|
3549
3831
|
// even when a re-render already stripped our marker from the element.
|
|
3550
3832
|
for (const [key, el] of [...patchedElements]) {
|
|
3551
|
-
if (wantedText.has(key)) continue
|
|
3833
|
+
if (wantedText.has(key) || wantedReasoning.has(key)) continue
|
|
3552
3834
|
patchedElements.delete(key)
|
|
3553
3835
|
if (el.isConnected === true) restorePatchedBlock(el)
|
|
3554
3836
|
}
|
|
@@ -3566,18 +3848,14 @@ window.__ModuleLoader__.load({
|
|
|
3566
3848
|
el.textContent = shown
|
|
3567
3849
|
}
|
|
3568
3850
|
}
|
|
3569
|
-
|
|
3570
|
-
const key = note.getAttribute(NOTE_ATTR)
|
|
3571
|
-
if (!wantedNotes.has(key)) note.remove()
|
|
3572
|
-
}
|
|
3573
|
-
if (wantedText.size === 0 && wantedNotes.size === 0) return
|
|
3851
|
+
if (wantedText.size === 0 && wantedReasoning.size === 0) return
|
|
3574
3852
|
|
|
3575
3853
|
const nodes = [...root.querySelectorAll('[data-slot="conversation.chat.node"]')]
|
|
3576
3854
|
const containers = []
|
|
3577
3855
|
for (const node of nodes) {
|
|
3578
3856
|
for (const el of node.querySelectorAll('*')) {
|
|
3579
3857
|
try {
|
|
3580
|
-
if (isMarkdownContainer(el) && el.closest('.dshet-editor, .dshet-pick
|
|
3858
|
+
if (isMarkdownContainer(el) && el.closest('.dshet-editor, .dshet-pick') === null) containers.push(el)
|
|
3581
3859
|
} catch {
|
|
3582
3860
|
/* a detached node is skipped */
|
|
3583
3861
|
}
|
|
@@ -3608,7 +3886,8 @@ window.__ModuleLoader__.load({
|
|
|
3608
3886
|
continue
|
|
3609
3887
|
}
|
|
3610
3888
|
const original = matchText(wanted.original)
|
|
3611
|
-
|
|
3889
|
+
const previous = matchText(patchedTexts.get(key) ?? '')
|
|
3890
|
+
if (original === '' && previous === '') continue
|
|
3612
3891
|
let best = null
|
|
3613
3892
|
let bestScore = Infinity
|
|
3614
3893
|
for (const el of containers) {
|
|
@@ -3622,7 +3901,7 @@ window.__ModuleLoader__.load({
|
|
|
3622
3901
|
}
|
|
3623
3902
|
const text = matchText(el.textContent)
|
|
3624
3903
|
if (text === '') continue
|
|
3625
|
-
const exact = text === original
|
|
3904
|
+
const exact = text === original || (previous !== '' && text === previous)
|
|
3626
3905
|
const prefix = text.startsWith(original.slice(0, 60)) || original.startsWith(text.slice(0, 60))
|
|
3627
3906
|
if (!exact && !prefix) continue
|
|
3628
3907
|
// Prefer the exact match, then the container closest in length: a
|
|
@@ -3634,47 +3913,90 @@ window.__ModuleLoader__.load({
|
|
|
3634
3913
|
bestScore = score
|
|
3635
3914
|
}
|
|
3636
3915
|
}
|
|
3637
|
-
if (best === null) best = findPatchTextFallback(owner, wanted)
|
|
3916
|
+
if (best === null) best = findPatchTextFallback(owner, wanted, patchedTexts.get(key) ?? '')
|
|
3638
3917
|
if (best === null) continue
|
|
3639
3918
|
patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
|
|
3640
3919
|
best.textContent = displayText(wanted.text)
|
|
3641
3920
|
best.setAttribute(PATCH_ATTR, key)
|
|
3642
3921
|
best.style.whiteSpace = 'pre-wrap'
|
|
3643
3922
|
patchedElements.set(key, best)
|
|
3923
|
+
patchedTexts.set(key, wanted.text)
|
|
3644
3924
|
}
|
|
3645
3925
|
|
|
3646
|
-
//
|
|
3647
|
-
//
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
const
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3926
|
+
// A reasoning patch is drawn in place when the host renders that
|
|
3927
|
+
// reasoning body; the note is only the fallback for a panel that is not
|
|
3928
|
+
// in the DOM (or is drawn as a short summary without the full text).
|
|
3929
|
+
for (const [key, wanted] of wantedReasoning) {
|
|
3930
|
+
const owner = rowBySeq.get(wanted.seq)
|
|
3931
|
+
const remembered = patchedElements.get(key)
|
|
3932
|
+
if (remembered !== undefined) {
|
|
3933
|
+
// The reasoning part is its own row (the flow key carries a
|
|
3934
|
+
// `,reasoning` group part), so it is NOT the row `rowBySeq` picked for
|
|
3935
|
+
// the seq - that one draws the answer. Only connectivity matters here.
|
|
3936
|
+
const stillThere = remembered.isConnected === true
|
|
3937
|
+
if (stillThere) {
|
|
3938
|
+
const shown = displayText(wanted.text)
|
|
3939
|
+
if (normalizedText(remembered.textContent) !== normalizedText(shown)) remembered.textContent = shown
|
|
3940
|
+
if (remembered.getAttribute(PATCH_ATTR) !== key) remembered.setAttribute(PATCH_ATTR, key)
|
|
3941
|
+
remembered.style.whiteSpace = 'pre-wrap'
|
|
3942
|
+
patchedTexts.set(key, wanted.text)
|
|
3943
|
+
continue
|
|
3658
3944
|
}
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3945
|
+
patchedElements.delete(key)
|
|
3946
|
+
}
|
|
3947
|
+
const done = [...root.querySelectorAll(`[${PATCH_ATTR}]`)].find((el) => el.getAttribute(PATCH_ATTR) === key)
|
|
3948
|
+
if (done !== undefined) {
|
|
3949
|
+
patchedElements.set(key, done)
|
|
3950
|
+
continue
|
|
3951
|
+
}
|
|
3952
|
+
const original = matchText(wanted.original)
|
|
3953
|
+
const previous = matchText(patchedTexts.get(key) ?? '')
|
|
3954
|
+
const previousText = patchedTexts.get(key) ?? ''
|
|
3955
|
+
let best = null
|
|
3956
|
+
if (original !== '' || previous !== '') {
|
|
3957
|
+
// The owning row first (a host that draws the reasoning inside the
|
|
3958
|
+
// answer row), then the whole conversation: on this host the reasoning
|
|
3959
|
+
// is its own row, so the answer row holds no reasoning text at all.
|
|
3960
|
+
for (const scope of owner === undefined ? [null] : [owner, null]) {
|
|
3961
|
+
let bestScore = Infinity
|
|
3962
|
+
for (const el of containers) {
|
|
3963
|
+
if (el.getAttribute(PATCH_ATTR) !== null) continue
|
|
3964
|
+
if (scope !== null) {
|
|
3965
|
+
try {
|
|
3966
|
+
if (el.closest('[data-chat-flow-key]') !== scope) continue
|
|
3967
|
+
} catch {
|
|
3968
|
+
/* keep the global match */
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
const text = matchText(el.textContent)
|
|
3972
|
+
if (text === '') continue
|
|
3973
|
+
if (text !== original && !(previous !== '' && text === previous)) continue
|
|
3974
|
+
const score = Math.abs(text.length - original.length)
|
|
3975
|
+
if (score < bestScore) {
|
|
3976
|
+
best = el
|
|
3977
|
+
bestScore = score
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
if (best !== null) break
|
|
3662
3981
|
}
|
|
3982
|
+
if (best === null) best = findPatchTextFallback(owner ?? null, wanted, previousText)
|
|
3983
|
+
if (best === null) best = findReasoningElement(root, wanted, previousText)
|
|
3663
3984
|
}
|
|
3664
|
-
if (
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
const body = document.createElement('pre')
|
|
3671
|
-
body.className = 'dshet-patch-note-body'
|
|
3672
|
-
note.appendChild(summary)
|
|
3673
|
-
note.appendChild(body)
|
|
3985
|
+
if (best === null) {
|
|
3986
|
+
// No body in the DOM (the panel is closed): the header snippet is the
|
|
3987
|
+
// only place the edit can show.
|
|
3988
|
+
if (owner !== undefined) patchReasoningSummary(owner, wanted)
|
|
3989
|
+
patchReasoningSummary(root, wanted)
|
|
3990
|
+
continue
|
|
3674
3991
|
}
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3992
|
+
patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
|
|
3993
|
+
best.textContent = displayText(wanted.text)
|
|
3994
|
+
best.setAttribute(PATCH_ATTR, key)
|
|
3995
|
+
best.style.whiteSpace = 'pre-wrap'
|
|
3996
|
+
patchedElements.set(key, best)
|
|
3997
|
+
patchedTexts.set(key, wanted.text)
|
|
3998
|
+
if (owner !== undefined) patchReasoningSummary(owner, wanted)
|
|
3999
|
+
patchReasoningSummary(root, wanted)
|
|
3678
4000
|
}
|
|
3679
4001
|
}
|
|
3680
4002
|
|
|
@@ -4292,6 +4614,31 @@ window.__ModuleLoader__.load({
|
|
|
4292
4614
|
// A reply's edit entry lives in the official assistant-actions strip, one
|
|
4293
4615
|
// entry per assistant message. order 5 puts it ahead of the feedback
|
|
4294
4616
|
// entries (order 10), so the pencil sits with the leading icons.
|
|
4617
|
+
// The active skin repaints its surfaces at any time (switching one
|
|
4618
|
+
// rewrites body's attributes and inline variables), so the palette is
|
|
4619
|
+
// resolved once and then again on every theme or skin change.
|
|
4620
|
+
ctx.effect(() => {
|
|
4621
|
+
const run = () => {
|
|
4622
|
+
try {
|
|
4623
|
+
resolveSkinPalette()
|
|
4624
|
+
} catch {
|
|
4625
|
+
/* the stylesheet defaults stand */
|
|
4626
|
+
}
|
|
4627
|
+
}
|
|
4628
|
+
run()
|
|
4629
|
+
const observer =
|
|
4630
|
+
typeof MutationObserver === 'function' && document && document.body
|
|
4631
|
+
? new MutationObserver(run)
|
|
4632
|
+
: null
|
|
4633
|
+
if (observer !== null) {
|
|
4634
|
+
observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'style', 'data-ds-dark-theme'] })
|
|
4635
|
+
if (document.documentElement) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] })
|
|
4636
|
+
}
|
|
4637
|
+
return () => {
|
|
4638
|
+
if (observer !== null) observer.disconnect()
|
|
4639
|
+
}
|
|
4640
|
+
}, 'dsh-edit-turn:skin-palette')
|
|
4641
|
+
|
|
4295
4642
|
ctx.slots.inject('conversation.chat.assistant-actions', () =>
|
|
4296
4643
|
ctx.slots.register(
|
|
4297
4644
|
{
|
package/lib/index.js
CHANGED
|
@@ -80,7 +80,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
|
|
|
80
80
|
export const name = PLUGIN_ID
|
|
81
81
|
|
|
82
82
|
/** Keep in sync with package.json and lib/client.js. */
|
|
83
|
-
export const PLUGIN_VERSION = '0.5.
|
|
83
|
+
export const PLUGIN_VERSION = '0.5.2'
|
|
84
84
|
|
|
85
85
|
// A ring of the last few requests, answering "did the browser half even ask, and
|
|
86
86
|
// what did it get?" without needing its console. Read-only diagnostics: the
|
|
@@ -162,6 +162,10 @@ export const Config =
|
|
|
162
162
|
* replay splice otherwise; `patch` refuses to run without the
|
|
163
163
|
* projection; `replay` always uses the old path. Prompt edits always use
|
|
164
164
|
* the official surface replacement.
|
|
165
|
+
*
|
|
166
|
+
* There is deliberately no `replace` value: appending a fresh
|
|
167
|
+
* `assistant/message` with a replace op is refused by the session's own
|
|
168
|
+
* surface validation (see the note in the README).
|
|
165
169
|
*/
|
|
166
170
|
replyMode: z.string().default('auto'),
|
|
167
171
|
/**
|
|
@@ -1868,7 +1872,9 @@ async function applyTargetsEdit(ctx, sessionId, body, config) {
|
|
|
1868
1872
|
}
|
|
1869
1873
|
const events = await readEvents(ctx, sessionId)
|
|
1870
1874
|
if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
|
|
1871
|
-
|
|
1875
|
+
// No busy gate here: this path only appends the in-place patch record, which
|
|
1876
|
+
// leaves every surface node where it is. A running turn keeps writing its own
|
|
1877
|
+
// events; the record lands between them and the fold ignores it.
|
|
1872
1878
|
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
1873
1879
|
const patches = patchesOf(events)
|
|
1874
1880
|
const plans = []
|
|
@@ -1900,7 +1906,11 @@ async function applyTargetsEdit(ctx, sessionId, body, config) {
|
|
|
1900
1906
|
buildPatchData(plans.map(({ plan, text }) => ({ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }))),
|
|
1901
1907
|
)
|
|
1902
1908
|
} catch (error) {
|
|
1903
|
-
|
|
1909
|
+
const message = String((error && error.message) || error)
|
|
1910
|
+
if (isProjectionUnavailable(message)) {
|
|
1911
|
+
throw new HttpError(409, 'projection-missing', 'the edit-turn projection is not registered for this session; restart DSH so the owning plugin registers it, then retry')
|
|
1912
|
+
}
|
|
1913
|
+
throw new HttpError(409, 'stale', `the session refused the patch record: ${message}`)
|
|
1904
1914
|
}
|
|
1905
1915
|
const flush = await flushSession(ctx, session)
|
|
1906
1916
|
return {
|
|
@@ -1927,22 +1937,23 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1927
1937
|
}
|
|
1928
1938
|
const events = await readEvents(ctx, sessionId)
|
|
1929
1939
|
if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
|
|
1930
|
-
|
|
1940
|
+
const busy = isBusy(events)
|
|
1931
1941
|
|
|
1932
1942
|
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
1933
1943
|
const patches = patchesOf(events)
|
|
1934
1944
|
const patchMode = config.patch === true
|
|
1945
|
+
const request = {
|
|
1946
|
+
// A block from another step of the same turn names its own seq; that is
|
|
1947
|
+
// the message the projection has to patch.
|
|
1948
|
+
seq: typeof body.targetSeq === 'number' ? body.targetSeq : typeof body.seq === 'number' ? body.seq : undefined,
|
|
1949
|
+
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
1950
|
+
turn: typeof body.turn === 'number' ? body.turn : undefined,
|
|
1951
|
+
blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
|
|
1952
|
+
kind: typeof body.kind === 'string' ? body.kind : undefined,
|
|
1953
|
+
}
|
|
1935
1954
|
let plan
|
|
1936
1955
|
try {
|
|
1937
|
-
plan = planRollback(events, surfaceNodes, {
|
|
1938
|
-
// A block from another step of the same turn names its own seq; that is
|
|
1939
|
-
// the message the projection has to patch.
|
|
1940
|
-
seq: typeof body.targetSeq === 'number' ? body.targetSeq : typeof body.seq === 'number' ? body.seq : undefined,
|
|
1941
|
-
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
1942
|
-
turn: typeof body.turn === 'number' ? body.turn : undefined,
|
|
1943
|
-
blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
|
|
1944
|
-
kind: typeof body.kind === 'string' ? body.kind : undefined,
|
|
1945
|
-
}, { patches, patch: patchMode })
|
|
1956
|
+
plan = planRollback(events, surfaceNodes, request, { patches, patch: patchMode })
|
|
1946
1957
|
} catch (error) {
|
|
1947
1958
|
if (error instanceof EditPlanError) {
|
|
1948
1959
|
const status = error.code === 'not-editable' ? 400 : 409
|
|
@@ -1951,6 +1962,13 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1951
1962
|
throw error
|
|
1952
1963
|
}
|
|
1953
1964
|
|
|
1965
|
+
// A running turn is a hard stop for every path that moves the surface: the
|
|
1966
|
+
// prompt rewrite shadows a message the turn may still be reading, and the
|
|
1967
|
+
// replay splice shadows a window and regenerates the tail. The in-place record
|
|
1968
|
+
// below moves nothing, so it is allowed to land while the agent is still
|
|
1969
|
+
// writing - the model reads it on its next fold.
|
|
1970
|
+
if (busy && plan.patch !== true) throw new HttpError(409, 'busy', 'the session is still working')
|
|
1971
|
+
|
|
1954
1972
|
// The in-place reply path: one small plugin record, interpreted by this
|
|
1955
1973
|
// plugin's projection. Nothing leaves the surface, so there is no window to
|
|
1956
1974
|
// check, no turn to open and no tail to replay. The record is append-only and
|
|
@@ -1980,8 +1998,19 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1980
1998
|
buildPatchData([{ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }]),
|
|
1981
1999
|
)
|
|
1982
2000
|
} catch (error) {
|
|
1983
|
-
|
|
2001
|
+
const message = String((error && error.message) || error)
|
|
2002
|
+
// The projection is not registered for this session (a live reload that
|
|
2003
|
+
// dropped it). The same edit can land through the replay path, which
|
|
2004
|
+
// needs no projection - so fall through instead of failing the save.
|
|
2005
|
+
if (isProjectionUnavailable(message)) {
|
|
2006
|
+
// The fallback replays the tail, which a running turn forbids.
|
|
2007
|
+
if (busy) throw new HttpError(409, 'busy', 'the session is still working')
|
|
2008
|
+
plan = planRollback(events, surfaceNodes, request, { patches, patch: false })
|
|
2009
|
+
} else {
|
|
2010
|
+
throw new HttpError(409, 'stale', `the session refused the patch record: ${message}`)
|
|
2011
|
+
}
|
|
1984
2012
|
}
|
|
2013
|
+
if (plan.patch === true) {
|
|
1985
2014
|
const flush = await flushSession(ctx, session)
|
|
1986
2015
|
return {
|
|
1987
2016
|
kind: 'reply',
|
|
@@ -1999,6 +2028,7 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1999
2028
|
shadowed: [],
|
|
2000
2029
|
...flush,
|
|
2001
2030
|
}
|
|
2031
|
+
}
|
|
2002
2032
|
}
|
|
2003
2033
|
|
|
2004
2034
|
// The live surface is the append authority; a node that vanished between the
|
|
@@ -2433,9 +2463,25 @@ function requireSessionId(value) {
|
|
|
2433
2463
|
return value
|
|
2434
2464
|
}
|
|
2435
2465
|
|
|
2466
|
+
/**
|
|
2467
|
+
* Whether the host refused a plugin record because its projection is no longer
|
|
2468
|
+
* registered for this session (a live reload that dropped it, or a session
|
|
2469
|
+
* loaded before the owning plugin). Such a save can still go through the replay
|
|
2470
|
+
* path, so it is not fatal.
|
|
2471
|
+
*
|
|
2472
|
+
* @param message - the host's error text.
|
|
2473
|
+
* @returns true when the record's projection is gone.
|
|
2474
|
+
*/
|
|
2475
|
+
export function isProjectionUnavailable(message) {
|
|
2476
|
+
return typeof message === 'string' && /projection .*was removed or replaced/i.test(message)
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2436
2479
|
function failure(res, error) {
|
|
2437
2480
|
const status = error instanceof HttpError ? error.status : 500
|
|
2438
2481
|
const code = error instanceof HttpError ? error.code : 'internal'
|
|
2482
|
+
// An unexpected throw is the only case the client cannot explain; leave the
|
|
2483
|
+
// stack in the host log so a report can name the cause.
|
|
2484
|
+
if (!(error instanceof HttpError)) console.error('[dsh-edit-turn] internal error', error)
|
|
2439
2485
|
sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
|
|
2440
2486
|
}
|
|
2441
2487
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-edit-turn",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Edit any message in DeepSeek Harness in place: a prompt is replaced where it stands; a model reply block (answer text or reasoning) is patched through a plugin record and a message projection, so the turn stays where it is, later turns are untouched and the log grows by one small record. Falls back to the replay splice on hosts without the projection API. The append-only session log is never rewritten.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|