dsh-edit-turn 0.5.2 → 0.5.4
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 +27 -3
- package/lib/client.js +453 -93
- package/lib/index.js +99 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,7 +61,9 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
|
|
|
61
61
|
编辑器里,消息带的图片与文件**各显示成一个芯片**(缩略图或文件名 + 大小 + 删除按钮):不碰就原样保留,点删除就是不要它了;旁边还有一个「**添加附件**」(这条消息本来没有附件时也能用)。缩略图由本插件的只读路由按需取回;取不回来、或那些字节不是图片,芯片退回显示名字。**只有当这个部署没有附件存储时**,编辑器才会改回旧的那句话——「这条消息包含图片或文件附件,改写会丢弃它们,只保留文字」——并且不出现添加入口。
|
|
62
62
|
**一字不改就按保存,什么都不会发生。** 编辑器预填的就是这条消息**现在**的文本,所以「打开编辑器、不输入、直接保存」等于没提出任何修改:宿主不写任何事件(不落替换、不开合成轮次、不追加回答),编辑器照常关闭、**一条提示都不显示**(0.2.20 起;这条提示此前压在输入框的占位文字上)。会话日志、轮次导轨、轨迹视图都保持原样——此前它照样落一条替换(一次会话里三条),改回答时还会多出一整个轮次(用户只发了两条消息,轨迹视图里却有四轮)。**「重跑」不受这条守卫影响**:文本没变但用户点的是「重跑」,宿主同样不写,重跑照常发起(用户的意图就是让这一轮重新生成)。
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
**术语**:编辑器里的「**思维链**」「**正文**」「**全部**」三个界面叫**页**;「全部」页里的「思维链」「正文」条目叫**子页**(也叫**块**)。页与子页指的是同一份内容,所以**在任一页里改动、切到别的页再切回来,改过的内容都还在**(只有关掉编辑器才会丢);子页之间也是同一份草稿。
|
|
65
|
+
|
|
66
|
+
**编辑模型的回答**:悬停任意一条模型回复,同样会出现编辑入口。编辑器上方多一个**页选择器**:一条回复同时带思维链和正文时,可以选「思维链」「正文」页,或切到「全部」页一次看完整轮。改完点「保存」——记录只追加一条 `plugin:dsh-edit-turn/patch`,投影在模型下一次折叠时生效;**这条回答仍在原来的位置,后面的轮次原样不动**,工具调用和其他块也不动。转录里正文会就地显示改后的文字;思维链被改时会出现一条注记,展开就能看到模型实际读到的内容。
|
|
65
67
|
|
|
66
68
|
在退回重放路径的宿主上(或显式 `replyMode: replay`),行为仍是 0.2.21 的:整条回答被替换,后面的轮次回退再逐事件重放;这种模式下只能改正文,工具调用与思维链无法保留(编辑器会提示)。
|
|
67
69
|
|
|
@@ -276,7 +278,29 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
|
|
|
276
278
|
|
|
277
279
|
### 更新日志
|
|
278
280
|
|
|
279
|
-
**0.5.
|
|
281
|
+
**0.5.4** —— 设置卡导出给组合包(dsh-as-aistudio);会话 id 校验放宽,空会话按空状态处理。
|
|
282
|
+
|
|
283
|
+
- **会话 id 不再只认 UUID**:命名会话(例如 `bili-whale`,`~/.dsh/storages/<profile>/sessions/bili-whale.json`)是完全合法的会话,但这里的校验只接受 UUID 形式,于是对它的每个请求都被 400 拒掉 —— 面板上就是「状态加载失败,稍后重试。」。现在按 DSH 自己的语法(宿主侧 schema 就是 `z.string().min(1)`)放宽为 `/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/`,仍然挡住 `/`、`\`、`..`、空白与控制字符。
|
|
284
|
+
- **查不到会话按空状态处理**:没有日志的会话(刚建的命名会话)返回空状态而不是 404 —— 面板正常加载、显示"没有可操作的内容",日志一出现下一次刷新就接上。
|
|
285
|
+
- **设置卡导出**:`exports.ConfigCard`(就是 `EditorStyleOption` 那个组件)现在对外导出,于是聚合包 dsh-as-aistudio 能在自己的插件页上渲染**这同一张卡**,而不是照着重画一份。同一份设置、同一份存储键,两个入口不会漂移;单独装本插件时那张卡仍在本插件自己的行上。
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
**0.5.3** —— 编辑框随内容长高;隐藏兜底不再吞掉第三方插件的按钮。
|
|
289
|
+
|
|
290
|
+
- **换页不再丢草稿**:以前「正文」页写的内容只存在单块草稿里、「全部」页的子页各自另存一份,而且切页时草稿会被重置成记录原文 —— 于是「正文」页写个 1、切到「全部」页,子页里没有这个 1;切回来也没了。现在三页共用**一份按块索引的草稿**(`正文` 页与它在「全部」页里的子页是同一个块、同一个 key),任何一页里打的字,切到别的页、再切回来都还在;保存时发出的也是草稿(真页面复验:正文页写 `1` → 全部页正文子页显示 `1` → 在全部页改思维链子页 → 切回正文页仍是 `1` → 切到思维链页是 `thought draft` → 再切回正文页仍是 `1`)。
|
|
291
|
+
|
|
292
|
+
- **往下滚自动弹回顶部(主要问题)**:测量每个块的 textarea 要把它塌成 `height: auto`,整个文档随之变矮,浏览器就把滚动条的 `scrollTop` 夹到新的上限(≈0)—— 高度随后虽然恢复,**位置已经丢了**。宿主在流式回复时会不停重绘,所以表现为「滚到下面自动回上去」。实测:滚到底(`scrollTop 847 = max 847`)后触发一次布局,`scrollTop` 变 **0**。修法:新增 `withScrollKept(scroller, work)`,测量前后保存/恢复滚动位置;「全部」页的整体重测、逐块撑高、单块模式的测量全部走它。修后同样操作 `scrollTop` 保持 **847**。
|
|
293
|
+
- **最后一行滚不到**:同一次「设成 `auto` 再读 `scrollHeight`」的读取**永远少一行**(换行在写完样式之后才落定),元素被钉在偏小高度且 `overflow:hidden`,外层滚动条按这个值算高度 → 最后一行既看不见也滚不到(实测:一次读 2392、二次读 2415,行高 23px)。修法:抽出 `settleAreaHeight()`,重置后**读两次**再落定。
|
|
294
|
+
- **真页面复验**:正文灌到 1200 / 3000 / 6000 / 12000 字符,`clipped` 全为 0、滚动条都能到底、最后一行完整可见且停在底栏上方 8px;滚到底后连续两次布局,位置不动。
|
|
295
|
+
- **改写后的提示词气泡跟随 Markdown 渲染**:装了 `dsh-markdown-bubble` 时,改写后那条气泡(`.dshet-revision`)不再固定成纯文本,而是走宿主自己的 `MarkdownText`(用 `react-dom/client` 挂载 —— 宿主自己的包也是这么用的,已实测可行)。判据是那个插件自己的存在标记 `globalThis.__DSH_MARKDOWN_BUBBLE__`:**装了才渲染 Markdown,没装就保持纯文本**,因为没装时宿主画的其它用户气泡就是纯文本,只有跟着一致才不会出现「改写过的和没改写过的长得不一样」。理由与上游建议见 [deepseek-harness#10704](https://github.com/deepseek-ai/deepseek-harness/discussions/10704)。
|
|
296
|
+
- **编辑时框跟着内容长高**:以前框高在打开那一刻定死(「一轮最大」还带缓存),打字变多也不长 —— 内容超出后只能靠框内滚动条。现在:**打字变多就长高,删字就变矮,但不会比「打开时」更矮**(这道地板属于这一次编辑,切页也保留,保存或点叉才结束)。方向看空间:下面够就在下方长,下面不够就把底边钉在输入框卡片上沿、往上长(实测底边 680 / 输入框卡片上沿 688,留 8px 边距);整段都装不下就铺满对话区上下边界、由框内滚动。三档「高度」偏好只决定**打开时**多大,编辑中的跟随规则三档一致(设置页的说明文案已同步)。
|
|
297
|
+
- **隐藏兜底会吞掉第三方插件的按钮(issue #1,静默失败)**:折叠一条被改写的消息时,那段兜底是「除我自己的三个类名外,兄弟节点一律隐藏」。`dsh-rewind-pro` 这类插件把图标注入到同一条用户消息里(动作条旁边 / 插槽旁边),于是被一并隐藏 —— DOM 里在、点得到、屏幕上 `display:none`,**没有任何报错**,自动化测试(`querySelectorAll` 数得到元素)也全绿,所以很难发现。现在只隐藏**装着消息正文**的那个子树:兄弟节点里不含正文的一律不动(`messageContentRoot` 找不到宿主形状时仍退回旧行为,免得改写后的消息留在屏幕上)。回归测试里注入两个第三方节点(动作条旁、插槽旁),断言它们保持可见、且不带本插件的折叠标记 —— 改回旧写法该测试转红。
|
|
298
|
+
- **补上 `error.session-empty` 的中英文案**:宿主半部对「还没有内容的会话」会报这个 code,客户端字典里缺它,静态校验(`verify:client`)一直在红。
|
|
299
|
+
- **单块页(思维链 / 正文 / 用户输入)滚不动**:真因不是滚轮,而是**滚动位置活不过下一次布局** —— 给 textarea 赋 `value` 会把它的滚动位置扔回顶部(Chromium 实测:赋**同一个**字符串位置不变,赋**另一个**字符串位置归零,再赋回原值也回不来)。而「一轮最大」的高度测量正是把**同一轮其它块**的文字写进这个输入框来量的,于是每次**缓存未命中**的布局(第一次布局、切页、以及一轮里块还在变的时候——也就是模型流式输出期间)都会把读者扔回顶部。修法:测量前后保存/恢复 `scrollTop`。真页面复验:用户输入页灌 3000 字 → 滚到 400 → 强制重新测量(改宽度 + 输入)→ 仍是 **400**。
|
|
300
|
+
- **思维链页保存后立即渲染**(两层原因):① 宿主对一条思维链会保留**多份拷贝** —— 折叠时那一行留在 DOM 里但 `display:none`,展开面板时再渲染一份**可见的**正文;而补丁逻辑只要「记住的那个元素还连在文档里」就认为已经打过补丁并 `continue`,于是**用户正在看的那一份**始终是记录原文,直到整页刷新(刷新会从日志重新折叠,宿主自己就画对了)。现在这一支会**把所有拷贝一起打上**(记住的元素、所有带标记的元素、所有文本等于原文/上一次文本的元素,含 `findReasoningElements` 这个复数版搜索),实测同一次保存后 `data-dshet-patch` 出现两份 `335:0`。② 一条回复的块列表是**整轮**的(宿主把同一轮里每一步的思维链和正文都挂在回答行上,每块带自己的 `seq`),于是同一份列表里会有多个 `index` 相同的块(第一步的思维链和第四步的思维链都是 `index 0`)。以前两处都只按 `index` 找块:保存后本地那条「乐观」补丁还按**回答行的 seq** 记,渲染时又用 `find(index === …)` 命中**第一个**同 index 的块 —— 两处都拿错了 `original`,于是补丁在 DOM 里匹配不到任何东西,思维链正文要等刷新才变。现在补丁按**块自己的 seq** 记,查块按 **(seq, index)** 查,并顺着块找到**承载它的那条回复条目**(后一步的块住在以回答行 seq 为键的条目里)。
|
|
301
|
+
- **测试**:`npm test` **254/254**(新增六例:补丁只落在目标步的思维链上;保存后思维链正文立即变;后开的思考面板同样显示改写;测量后 textarea 的滚动位置不变;编辑时随内容长高、删字只矮回打开时的高度;下方没空间时向上长而不压输入框 —— 六例在改回旧写法时都会失败)。(新增五例:换页保留草稿;模拟「第一次读少一行」的 textarea 断言最终高度;模拟浏览器夹滚动条断言测量后位置不变;装了 markdown 插件时改写气泡走宿主渲染器、没装时保持纯文本 —— 把修法还原成旧写法时都会失败)。
|
|
302
|
+
|
|
303
|
+
**0.5.2** —— 回复编辑不再等这一轮回复结束;编辑器跟着当前皮肤走。
|
|
280
304
|
|
|
281
305
|
- **回复编辑不再被「正在回复中」挡住**:就地记录(patch)只 append 一条 `ignorable` 事件,不改任何 surface 节点,所以它可以在 agent 还在写的时候落盘 —— 模型下一次折叠就读到。宿主侧只在**会移动 surface** 的路径上保留忙时拒绝:改写提示词(surface replace)和 replay 兜底(遮蔽窗口 + 重放尾部)。客户端同步:回复就地编辑不再禁用保存、不再显示「该会话正在回复中」横幅;提示词编辑与 replay 路径照旧拦下并说明。
|
|
282
306
|
- **测试**:回复编辑在未闭合的 turn 中保存成功、提示词改写仍被拒、整轮多块在忙时保存成功。
|
|
@@ -288,7 +312,7 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
|
|
|
288
312
|
- **改写后的提示词气泡用皮肤自己的气泡色**(`--dsw-specific-bubble`),圆角用 `--dsw-radius-xl`,与真实用户气泡一致。
|
|
289
313
|
- **测试**:`npm test` **241/241**、`npm run verify:contract` **96** 项、`npm run verify:client` **192** 项。
|
|
290
314
|
|
|
291
|
-
**0.5.1
|
|
315
|
+
**0.5.1** —— 思维链就地画出来、保存不再“点不动”、同一个块可以反复编辑。
|
|
292
316
|
|
|
293
317
|
- **删掉「思维链已改写(模型可见)」卡片**:reasoning 正文只要在 DOM 里就就地改写;没有正文时不再插入任何占位卡片。
|
|
294
318
|
- **无限次编辑**:记录「上一次画上去的文本」,第二、第三次编辑同一个块时仍能找到元素并更新。
|
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.4'
|
|
49
49
|
|
|
50
50
|
// --- copy -----------------------------------------------------------------
|
|
51
51
|
|
|
@@ -78,9 +78,9 @@ window.__ModuleLoader__.load({
|
|
|
78
78
|
'config.summary.height.max': '一轮最大',
|
|
79
79
|
'config.summary.height.content': '随内容',
|
|
80
80
|
'config.summary.height.fill': '全部铺满',
|
|
81
|
-
'config.hint.height.max': '
|
|
82
|
-
'config.hint.height.content': '
|
|
83
|
-
'config.hint.height.fill': '
|
|
81
|
+
'config.hint.height.max': '打开时取这一轮所有思维链/正文里的最大高度;编辑中随你打字长高、删字变矮,但不会比打开时更矮。',
|
|
82
|
+
'config.hint.height.content': '打开时框高跟着当前块走;编辑中随你打字长高、删字变矮,但不会比打开时更矮。',
|
|
83
|
+
'config.hint.height.fill': '普通块打开时按一轮最大,编辑中随打字长高、删字变矮(不会比打开时更矮);选「全部」时铺满对话区到输入框之间的整段高度,只有一个滚动条。',
|
|
84
84
|
'placement.overlay': '浮层',
|
|
85
85
|
'placement.inline': '页面内联',
|
|
86
86
|
'placement.legacy': '整行旧版',
|
|
@@ -114,6 +114,7 @@ window.__ModuleLoader__.load({
|
|
|
114
114
|
'error.invalid': '请求无效,请刷新后重试。',
|
|
115
115
|
'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
|
|
116
116
|
'error.session-not-found': '找不到该会话的日志。',
|
|
117
|
+
'error.session-empty': '这个会话还没有内容,暂时没有可编辑的东西。',
|
|
117
118
|
'error.busy': '该会话正在回复中,请等回复结束后再编辑。',
|
|
118
119
|
'error.not-editable': '这条消息不支持编辑。',
|
|
119
120
|
'error.unsupported': '当前宿主不支持就地改写回复,需要走重放路径。',
|
|
@@ -164,9 +165,9 @@ window.__ModuleLoader__.load({
|
|
|
164
165
|
'config.summary.height.max': 'Turn max',
|
|
165
166
|
'config.summary.height.content': 'Content',
|
|
166
167
|
'config.summary.height.fill': 'Fill when all',
|
|
167
|
-
'config.hint.height.max': '
|
|
168
|
-
'config.hint.height.content': '
|
|
169
|
-
'config.hint.height.fill': 'Single blocks
|
|
168
|
+
'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.',
|
|
169
|
+
'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.',
|
|
170
|
+
'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.',
|
|
170
171
|
'placement.overlay': 'Overlay',
|
|
171
172
|
'placement.inline': 'In-page',
|
|
172
173
|
'placement.legacy': 'Legacy row',
|
|
@@ -200,6 +201,7 @@ window.__ModuleLoader__.load({
|
|
|
200
201
|
'error.invalid': 'Invalid request; refresh and try again.',
|
|
201
202
|
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
202
203
|
'error.session-not-found': 'No session log was found for this id.',
|
|
204
|
+
'error.session-empty': 'This session has nothing in it yet; there is nothing to edit.',
|
|
203
205
|
'error.busy': 'This session is still replying; wait for it to finish.',
|
|
204
206
|
'error.not-editable': 'This message cannot be edited.',
|
|
205
207
|
'error.unsupported': 'This harness does not expose the in-place reply path; the replay path is needed.',
|
|
@@ -236,6 +238,70 @@ window.__ModuleLoader__.load({
|
|
|
236
238
|
return t(typeof code === 'string' && ERROR_KEYS.has(`error.${code}`) ? `error.${code}` : 'error.generic')
|
|
237
239
|
}
|
|
238
240
|
|
|
241
|
+
// --- a rewritten prompt, rendered the way the bubble it stands in for is ---
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Whether the markdown bubble plugin is on this page.
|
|
245
|
+
*
|
|
246
|
+
* Its own presence marker is the only honest signal: the host draws a user
|
|
247
|
+
* bubble as plain text (`white-space: pre-wrap`), so rendering markdown in
|
|
248
|
+
* the revision bubble is only correct when something else is already
|
|
249
|
+
* rendering the OTHER user bubbles that way. Without it, a rewritten prompt
|
|
250
|
+
* would look different from every untouched prompt around it.
|
|
251
|
+
*/
|
|
252
|
+
function markdownBubbleInstalled() {
|
|
253
|
+
try {
|
|
254
|
+
return typeof globalThis !== 'undefined' && globalThis.__DSH_MARKDOWN_BUBBLE__ !== undefined
|
|
255
|
+
} catch {
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** One mounted markdown occurrence per bubble, so a rebuild can unmount it. */
|
|
261
|
+
const revisionRoots = new WeakMap()
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Draw one rewritten prompt's text into `host`.
|
|
265
|
+
*
|
|
266
|
+
* The host's own markdown renderer (`MarkdownText`) is a React component and
|
|
267
|
+
* this half builds DOM by hand, so it is mounted through `react-dom/client` -
|
|
268
|
+
* the same module the host's own bundles require. Anything missing (no
|
|
269
|
+
* react-dom, no primitives, a hostile host shape) falls back to the plain
|
|
270
|
+
* text node this bubble has always used.
|
|
271
|
+
*
|
|
272
|
+
* @param host - the bubble element that shows the text.
|
|
273
|
+
* @param text - the rewritten text.
|
|
274
|
+
* @returns whether the markdown path was used.
|
|
275
|
+
*/
|
|
276
|
+
function renderRevisionText(host, text) {
|
|
277
|
+
if (!markdownBubbleInstalled()) return false
|
|
278
|
+
try {
|
|
279
|
+
const react = require('react')
|
|
280
|
+
const reactDom = require('react-dom/client')
|
|
281
|
+
const MarkdownText = primitives.MarkdownText
|
|
282
|
+
if (
|
|
283
|
+
typeof react?.createElement !== 'function' ||
|
|
284
|
+
typeof reactDom?.createRoot !== 'function' ||
|
|
285
|
+
MarkdownText === undefined ||
|
|
286
|
+
MarkdownText === null
|
|
287
|
+
) {
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
const previous = revisionRoots.get(host)
|
|
291
|
+
if (previous !== undefined && typeof previous.unmount === 'function') previous.unmount()
|
|
292
|
+
const node = document.createElement('span')
|
|
293
|
+
node.className = 'dshet-revision-text'
|
|
294
|
+
host.appendChild(node)
|
|
295
|
+
const root = reactDom.createRoot(node)
|
|
296
|
+
root.render(react.createElement(MarkdownText, { text }))
|
|
297
|
+
revisionRoots.set(host, root)
|
|
298
|
+
return true
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.warn('[dsh-edit-turn] the rewritten prompt could not be rendered as markdown:', error)
|
|
301
|
+
return false
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
239
305
|
// --- style ----------------------------------------------------------------
|
|
240
306
|
/**
|
|
241
307
|
* Take the active skin's own surfaces when they are actually opaque.
|
|
@@ -574,6 +640,8 @@ window.__ModuleLoader__.load({
|
|
|
574
640
|
this.editorStyle = 'frame'
|
|
575
641
|
this.editorFocus = false
|
|
576
642
|
this.editorDraft = ''
|
|
643
|
+
this.editorBlockKey = ''
|
|
644
|
+
this.editorDrafts = new Map()
|
|
577
645
|
// The block list being edited: what the carrier will be rebuilt from.
|
|
578
646
|
// Every entry is either `{ keep: <index> }` - a block of this message the
|
|
579
647
|
// user left alone - or `{ add: { data, mediaType?, name } }` for
|
|
@@ -802,8 +870,16 @@ window.__ModuleLoader__.load({
|
|
|
802
870
|
this.editorBlockKind = chosen === undefined ? null : chosen.kind
|
|
803
871
|
this.editorTargetSeq = chosen !== undefined && typeof chosen.seq === 'number' ? chosen.seq : null
|
|
804
872
|
this.editorAll = false
|
|
805
|
-
|
|
873
|
+
// The height the box opens at: the floor the user cannot shrink below
|
|
874
|
+
// for as long as this edit lasts (save or cancel ends it). Null until
|
|
875
|
+
// the first layout measures it.
|
|
876
|
+
this.editorBaseHeight = null
|
|
877
|
+
// ONE draft store per open, keyed by block: the 正文/思维链 pages and the
|
|
878
|
+
// sub-pages inside 全部 all read and write it, so switching pages never
|
|
879
|
+
// drops what the user typed (only closing the editor does).
|
|
880
|
+
this.editorDrafts = new Map()
|
|
806
881
|
if (!same) {
|
|
882
|
+
this.editorBlockKey = chosen === undefined ? '' : blockDraftKey(chosen)
|
|
807
883
|
this.editorDraft = chosen === undefined ? target.text : chosen.text
|
|
808
884
|
this.editorParts = blocksToParts(normalized, this.sessionId)
|
|
809
885
|
}
|
|
@@ -870,8 +946,9 @@ window.__ModuleLoader__.load({
|
|
|
870
946
|
if (target === null || !Array.isArray(target.blocks)) return
|
|
871
947
|
const raw = String(value)
|
|
872
948
|
if (raw === 'all') {
|
|
949
|
+
// The all-blocks page reads the SAME drafts: a block edited on its own
|
|
950
|
+
// page shows that edit in its sub-page, and the other way round.
|
|
873
951
|
this.editorAll = true
|
|
874
|
-
this.editorAllDrafts = new Map(target.blocks.map((block) => [`${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`, block.text]))
|
|
875
952
|
this.editorElement = null
|
|
876
953
|
this.publish({ allBlocks: true, blockIndex: null, blockKind: null, targetSeq: null, failure: null })
|
|
877
954
|
return
|
|
@@ -894,9 +971,12 @@ window.__ModuleLoader__.load({
|
|
|
894
971
|
this.editorBlockIndex = block.index
|
|
895
972
|
this.editorBlockKind = block.kind
|
|
896
973
|
this.editorTargetSeq = typeof block.seq === 'number' ? block.seq : this.editorTargetSeq
|
|
897
|
-
this.
|
|
974
|
+
this.editorBlockKey = blockDraftKey(block)
|
|
975
|
+
// This block's own draft, which may already carry what the user typed on
|
|
976
|
+
// another page - never the recorded text, which used to wipe it.
|
|
977
|
+
this.editorDraft = this.draftOfBlock(block)
|
|
898
978
|
this.editorElement = null
|
|
899
|
-
this.publish({ draft:
|
|
979
|
+
this.publish({ draft: this.editorDraft, blockIndex: block.index, blockKind: block.kind, targetSeq: this.editorTargetSeq, allBlocks: false, failure: null })
|
|
900
980
|
}
|
|
901
981
|
|
|
902
982
|
close() {
|
|
@@ -918,12 +998,40 @@ window.__ModuleLoader__.load({
|
|
|
918
998
|
this.editorBlockKind = null
|
|
919
999
|
this.editorTargetSeq = null
|
|
920
1000
|
this.editorAll = false
|
|
921
|
-
this.
|
|
1001
|
+
this.editorDrafts = new Map()
|
|
1002
|
+
this.editorBlockKey = ''
|
|
1003
|
+
this.editorBaseHeight = null
|
|
922
1004
|
this.publish({ editing: null, draft: '', parts: [], blocks: [], blockIndex: null, blockKind: null, confirming: false, failure: null })
|
|
923
1005
|
}
|
|
924
1006
|
|
|
1007
|
+
/** The draft of one block: what the user typed, else the recorded text. */
|
|
1008
|
+
draftOfBlock(block) {
|
|
1009
|
+
const drafts = this.editorDrafts
|
|
1010
|
+
if (drafts instanceof Map) {
|
|
1011
|
+
const key = blockDraftKey(block)
|
|
1012
|
+
const typed = drafts.get(key)
|
|
1013
|
+
if (typeof typed === 'string') return typed
|
|
1014
|
+
}
|
|
1015
|
+
return block !== null && block !== undefined && typeof block.text === 'string' ? block.text : ''
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Remember one block's draft, wherever the user typed it. */
|
|
1019
|
+
setDraftFor(block, value) {
|
|
1020
|
+
if (!(this.editorDrafts instanceof Map)) this.editorDrafts = new Map()
|
|
1021
|
+
this.editorDrafts.set(blockDraftKey(block), String(value ?? ''))
|
|
1022
|
+
}
|
|
1023
|
+
|
|
925
1024
|
setDraft(value) {
|
|
926
1025
|
this.editorDraft = value
|
|
1026
|
+
// The 正文/思维链 page and the matching sub-page inside 全部 are the same
|
|
1027
|
+
// block: one store, so the edit survives a page switch.
|
|
1028
|
+
const blocks = Array.isArray(this.editorBlocks) ? this.editorBlocks : []
|
|
1029
|
+
const current =
|
|
1030
|
+
blocks.find((block) => blockDraftKey(block) === this.editorBlockKey) ??
|
|
1031
|
+
(typeof this.editorBlockIndex === 'number'
|
|
1032
|
+
? blocks.find((block) => block.index === this.editorBlockIndex && (this.editorBlockKind === null || block.kind === this.editorBlockKind))
|
|
1033
|
+
: undefined)
|
|
1034
|
+
if (current !== undefined) this.setDraftFor(current, value)
|
|
927
1035
|
}
|
|
928
1036
|
|
|
929
1037
|
setEditorStyle(style) {
|
|
@@ -1001,11 +1109,9 @@ window.__ModuleLoader__.load({
|
|
|
1001
1109
|
/** The all-blocks save: one request carrying every edited block. */
|
|
1002
1110
|
async confirmAll(target) {
|
|
1003
1111
|
const blocks = Array.isArray(target.blocks) ? target.blocks : []
|
|
1004
|
-
const drafts = this.editorAllDrafts instanceof Map ? this.editorAllDrafts : new Map()
|
|
1005
1112
|
const targets = []
|
|
1006
1113
|
for (const block of blocks) {
|
|
1007
|
-
const
|
|
1008
|
-
const text = drafts.get(key)
|
|
1114
|
+
const text = this.draftOfBlock(block)
|
|
1009
1115
|
if (typeof text !== 'string' || text.trim() === '') {
|
|
1010
1116
|
this.publish({ failure: 'empty' })
|
|
1011
1117
|
return
|
|
@@ -1040,7 +1146,7 @@ window.__ModuleLoader__.load({
|
|
|
1040
1146
|
this.editor = null
|
|
1041
1147
|
this.editorDraft = ''
|
|
1042
1148
|
this.editorAll = false
|
|
1043
|
-
this.
|
|
1149
|
+
this.editorDrafts = new Map()
|
|
1044
1150
|
this.editorElement = null
|
|
1045
1151
|
this.editorBox = null
|
|
1046
1152
|
this.editorRow = null
|
|
@@ -1177,8 +1283,17 @@ window.__ModuleLoader__.load({
|
|
|
1177
1283
|
let patches = this.view.patches
|
|
1178
1284
|
let patchBySeq = this.view.patchBySeq
|
|
1179
1285
|
if (data.mode === 'patch' && data.target && typeof data.target.blockIndex === 'number') {
|
|
1286
|
+
// The BLOCK's own seq, not the reply's. A turn's reasoning can live
|
|
1287
|
+
// in another step's message, and the host reports that seq in the
|
|
1288
|
+
// answer. Keying the optimistic patch by the reply's seq looked up
|
|
1289
|
+
// the wrong block, took the wrong `original`, and the patch then
|
|
1290
|
+
// matched nothing in the DOM - the edit appeared only after a reload,
|
|
1291
|
+
// when /state supplied it under its own seq (reported from the field
|
|
1292
|
+
// for the 思维链 page; the 正文 page looked fine because its block IS
|
|
1293
|
+
// the reply's own).
|
|
1294
|
+
const patchSeq = typeof data.target.seq === 'number' ? data.target.seq : target.seq
|
|
1180
1295
|
const targetPatch = {
|
|
1181
|
-
seq:
|
|
1296
|
+
seq: patchSeq,
|
|
1182
1297
|
blockIndex: data.target.blockIndex,
|
|
1183
1298
|
kind: data.target.kind === 'reasoning' ? 'reasoning' : 'text',
|
|
1184
1299
|
text: String(data.target.text ?? ''),
|
|
@@ -1190,10 +1305,21 @@ window.__ModuleLoader__.load({
|
|
|
1190
1305
|
const byBlock = new Map(patchBySeq.get(targetPatch.seq) ?? [])
|
|
1191
1306
|
byBlock.set(targetPatch.blockIndex, targetPatch)
|
|
1192
1307
|
patchBySeq.set(targetPatch.seq, byBlock)
|
|
1193
|
-
|
|
1308
|
+
// The entry that carries this block: the reply's own, or the one
|
|
1309
|
+
// whose block list holds another step's reasoning.
|
|
1310
|
+
let entry = replies.get(patchSeq)
|
|
1311
|
+
if (entry === undefined || !Array.isArray(entry.blocks)) {
|
|
1312
|
+
for (const candidate of replies.values()) {
|
|
1313
|
+
if (Array.isArray(candidate?.blocks) && candidate.blocks.some((block) => block.seq === patchSeq)) {
|
|
1314
|
+
entry = candidate
|
|
1315
|
+
break
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1194
1319
|
if (entry !== undefined && Array.isArray(entry.blocks)) {
|
|
1195
1320
|
const blocks = entry.blocks.map((block) =>
|
|
1196
|
-
block.index === targetPatch.blockIndex
|
|
1321
|
+
block.index === targetPatch.blockIndex &&
|
|
1322
|
+
(block.seq === undefined || block.seq === patchSeq)
|
|
1197
1323
|
? { ...block, text: targetPatch.text, patched: true }
|
|
1198
1324
|
: block)
|
|
1199
1325
|
const text = blocks.filter((block) => block.kind === 'text').map((block) => block.text).join('\n')
|
|
@@ -1623,15 +1749,23 @@ window.__ModuleLoader__.load({
|
|
|
1623
1749
|
// like the images were deleted.
|
|
1624
1750
|
const signature = text + '|' + extraBlocks.map((block) =>
|
|
1625
1751
|
`${String(block.index)}:${String(block.type)}:${String(block.name ?? '')}:${String(block.bytes ?? '')}`).join(',')
|
|
1626
|
-
|
|
1752
|
+
// The signature carries whether the markdown path was used: installing or
|
|
1753
|
+
// removing the markdown bubble plugin must redraw this bubble too.
|
|
1754
|
+
const markdownSignature = signature + '|md:' + (markdownBubbleInstalled() ? '1' : '0')
|
|
1755
|
+
if (bubble.dataset.dshetRevisionContent !== markdownSignature) {
|
|
1756
|
+
const previous = revisionRoots.get(bubble)
|
|
1757
|
+
if (previous !== undefined && typeof previous.unmount === 'function') previous.unmount()
|
|
1758
|
+
revisionRoots.delete(bubble)
|
|
1627
1759
|
bubble.textContent = ''
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1760
|
+
if (!renderRevisionText(bubble, text)) {
|
|
1761
|
+
const textNode = document.createElement('span')
|
|
1762
|
+
textNode.className = 'dshet-revision-text'
|
|
1763
|
+
textNode.textContent = text
|
|
1764
|
+
bubble.appendChild(textNode)
|
|
1765
|
+
}
|
|
1632
1766
|
const files = revisionAttachments(entry, controller)
|
|
1633
1767
|
if (files !== null) bubble.appendChild(files)
|
|
1634
|
-
bubble.dataset.dshetRevisionContent =
|
|
1768
|
+
bubble.dataset.dshetRevisionContent = markdownSignature
|
|
1635
1769
|
}
|
|
1636
1770
|
// The edit entry goes where every other action of this row lives: the
|
|
1637
1771
|
// platform's own bar, which the collapse keeps standing, at its right end
|
|
@@ -2034,9 +2168,39 @@ window.__ModuleLoader__.load({
|
|
|
2034
2168
|
// `_actions` suffix is stable) and hides that strip's ancestors' other
|
|
2035
2169
|
// children up to the row. Returns false when there is no strip to keep, and
|
|
2036
2170
|
// false is what tells the caller to hide the whole row instead.
|
|
2171
|
+
/**
|
|
2172
|
+
* The host's own message markup inside a row - the stack / bubble / markdown
|
|
2173
|
+
* the transcript draws the text in. That subtree is the ONLY thing the
|
|
2174
|
+
* collapse walk may hide: a sibling that does not contain it belongs to
|
|
2175
|
+
* somebody else (another plugin's injected button, a portal), and hiding it
|
|
2176
|
+
* is issue #1 - `dsh-rewind-pro`'s button stayed in the DOM, stayed
|
|
2177
|
+
* clickable, and was `display:none` on screen with no console error to
|
|
2178
|
+
* notice. Null when the host shape is not one we recognise; the walk then
|
|
2179
|
+
* keeps its old "everything but ours" behaviour instead of leaving a
|
|
2180
|
+
* rewritten prompt on screen.
|
|
2181
|
+
*/
|
|
2182
|
+
function messageContentRoot(row) {
|
|
2183
|
+
const selectors = ['[class*="_bubble"]', '[class*="userStack"]', '[class*="_markdown"]', '[class*="_stack"]', '[class*="messageBody"]']
|
|
2184
|
+
for (const selector of selectors) {
|
|
2185
|
+
let found = null
|
|
2186
|
+
try {
|
|
2187
|
+
found = row.querySelector(selector)
|
|
2188
|
+
} catch {
|
|
2189
|
+
found = null
|
|
2190
|
+
}
|
|
2191
|
+
if (found === null || found === undefined) continue
|
|
2192
|
+
if (typeof found.closest === 'function' && found.closest('.dshet-editor') !== null) continue
|
|
2193
|
+
return found
|
|
2194
|
+
}
|
|
2195
|
+
return null
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2037
2198
|
function collapseRowContent(row, hide) {
|
|
2038
2199
|
const strip = row.querySelector('[class*="_actions"]')
|
|
2039
2200
|
if (strip === null) return false
|
|
2201
|
+
// Only consulted while hiding: the restore pass goes by the marker this
|
|
2202
|
+
// plugin wrote, so a node an older build had hidden is still restored.
|
|
2203
|
+
const contentRoot = hide ? messageContentRoot(row) : null
|
|
2040
2204
|
let node = strip
|
|
2041
2205
|
while (node !== null && node !== row) {
|
|
2042
2206
|
const parent = node.parentElement
|
|
@@ -2056,6 +2220,16 @@ window.__ModuleLoader__.load({
|
|
|
2056
2220
|
) {
|
|
2057
2221
|
continue
|
|
2058
2222
|
}
|
|
2223
|
+
// I5 / issue #1: another plugin's node beside the message is not ours
|
|
2224
|
+
// to hide. Only a sibling that holds the message content is.
|
|
2225
|
+
if (
|
|
2226
|
+
hide &&
|
|
2227
|
+
contentRoot !== null &&
|
|
2228
|
+
typeof child.contains === 'function' &&
|
|
2229
|
+
child.contains(contentRoot) !== true
|
|
2230
|
+
) {
|
|
2231
|
+
continue
|
|
2232
|
+
}
|
|
2059
2233
|
if (hide) {
|
|
2060
2234
|
// Written only when the value changes: this runs on every DOM pass,
|
|
2061
2235
|
// and an attribute write the observer can see would never settle.
|
|
@@ -2544,7 +2718,7 @@ window.__ModuleLoader__.load({
|
|
|
2544
2718
|
return bounds
|
|
2545
2719
|
}
|
|
2546
2720
|
|
|
2547
|
-
function placeEditor(row, box, mode = 'reply', style = 'frame', blockKind = null, blocks = null) {
|
|
2721
|
+
function placeEditor(row, box, mode = 'reply', style = 'frame', blockKind = null, blocks = null, controller = null) {
|
|
2548
2722
|
const layer = ensureEditorLayer()
|
|
2549
2723
|
if (box.parentElement !== layer) layer.appendChild(box)
|
|
2550
2724
|
const placement = readStoredEditorPlacement() ?? 'overlay'
|
|
@@ -2718,11 +2892,23 @@ window.__ModuleLoader__.load({
|
|
|
2718
2892
|
try {
|
|
2719
2893
|
const previousValue = area.value
|
|
2720
2894
|
const previousHeight = area.style.height
|
|
2895
|
+
// Assigning `value` drops a textarea's scroll position to the top -
|
|
2896
|
+
// even when the assignment is only a measuring detour. Verified in
|
|
2897
|
+
// Chromium: the SAME string keeps the scroll, a DIFFERENT string
|
|
2898
|
+
// resets it to 0, and putting the original back does not bring it
|
|
2899
|
+
// back. The "turn max" pass below writes every other block's text into
|
|
2900
|
+
// this field, so every cache-miss layout (the first one, a page
|
|
2901
|
+
// switch, and every pass while the turn's blocks change - i.e. while
|
|
2902
|
+
// the model streams) threw the reader back to the top. That is what
|
|
2903
|
+
// "the 思维链/正文 page cannot be scrolled down" was: the wheel worked,
|
|
2904
|
+
// the position did not survive the next layout.
|
|
2905
|
+
const previousScroll = typeof area.scrollTop === 'number' ? area.scrollTop : 0
|
|
2721
2906
|
const measure = (text) => {
|
|
2722
2907
|
area.value = text
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2908
|
+
// The settled height: one pass reads a line short (see
|
|
2909
|
+
// settleAreaHeight), and this number decides the whole box. The
|
|
2910
|
+
// scroller is the box itself, so its position is kept too.
|
|
2911
|
+
return Math.round(withScrollKept(box, () => settleAreaHeight(area)))
|
|
2726
2912
|
}
|
|
2727
2913
|
const own = measure(previousValue)
|
|
2728
2914
|
if (own > 0) contentHeight = own
|
|
@@ -2748,11 +2934,26 @@ window.__ModuleLoader__.load({
|
|
|
2748
2934
|
}
|
|
2749
2935
|
area.value = previousValue
|
|
2750
2936
|
area.style.height = previousHeight
|
|
2937
|
+
if (typeof area.scrollTop === 'number' && area.scrollTop !== previousScroll) {
|
|
2938
|
+
area.scrollTop = previousScroll
|
|
2939
|
+
}
|
|
2751
2940
|
} catch {}
|
|
2752
2941
|
}
|
|
2753
2942
|
// Every mode hugs the measured content; `coveredHeight` is only a
|
|
2754
2943
|
// fallback for a host whose textarea cannot be measured.
|
|
2755
|
-
|
|
2944
|
+
// While the editor is open its height FOLLOWS the draft: more lines grow
|
|
2945
|
+
// the box, deleting lines shrinks it - but never below the height it had
|
|
2946
|
+
// when it opened, and never past the composer below or the conversation
|
|
2947
|
+
// top above. The floor belongs to the whole edit (a page switch keeps it)
|
|
2948
|
+
// and only a save or a cancel ends it.
|
|
2949
|
+
const baseHeight =
|
|
2950
|
+
controller !== null &&
|
|
2951
|
+
controller !== undefined &&
|
|
2952
|
+
typeof controller.editorBaseHeight === 'number' &&
|
|
2953
|
+
controller.editorBaseHeight > 0
|
|
2954
|
+
? controller.editorBaseHeight
|
|
2955
|
+
: null
|
|
2956
|
+
let height = Math.max(minimumHeight, contentHeight + headerOffset + FOOTER_BAND, baseHeight === null ? 0 : baseHeight)
|
|
2756
2957
|
let clampedTop = null
|
|
2757
2958
|
if (clamped) {
|
|
2758
2959
|
const bounds = conversationBounds(row)
|
|
@@ -2763,14 +2964,32 @@ window.__ModuleLoader__.load({
|
|
|
2763
2964
|
// fills the WHOLE band (not just the message's own position), so the
|
|
2764
2965
|
// top touches the conversation edge and the bottom the composer.
|
|
2765
2966
|
const fillAll = allBox !== null && allBox !== undefined && heightMode !== 'content'
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2967
|
+
const band = bottomLimit - topLimit
|
|
2968
|
+
if (fillAll) {
|
|
2969
|
+
clampedTop = topLimit
|
|
2970
|
+
height = Math.max(minimumHeight, band)
|
|
2971
|
+
} else if (height <= band) {
|
|
2972
|
+
// It fits: grow DOWN from the row while there is room for it, and
|
|
2973
|
+
// when there is not, keep the bottom at the composer edge and grow
|
|
2974
|
+
// UP instead - the direction follows the space, the box never covers
|
|
2975
|
+
// the composer and never leaves the conversation's top.
|
|
2976
|
+
const room = bottomLimit - Math.max(base.top, topLimit)
|
|
2977
|
+
clampedTop = height <= room ? Math.max(base.top, topLimit) : Math.max(topLimit, bottomLimit - height)
|
|
2978
|
+
height = Math.min(height, band)
|
|
2979
|
+
} else {
|
|
2980
|
+
// Taller than the whole band: fill it and let the field scroll.
|
|
2981
|
+
clampedTop = topLimit
|
|
2982
|
+
height = Math.max(minimumHeight, band)
|
|
2983
|
+
}
|
|
2770
2984
|
}
|
|
2771
2985
|
}
|
|
2772
2986
|
if (!inline && !clamped && viewport.height > 0) height = Math.min(height, Math.max(minimumHeight, viewport.height - margin * 2))
|
|
2773
2987
|
height = Math.max(minimumHeight, height)
|
|
2988
|
+
if (baseHeight === null && controller !== null && controller !== undefined) {
|
|
2989
|
+
// The first layout of this open fixes the floor: from here on the box
|
|
2990
|
+
// may grow and shrink with the draft, but not below this.
|
|
2991
|
+
controller.editorBaseHeight = height
|
|
2992
|
+
}
|
|
2774
2993
|
box.style.height = `${height}px`
|
|
2775
2994
|
box.style.maxHeight = ''
|
|
2776
2995
|
let top = clampedTop === null ? base.top : clampedTop
|
|
@@ -2794,11 +3013,9 @@ window.__ModuleLoader__.load({
|
|
|
2794
3013
|
// is exactly as tall as its text and the ONE scrollbar appears when it
|
|
2795
3014
|
// overflows the band.
|
|
2796
3015
|
if (typeof allBox.querySelectorAll === 'function') {
|
|
2797
|
-
|
|
2798
|
-
blockArea
|
|
2799
|
-
|
|
2800
|
-
if (typeof measured === 'number' && measured > 0) blockArea.style.height = `${measured}px`
|
|
2801
|
-
}
|
|
3016
|
+
withScrollKept(allBox, () => {
|
|
3017
|
+
for (const blockArea of allBox.querySelectorAll('.dshet-all-area')) settleAreaHeight(blockArea)
|
|
3018
|
+
})
|
|
2802
3019
|
}
|
|
2803
3020
|
} else if (area !== null && area !== undefined) {
|
|
2804
3021
|
const offsetLeft = Math.max(0, bubble.rect.left - base.left)
|
|
@@ -2836,6 +3053,73 @@ window.__ModuleLoader__.load({
|
|
|
2836
3053
|
}
|
|
2837
3054
|
}
|
|
2838
3055
|
|
|
3056
|
+
/**
|
|
3057
|
+
* Run `work` without losing the reader's place.
|
|
3058
|
+
*
|
|
3059
|
+
* Measuring a textarea means collapsing it (`height: auto`), which shrinks
|
|
3060
|
+
* the document; the browser then clamps the scroller's `scrollTop` to the
|
|
3061
|
+
* new maximum, and the position is gone even though the heights come back.
|
|
3062
|
+
* The host re-renders constantly (a streaming turn, a state refresh), so
|
|
3063
|
+
* this is what "scroll down and it jumps back up" was. The position is
|
|
3064
|
+
* captured before the work and put back after.
|
|
3065
|
+
*
|
|
3066
|
+
* @param scroller - the scrolling element that holds the textareas.
|
|
3067
|
+
* @param work - the measuring pass.
|
|
3068
|
+
* @returns whatever `work` returned.
|
|
3069
|
+
*/
|
|
3070
|
+
function withScrollKept(scroller, work) {
|
|
3071
|
+
const keep =
|
|
3072
|
+
scroller !== null && scroller !== undefined && typeof scroller.scrollTop === 'number'
|
|
3073
|
+
? scroller.scrollTop
|
|
3074
|
+
: null
|
|
3075
|
+
const result = work()
|
|
3076
|
+
if (keep !== null && scroller.scrollTop !== keep) scroller.scrollTop = keep
|
|
3077
|
+
return result
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
/**
|
|
3081
|
+
* The content height of one textarea, SETTLED and applied.
|
|
3082
|
+
*
|
|
3083
|
+
* One measurement is not enough. With the height reset to `auto`, the first
|
|
3084
|
+
* read comes back exactly one line short - the wrap is resolved after the
|
|
3085
|
+
* style write - and the element is then pinned to that height with
|
|
3086
|
+
* `overflow:hidden`. In the all-blocks page that made the last line of a
|
|
3087
|
+
* long body unreachable: the surrounding scroller's height is computed from
|
|
3088
|
+
* the pinned value, so it cannot scroll to a line the textarea is hiding
|
|
3089
|
+
* (reported from the field: a long 正文 could not be scrolled past).
|
|
3090
|
+
*
|
|
3091
|
+
* Measured live: first read 2392, second read 2415, line height 23px.
|
|
3092
|
+
*
|
|
3093
|
+
* @param area - the textarea to measure and size.
|
|
3094
|
+
* @returns the applied height in CSS pixels, or 0 when unmeasurable.
|
|
3095
|
+
*/
|
|
3096
|
+
function settleAreaHeight(area) {
|
|
3097
|
+
if (area === null || area === undefined || typeof area.scrollHeight !== 'number') return 0
|
|
3098
|
+
area.style.height = 'auto'
|
|
3099
|
+
const first = area.scrollHeight
|
|
3100
|
+
if (!(typeof first === 'number' && first > 0)) return 0
|
|
3101
|
+
area.style.height = `${first}px`
|
|
3102
|
+
const settled = area.scrollHeight
|
|
3103
|
+
const height = typeof settled === 'number' && settled > first ? settled : first
|
|
3104
|
+
area.style.height = `${height}px`
|
|
3105
|
+
return height
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
/**
|
|
3109
|
+
* One block's identity inside the editor's draft store.
|
|
3110
|
+
*
|
|
3111
|
+
* A turn's blocks are addressed by (seq, index): the same block reached from
|
|
3112
|
+
* the 正文 page and from its sub-page inside 全部 must resolve to the SAME
|
|
3113
|
+
* key, or an unsaved edit disappears when the user switches pages.
|
|
3114
|
+
*
|
|
3115
|
+
* @param block - an editor block (seq, index).
|
|
3116
|
+
* @returns the stable key.
|
|
3117
|
+
*/
|
|
3118
|
+
function blockDraftKey(block) {
|
|
3119
|
+
if (block === null || block === undefined) return ''
|
|
3120
|
+
return `${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`
|
|
3121
|
+
}
|
|
3122
|
+
|
|
2839
3123
|
// Grow the textarea to its content, the way a normal input box behaves.
|
|
2840
3124
|
// Without this it sits at a fixed `rows` height and a two-line edit floats
|
|
2841
3125
|
// in half an empty box. The stylesheet owns the limits (`min-height`,
|
|
@@ -2845,9 +3129,12 @@ window.__ModuleLoader__.load({
|
|
|
2845
3129
|
|
|
2846
3130
|
function fitEditorHeight(area) {
|
|
2847
3131
|
if (typeof area.closest === 'function' && area.closest('[data-dshet-inplace="1"]') !== null) return
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
3132
|
+
const scroller =
|
|
3133
|
+
typeof area.closest === 'function'
|
|
3134
|
+
? area.closest('.dshet-all') ?? area.closest('.dshet-editor')
|
|
3135
|
+
: null
|
|
3136
|
+
const height = withScrollKept(scroller, () => settleAreaHeight(area))
|
|
3137
|
+
if (height > 0) area.style.height = `${height + 2}px`
|
|
2851
3138
|
}
|
|
2852
3139
|
|
|
2853
3140
|
// Wire an action so a host re-render cannot eat the click.
|
|
@@ -3134,7 +3421,7 @@ window.__ModuleLoader__.load({
|
|
|
3134
3421
|
? controller.editorBox
|
|
3135
3422
|
: null
|
|
3136
3423
|
if (existing && existing.dataset.dshetFor === marker) {
|
|
3137
|
-
placeEditor(row, existing, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
|
|
3424
|
+
placeEditor(row, existing, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
|
|
3138
3425
|
return
|
|
3139
3426
|
}
|
|
3140
3427
|
|
|
@@ -3235,13 +3522,13 @@ window.__ModuleLoader__.load({
|
|
|
3235
3522
|
fitEditorHeight(area)
|
|
3236
3523
|
const row = controller.editorRow
|
|
3237
3524
|
const box = controller.editorBox
|
|
3238
|
-
if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
|
|
3525
|
+
if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
|
|
3239
3526
|
})
|
|
3240
3527
|
area.addEventListener('compositionend', () => {
|
|
3241
3528
|
fitEditorHeight(area)
|
|
3242
3529
|
const row = controller.editorRow
|
|
3243
3530
|
const box = controller.editorBox
|
|
3244
|
-
if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
|
|
3531
|
+
if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
|
|
3245
3532
|
})
|
|
3246
3533
|
area.addEventListener('focus', () => {
|
|
3247
3534
|
controller.editorFocus = true
|
|
@@ -3314,15 +3601,15 @@ window.__ModuleLoader__.load({
|
|
|
3314
3601
|
blockArea.style.position = 'static'
|
|
3315
3602
|
blockArea.style.left = 'auto'
|
|
3316
3603
|
blockArea.style.top = 'auto'
|
|
3317
|
-
blockArea.value = controller.
|
|
3604
|
+
blockArea.value = controller.draftOfBlock(block)
|
|
3318
3605
|
blockArea.disabled = view.pending || view.confirming
|
|
3319
|
-
const grow = () =>
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3606
|
+
const grow = () =>
|
|
3607
|
+
withScrollKept(
|
|
3608
|
+
typeof blockArea.closest === 'function' ? blockArea.closest('.dshet-all') : null,
|
|
3609
|
+
() => settleAreaHeight(blockArea),
|
|
3610
|
+
)
|
|
3324
3611
|
blockArea.addEventListener('input', () => {
|
|
3325
|
-
|
|
3612
|
+
controller.setDraftFor(block, blockArea.value)
|
|
3326
3613
|
grow()
|
|
3327
3614
|
})
|
|
3328
3615
|
section.appendChild(head)
|
|
@@ -3523,7 +3810,7 @@ window.__ModuleLoader__.load({
|
|
|
3523
3810
|
controller.editorBox = box
|
|
3524
3811
|
controller.editorRow = row
|
|
3525
3812
|
controller.editorElement = area
|
|
3526
|
-
placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
|
|
3813
|
+
placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
|
|
3527
3814
|
fitEditorHeight(area)
|
|
3528
3815
|
if (!view.confirming && !view.pending && (fresh || wasFocused)) {
|
|
3529
3816
|
try {
|
|
@@ -3735,6 +4022,49 @@ window.__ModuleLoader__.load({
|
|
|
3735
4022
|
* the deepest element whose text is the recorded original (or what we last
|
|
3736
4023
|
* painted there).
|
|
3737
4024
|
*/
|
|
4025
|
+
/**
|
|
4026
|
+
* EVERY element that shows this reasoning text.
|
|
4027
|
+
*
|
|
4028
|
+
* Plural on purpose: the host keeps more than one copy of a turn's reasoning.
|
|
4029
|
+
* The collapsed process row stays in the DOM with `display: none`, and opening
|
|
4030
|
+
* the panel renders a second, visible body. Patching only the first one left
|
|
4031
|
+
* the copy the user was looking at showing the recorded text.
|
|
4032
|
+
*
|
|
4033
|
+
* @param root - the conversation root to search.
|
|
4034
|
+
* @param wanted - the patch (`original` text, new `text`).
|
|
4035
|
+
* @param previousText - what the patch showed before this pass, if anything.
|
|
4036
|
+
* @returns the deepest matching elements, best match first.
|
|
4037
|
+
*/
|
|
4038
|
+
function findReasoningElements(root, wanted, previousText) {
|
|
4039
|
+
if (root === null || root === undefined || typeof root.querySelectorAll !== 'function') return []
|
|
4040
|
+
const original = matchText(wanted.original)
|
|
4041
|
+
const previous = matchText(previousText)
|
|
4042
|
+
if (original === '' && previous === '') return []
|
|
4043
|
+
const found = []
|
|
4044
|
+
for (const el of root.querySelectorAll('*')) {
|
|
4045
|
+
if (el.getAttribute(PATCH_ATTR) !== null) continue
|
|
4046
|
+
try {
|
|
4047
|
+
if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
|
|
4048
|
+
} catch {
|
|
4049
|
+
/* a detached node is still a candidate */
|
|
4050
|
+
}
|
|
4051
|
+
const text = matchText(el.textContent)
|
|
4052
|
+
if (text === '') continue
|
|
4053
|
+
if (text !== original && !(previous !== '' && text === previous)) continue
|
|
4054
|
+
let deeper = false
|
|
4055
|
+
for (const child of el.children ?? []) {
|
|
4056
|
+
if (matchText(child.textContent) === text) {
|
|
4057
|
+
deeper = true
|
|
4058
|
+
break
|
|
4059
|
+
}
|
|
4060
|
+
}
|
|
4061
|
+
if (deeper) continue
|
|
4062
|
+
found.push({ el, score: Math.abs(text.length - original.length) })
|
|
4063
|
+
}
|
|
4064
|
+
found.sort((a, b) => a.score - b.score)
|
|
4065
|
+
return found.map((entry) => entry.el)
|
|
4066
|
+
}
|
|
4067
|
+
|
|
3738
4068
|
function findReasoningElement(root, wanted, previousText) {
|
|
3739
4069
|
if (root === null || root === undefined || typeof root.querySelectorAll !== 'function') return null
|
|
3740
4070
|
const original = matchText(wanted.original)
|
|
@@ -3804,9 +4134,38 @@ window.__ModuleLoader__.load({
|
|
|
3804
4134
|
const wantedText = new Map()
|
|
3805
4135
|
const wantedReasoning = new Map()
|
|
3806
4136
|
for (const patch of Array.isArray(view.patches) ? view.patches : []) {
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
4137
|
+
// Find the block by (seq, index) AND the entry that carries it.
|
|
4138
|
+
//
|
|
4139
|
+
// One reply entry carries its whole TURN's blocks, so several blocks
|
|
4140
|
+
// share an index - the first step's reasoning and the fourth step's
|
|
4141
|
+
// reasoning are both "index 0" - and a later step's block lives in the
|
|
4142
|
+
// entry keyed by the ANSWER's seq, not by its own. Keying the lookup by
|
|
4143
|
+
// `patch.seq` alone found no entry, and `find(index === ...)` alone found
|
|
4144
|
+
// the wrong block: either way the patch matched nothing in the DOM and
|
|
4145
|
+
// the reasoning body kept the recorded text until a reload (reported from
|
|
4146
|
+
// the field for the 思维链 page; a save on another step's reasoning wrote
|
|
4147
|
+
// the record and /state reported it, yet the transcript did not move).
|
|
4148
|
+
let reply = replies.get(patch.seq)
|
|
4149
|
+
let block = Array.isArray(reply?.blocks)
|
|
4150
|
+
? reply.blocks.find(
|
|
4151
|
+
(entry) =>
|
|
4152
|
+
entry.index === patch.blockIndex &&
|
|
4153
|
+
(entry.seq === undefined || patch.seq === undefined || entry.seq === patch.seq),
|
|
4154
|
+
)
|
|
4155
|
+
: undefined
|
|
4156
|
+
if (block === undefined) {
|
|
4157
|
+
for (const candidate of replies.values()) {
|
|
4158
|
+
if (!Array.isArray(candidate?.blocks)) continue
|
|
4159
|
+
const found = candidate.blocks.find(
|
|
4160
|
+
(entry) => entry.index === patch.blockIndex && entry.seq === patch.seq,
|
|
4161
|
+
)
|
|
4162
|
+
if (found !== undefined) {
|
|
4163
|
+
reply = candidate
|
|
4164
|
+
block = found
|
|
4165
|
+
break
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
3810
4169
|
if (block === undefined) continue
|
|
3811
4170
|
if (patch.kind === 'text') {
|
|
3812
4171
|
wantedText.set(`${patch.seq}:${patch.blockIndex}`, {
|
|
@@ -3928,37 +4287,34 @@ window.__ModuleLoader__.load({
|
|
|
3928
4287
|
// in the DOM (or is drawn as a short summary without the full text).
|
|
3929
4288
|
for (const [key, wanted] of wantedReasoning) {
|
|
3930
4289
|
const owner = rowBySeq.get(wanted.seq)
|
|
4290
|
+
const shown = displayText(wanted.text)
|
|
4291
|
+
const previousText = patchedTexts.get(key) ?? ''
|
|
4292
|
+
const original = matchText(wanted.original)
|
|
4293
|
+
const previous = matchText(previousText)
|
|
4294
|
+
// EVERY copy of this reasoning, not just the one this pass remembers.
|
|
4295
|
+
//
|
|
4296
|
+
// The host keeps the collapsed process row in the DOM with `display:
|
|
4297
|
+
// none` and renders a SECOND body when the panel is opened. Trusting the
|
|
4298
|
+
// remembered element because it is still `isConnected` meant the copy the
|
|
4299
|
+
// user was looking at kept the recorded text until a reload - which is
|
|
4300
|
+
// exactly the reported "思维链页保存后不能马上渲染".
|
|
4301
|
+
const targets = new Set()
|
|
3931
4302
|
const remembered = patchedElements.get(key)
|
|
3932
4303
|
if (remembered !== undefined) {
|
|
3933
|
-
|
|
3934
|
-
|
|
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
|
|
3944
|
-
}
|
|
3945
|
-
patchedElements.delete(key)
|
|
4304
|
+
if (remembered.isConnected === true) targets.add(remembered)
|
|
4305
|
+
else patchedElements.delete(key)
|
|
3946
4306
|
}
|
|
3947
|
-
const
|
|
3948
|
-
|
|
3949
|
-
patchedElements.set(key, done)
|
|
3950
|
-
continue
|
|
4307
|
+
for (const el of root.querySelectorAll(`[${PATCH_ATTR}]`)) {
|
|
4308
|
+
if (el.getAttribute(PATCH_ATTR) === key) targets.add(el)
|
|
3951
4309
|
}
|
|
3952
|
-
const original = matchText(wanted.original)
|
|
3953
|
-
const previous = matchText(patchedTexts.get(key) ?? '')
|
|
3954
|
-
const previousText = patchedTexts.get(key) ?? ''
|
|
3955
|
-
let best = null
|
|
3956
4310
|
if (original !== '' || previous !== '') {
|
|
3957
4311
|
// The owning row first (a host that draws the reasoning inside the
|
|
3958
4312
|
// answer row), then the whole conversation: on this host the reasoning
|
|
3959
4313
|
// is its own row, so the answer row holds no reasoning text at all.
|
|
4314
|
+
// EVERY scope is merged, never just the first one that matched: the
|
|
4315
|
+
// point of this pass is to catch all copies at once.
|
|
3960
4316
|
for (const scope of owner === undefined ? [null] : [owner, null]) {
|
|
3961
|
-
let
|
|
4317
|
+
let foundInScope = false
|
|
3962
4318
|
for (const el of containers) {
|
|
3963
4319
|
if (el.getAttribute(PATCH_ATTR) !== null) continue
|
|
3964
4320
|
if (scope !== null) {
|
|
@@ -3971,29 +4327,29 @@ window.__ModuleLoader__.load({
|
|
|
3971
4327
|
const text = matchText(el.textContent)
|
|
3972
4328
|
if (text === '') continue
|
|
3973
4329
|
if (text !== original && !(previous !== '' && text === previous)) continue
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
best = el
|
|
3977
|
-
bestScore = score
|
|
3978
|
-
}
|
|
4330
|
+
targets.add(el)
|
|
4331
|
+
foundInScope = true
|
|
3979
4332
|
}
|
|
3980
|
-
if (
|
|
4333
|
+
if (foundInScope) break
|
|
3981
4334
|
}
|
|
3982
|
-
|
|
3983
|
-
if (
|
|
4335
|
+
const fallback = findPatchTextFallback(owner ?? null, wanted, previousText)
|
|
4336
|
+
if (fallback !== null) targets.add(fallback)
|
|
4337
|
+
for (const el of findReasoningElements(root, wanted, previousText)) targets.add(el)
|
|
3984
4338
|
}
|
|
3985
|
-
if (
|
|
4339
|
+
if (targets.size === 0) {
|
|
3986
4340
|
// No body in the DOM (the panel is closed): the header snippet is the
|
|
3987
4341
|
// only place the edit can show.
|
|
3988
4342
|
if (owner !== undefined) patchReasoningSummary(owner, wanted)
|
|
3989
4343
|
patchReasoningSummary(root, wanted)
|
|
3990
4344
|
continue
|
|
3991
4345
|
}
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
4346
|
+
for (const el of targets) {
|
|
4347
|
+
if (!patchOriginals.has(el)) patchOriginals.set(el, { html: el.innerHTML, text: el.textContent })
|
|
4348
|
+
if (normalizedText(el.textContent) !== normalizedText(shown)) el.textContent = shown
|
|
4349
|
+
if (el.getAttribute(PATCH_ATTR) !== key) el.setAttribute(PATCH_ATTR, key)
|
|
4350
|
+
el.style.whiteSpace = 'pre-wrap'
|
|
4351
|
+
patchedElements.set(key, el)
|
|
4352
|
+
}
|
|
3997
4353
|
patchedTexts.set(key, wanted.text)
|
|
3998
4354
|
if (owner !== undefined) patchReasoningSummary(owner, wanted)
|
|
3999
4355
|
patchReasoningSummary(root, wanted)
|
|
@@ -4217,7 +4573,7 @@ window.__ModuleLoader__.load({
|
|
|
4217
4573
|
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' : ''}`
|
|
4218
4574
|
if (key === lastPlacementKey) return
|
|
4219
4575
|
lastPlacementKey = key
|
|
4220
|
-
placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
|
|
4576
|
+
placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
|
|
4221
4577
|
}
|
|
4222
4578
|
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
|
|
4223
4579
|
else window.setTimeout(run, 16)
|
|
@@ -4684,6 +5040,10 @@ window.__ModuleLoader__.load({
|
|
|
4684
5040
|
}
|
|
4685
5041
|
|
|
4686
5042
|
exports.apply = apply
|
|
5043
|
+
// The settings card, exported so a composing bundle (dsh-as-aistudio) can
|
|
5044
|
+
// render THE SAME component on its own page instead of re-implementing it:
|
|
5045
|
+
// one source of truth for 编辑与重跑 settings.
|
|
5046
|
+
exports.ConfigCard = EditorStyleOption
|
|
4687
5047
|
exports.inject = ['slots', 'locale']
|
|
4688
5048
|
exports.PLUGIN_VERSION = PLUGIN_VERSION
|
|
4689
5049
|
return module.exports
|
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.4'
|
|
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
|
|
@@ -100,7 +100,15 @@ export function recentRequests() {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
const ROUTE_PREFIX = '/dsh-edit-turn'
|
|
103
|
-
|
|
103
|
+
// A session id is an OPAQUE string to DSH: the host's own schema is
|
|
104
|
+
// `z.string().min(1)`, and a named session created in the GUI (`bili-whale`,
|
|
105
|
+
// stored as ~/.dsh/storages/<profile>/sessions/bili-whale.json) is exactly as
|
|
106
|
+
// real as a UUID one. A UUID-only test rejected those with a 400, so the panel
|
|
107
|
+
// answered "状态加载失败" for a session that plainly exists. The character class
|
|
108
|
+
// still refuses everything that could leave a path or a URL: `/`, `\`, `..`
|
|
109
|
+
// (the first character must be alphanumeric, so a leading dot is already out),
|
|
110
|
+
// whitespace and control characters.
|
|
111
|
+
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
104
112
|
const MAX_TEXT = 200_000
|
|
105
113
|
const FLUSH_DEADLINE_MS = 5_000
|
|
106
114
|
|
|
@@ -1683,8 +1691,34 @@ export async function attachmentBytes(ctx, sessionId, seq, index) {
|
|
|
1683
1691
|
async function stateOf(ctx, sessionId, config) {
|
|
1684
1692
|
const events = await readEvents(ctx, sessionId)
|
|
1685
1693
|
if (!events) {
|
|
1686
|
-
|
|
1687
|
-
|
|
1694
|
+
// A named session with nothing in it yet is an EMPTY session, not a failed
|
|
1695
|
+
// one: the editor opens with nothing to edit, and the next refresh picks the
|
|
1696
|
+
// log up once it exists. Answering 404 here is what the panel printed as
|
|
1697
|
+
// "状态加载失败,稍后重试。". The capability probe still runs, because "what
|
|
1698
|
+
// can this deployment do" does not depend on the log.
|
|
1699
|
+
noteRequest({ kind: 'state', sessionId, ok: true, code: 'session-empty' })
|
|
1700
|
+
const store = resolveAttachmentStore(ctx)
|
|
1701
|
+
return {
|
|
1702
|
+
hidden: [],
|
|
1703
|
+
edits: 0,
|
|
1704
|
+
revisions: [],
|
|
1705
|
+
surface: [],
|
|
1706
|
+
turns: [],
|
|
1707
|
+
replies: [],
|
|
1708
|
+
patches: [],
|
|
1709
|
+
mode: config.patch === true ? 'patch' : 'replay',
|
|
1710
|
+
live: false,
|
|
1711
|
+
busy: false,
|
|
1712
|
+
lastSeq: -1,
|
|
1713
|
+
capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
|
|
1714
|
+
config: {
|
|
1715
|
+
confirm: config.confirm,
|
|
1716
|
+
replyMode: config.replyMode,
|
|
1717
|
+
patch: config.patch === true,
|
|
1718
|
+
editorStyle: config.editorStyle,
|
|
1719
|
+
},
|
|
1720
|
+
version: PLUGIN_VERSION,
|
|
1721
|
+
}
|
|
1688
1722
|
}
|
|
1689
1723
|
const folded = foldSurface(events)
|
|
1690
1724
|
const ledger = rollbackLedger(events)
|
|
@@ -2477,12 +2511,71 @@ export function isProjectionUnavailable(message) {
|
|
|
2477
2511
|
}
|
|
2478
2512
|
|
|
2479
2513
|
function failure(res, error) {
|
|
2514
|
+
const message = String((error && error.message) || error)
|
|
2515
|
+
// The host refuses to derive a session whose projection left the registry
|
|
2516
|
+
// (`SurfaceManager._assertProjections`). It is a known, self-explaining state
|
|
2517
|
+
// - the plugin was reloaded while that session was open - not an internal
|
|
2518
|
+
// fault, so it answers 409 with the host's own words and does not dump a stack
|
|
2519
|
+
// into the log on every request.
|
|
2520
|
+
if (isProjectionUnavailable(message)) {
|
|
2521
|
+
sendJson(res, 409, { ok: false, code: 'projection-missing', error: message })
|
|
2522
|
+
return
|
|
2523
|
+
}
|
|
2480
2524
|
const status = error instanceof HttpError ? error.status : 500
|
|
2481
2525
|
const code = error instanceof HttpError ? error.code : 'internal'
|
|
2482
2526
|
// An unexpected throw is the only case the client cannot explain; leave the
|
|
2483
2527
|
// stack in the host log so a report can name the cause.
|
|
2484
2528
|
if (!(error instanceof HttpError)) console.error('[dsh-edit-turn] internal error', error)
|
|
2485
|
-
sendJson(res, status, { ok: false, code, error:
|
|
2529
|
+
sendJson(res, status, { ok: false, code, error: message })
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
/**
|
|
2533
|
+
* The one projection object this PROCESS uses, kept across plugin reloads.
|
|
2534
|
+
*
|
|
2535
|
+
* The host registry compares projection IDENTITY, not type:
|
|
2536
|
+
* `SurfaceManager._assertProjections()` (dsh-session/lib/index.js) throws
|
|
2537
|
+
* `session message projection "<type>" was removed or replaced` when a
|
|
2538
|
+
* projection a LIVE session already folded with is no longer in the registry
|
|
2539
|
+
* array, and `registerMessageProjection`'s own docstring states the rule:
|
|
2540
|
+
* "Disposing the contribution makes sessions that used it refuse further
|
|
2541
|
+
* derivation."
|
|
2542
|
+
*
|
|
2543
|
+
* A reload disposes the previous fiber, which splices the old object out of the
|
|
2544
|
+
* registry. Building a fresh object then leaves every open session that carries
|
|
2545
|
+
* a patch event unable to derive anything: the surface read throws, so this
|
|
2546
|
+
* plugin's own save/state paths answer 500, and so does any sibling that reads
|
|
2547
|
+
* `session.surface.nodes` (verified live - dsh-delete-turn's /state answered 500
|
|
2548
|
+
* for such a session after a reload). Reusing one object from a process-global
|
|
2549
|
+
* hands the registry the SAME definition back, so a reloaded plugin keeps the
|
|
2550
|
+
* open sessions foldable.
|
|
2551
|
+
*
|
|
2552
|
+
* The object is a pure, stateless interpreter, which is what makes the reuse
|
|
2553
|
+
* safe. One consequence is deliberate: after a code change the projection keeps
|
|
2554
|
+
* running the PREVIOUS definition for sessions that are already open, until the
|
|
2555
|
+
* session is reopened or DSH restarts. That is the better half of the trade -
|
|
2556
|
+
* the alternative is a session that cannot be read at all.
|
|
2557
|
+
*
|
|
2558
|
+
* @returns the process-wide projection definition.
|
|
2559
|
+
*/
|
|
2560
|
+
function sharedPatchProjection() {
|
|
2561
|
+
const KEY = '__DSH_EDIT_TURN_PATCH_PROJECTION__'
|
|
2562
|
+
try {
|
|
2563
|
+
const stashed = globalThis[KEY]
|
|
2564
|
+
if (
|
|
2565
|
+
stashed !== undefined &&
|
|
2566
|
+
stashed !== null &&
|
|
2567
|
+
typeof stashed.project === 'function' &&
|
|
2568
|
+
stashed.type === PATCH_TYPE
|
|
2569
|
+
) {
|
|
2570
|
+
return stashed
|
|
2571
|
+
}
|
|
2572
|
+
const fresh = patchProjection()
|
|
2573
|
+
globalThis[KEY] = fresh
|
|
2574
|
+
return fresh
|
|
2575
|
+
} catch {
|
|
2576
|
+
// A frozen or absent globalThis is not worth failing the load over.
|
|
2577
|
+
return patchProjection()
|
|
2578
|
+
}
|
|
2486
2579
|
}
|
|
2487
2580
|
|
|
2488
2581
|
// --- plugin ------------------------------------------------------------------
|
|
@@ -2523,7 +2616,7 @@ export function apply(ctx, config) {
|
|
|
2523
2616
|
const existing = Array.isArray(registry)
|
|
2524
2617
|
? registry.find((entry) => entry && entry.type === PATCH_TYPE)
|
|
2525
2618
|
: undefined
|
|
2526
|
-
if (existing === undefined) sessionsService.registerMessageProjection(
|
|
2619
|
+
if (existing === undefined) sessionsService.registerMessageProjection(sharedPatchProjection())
|
|
2527
2620
|
patch = true
|
|
2528
2621
|
} catch (error) {
|
|
2529
2622
|
if (resolved.replyMode === 'patch') throw error
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-edit-turn",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
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",
|