dsh-edit-turn 0.3.0 → 0.3.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 +18 -1
- package/lib/client.js +180 -19
- package/lib/index.js +23 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -164,7 +164,7 @@ ctx.sessions.registerMessageProjection({
|
|
|
164
164
|
| 运行中的实例是否真的挂载了本插件(只读路由守卫探针,无需 token) | `npm run probe:loaded [端口]` | 通过:`/state` 返 400、`/apply` 返 405 —— 这两个状态码只有本插件会返回 |
|
|
165
165
|
| 浏览器半部到底问了什么、拿回了什么(只读诊断,含失败记录) | `GET /dsh-edit-turn/debug` | 通过:真实浏览器里确认客户端确实拿到 replies;并复现了旧 v3 会话的 session-not-found |
|
|
166
166
|
| 官方 append **与读取**契约(真实校验器,进程内) | `npm run verify:contract` | 96 项通过(每段写入后都用加载器重读整份日志):替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 developer 载体不产生模型消息、**连续两次回退都被接受**、**编辑模型回答的完整机制被接受**、**回复编辑后的尾部重放(后续提问/回答/tool 调用与结果按新 turn 号回到修正回答之后,顺序正确、日志仍可加载)**、**跨插件依赖的不变量(单节点窗口 / 同事件类型 / 全窗口 sourceEventSeqs / 每个替换都带 `editedBy` 标记 / 改写后的 `source.kind` 不是 `user` / 修正回复不带 `sourceEventSeqs`)**,以及**就地补丁:一条 ignorable 记录、surface 不变、去掉投影后原文回来、后续轮次与 tool 结果不动、第二条记录可叠加** |
|
|
167
|
-
| 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` |
|
|
167
|
+
| 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 213 项通过(含回复编辑的尾部重放用例:窗口/重放区间、新 turn 号与新 messageId、tool 调用与结果成对重映射、上一轮修正可再次编辑,以及与 `dsh-rerun-turn` builder 的逐字段对照;另含「文本没变 → 不写」的四条,以及块随消息一起改的 35 条:未动的块逐字节保留、删块少一块、文字位置不变、no-op 语义升级、附件服务缺失时降级且不写坏数据、自定义 `quote-card` 块的通用性、录用与拒绝、`/state` 的块描述与能力、附件字节路由、编辑器芯片/删除/添加/缩略图/降级警告) |
|
|
168
168
|
| 客户端半部静态检查(注册、i18n 完整性、样式、皮肤可读性、版本三处同步、线协议、块契约) | `npm run verify:client` | 184 项全部通过(含 12 条块契约检查:能力上报、服务现场探测、录用是唯一建块路径、录用先于 append、**两侧都不特判块类型**、块逐字节复制、文字归位、`dropped` 上报、只在无服务时警告、芯片靠 `error` 事件自证) |
|
|
169
169
|
| 实机前端产物校验(运行中的 DSH 是否在下发当前代码) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | 全部通过(含**宿主节点形状锚点**) |
|
|
170
170
|
| **真实浏览器渲染冒烟**(CDP 驱动已开着的标签页,只读) | `npm run verify:ui` | 全部通过:下发的字节就是刚改的字节、无槽位崩溃留下的空占位、回复铅笔在平台动作条里、被回滚的行保住动作条、**被回滚的用户行一定显示替代文本(消息不会凭空消失)**、**替代气泡一定排在时间/复制之上(顺序与正常消息一致)**、**改写后那颗铅笔一定在还活着的动作条末尾(已在原始会话、切走的会话、切回后三处现场确认)**、**回复铅笔一定排在动作条最左(复制之前,已现场确认)**、气泡上没有 `.dshet-floating` 压着文字、切走再切回入口仍在 |
|
|
@@ -250,6 +250,23 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
|
|
|
250
250
|
|
|
251
251
|
### 更新日志
|
|
252
252
|
|
|
253
|
+
**0.3.2** —— 修复编辑器在长回答或页面底部时位置错误、按钮点不到的问题。
|
|
254
|
+
|
|
255
|
+
- **视口钳制**:编辑框优先放在行下方;放不下就翻到行上方;行比屏幕高、底部在视口外时钉在视口底部,不再跟着行跑到屏幕外。
|
|
256
|
+
- **自身滚动**:编辑框有 `max-height` 和 `overflow-y:auto`,保存/取消所在的 footer 用 `position:sticky` 固定,长草稿也能滚到按钮。
|
|
257
|
+
- **宽度/左边界**也按视口钳制,窄窗口不会被推出屏幕。
|
|
258
|
+
- **测试**:`npm test` **213/213**、`npm run verify:contract` **96** 项、`npm run verify:client` **184** 项。
|
|
259
|
+
|
|
260
|
+
**0.3.1** —— 修复 0.3.0 的显示与跨插件清理问题。
|
|
261
|
+
|
|
262
|
+
- **编辑后看不到变化**:`turn-process` 行和 `assistant-step` 行都覆盖同一个 seq,旧的 DOM 映射按文档顺序先拿到过程行,补丁落到没有正文的行上。现在优先 assistant 行;极短回答是裸文本节点时也有兜底。
|
|
263
|
+
- **Markdown 匹配**:原文有 `**1**`、渲染后是 `1。`,旧比较把 `**` 换成空格后变成 `1 。`,逐字比较失败。现在比较忽略全部空白;显示时去掉 `**`、反引号和标题标记,不把 Markdown 源码画在正文里。
|
|
264
|
+
- **React 重渲染**:会清掉补丁元素的属性和样式但保留文本;现在按 `seq:block` 记住元素,连续第二次编辑仍能更新并能在补丁消失时恢复原文。
|
|
265
|
+
- **过时补丁**:目标已被 rerun 退役的 patch 不再出现在 `/state.patches`。
|
|
266
|
+
- **轮次丢失**:prompt 替换载体在 turn 括号之外追加,`turnIndex` 取不到轮次;现在从 `sourceEventSeqs` 继承被替换的轮次。
|
|
267
|
+
- **诊断**:客户端在 `<html>` 上写 `data-dshet-version`,可确认页面实际跑的客户端版本。
|
|
268
|
+
- **测试**:`npm test` **210/210**、`npm run verify:contract` **96** 项、`npm run verify:client` **184** 项。
|
|
269
|
+
|
|
253
270
|
**0.3.0** —— 回复编辑不再重放:用官方 plugin record + message projection 就地打补丁,正文与思维链都能改,日志每次只多一条小记录。
|
|
254
271
|
|
|
255
272
|
- **为什么改**:0.2.21 的回复编辑虽然保住了后续轮次,但代价是「遮蔽到末尾 + 逐事件重放」——改一条中间回答,日志会多出整段副本,轮次号、messageId 全换,工具调用/思维链也留不住。用户要的是「像改提示词一样改回答」,而不是一次 infix splice。
|
package/lib/client.js
CHANGED
|
@@ -42,7 +42,7 @@ window.__ModuleLoader__.load({
|
|
|
42
42
|
const RERUN_PREFIX = '/dsh-rerun-turn'
|
|
43
43
|
|
|
44
44
|
/** Keep in sync with package.json and lib/index.js. */
|
|
45
|
-
const PLUGIN_VERSION = '0.3.
|
|
45
|
+
const PLUGIN_VERSION = '0.3.2'
|
|
46
46
|
|
|
47
47
|
// --- copy -----------------------------------------------------------------
|
|
48
48
|
|
|
@@ -262,7 +262,7 @@ window.__ModuleLoader__.load({
|
|
|
262
262
|
// positioned under its row by `placeEditor`.
|
|
263
263
|
'.dshet-layer{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:40}',
|
|
264
264
|
'.dshet-layer .dshet-editor{position:absolute;pointer-events:auto}',
|
|
265
|
-
'.dshet-editor{margin:0;border:1px solid var(--dshet-line);border-radius:16px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink);transition:border-color .12s}',
|
|
265
|
+
'.dshet-editor{display:flex;flex-direction:column;max-height:calc(100vh - 16px);overflow-y:auto;overscroll-behavior:contain;margin:0;border:1px solid var(--dshet-line);border-radius:16px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink);transition:border-color .12s}',
|
|
266
266
|
'.dshet-editor:focus-within{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
|
|
267
267
|
'.dshet-editor textarea{display:block;width:100%;min-height:47px;max-height:46vh;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}',
|
|
268
268
|
'.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
|
|
@@ -290,7 +290,7 @@ window.__ModuleLoader__.load({
|
|
|
290
290
|
'.dshet-note{color:var(--dshet-ink-dim)}',
|
|
291
291
|
'.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
|
|
292
292
|
'.dshet-error{color:var(--dsw-alias-state-error-primary,#c93a31)}',
|
|
293
|
-
'.dshet-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:4px 10px 10px}',
|
|
293
|
+
'.dshet-footer{position:sticky;bottom:0;z-index:1;display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:4px 10px 10px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2)}',
|
|
294
294
|
'.dshet-btn{padding:6px 16px;border:1px solid var(--dshet-line);border-radius:999px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background-color .12s,border-color .12s}',
|
|
295
295
|
'.dshet-btn:hover:not(:disabled){background:var(--dshet-hover)}',
|
|
296
296
|
'.dshet-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
|
|
@@ -1945,13 +1945,51 @@ window.__ModuleLoader__.load({
|
|
|
1945
1945
|
// Put the box under the row it belongs to: same width, just below its lower
|
|
1946
1946
|
// edge. Called on render and whenever the page scrolls or resizes, since
|
|
1947
1947
|
// the layer is fixed to the viewport.
|
|
1948
|
+
function viewportSize() {
|
|
1949
|
+
const win = typeof window === 'undefined' ? undefined : window
|
|
1950
|
+
const doc = typeof document === 'undefined' ? undefined : document
|
|
1951
|
+
const width = (win !== undefined && win.innerWidth) ||
|
|
1952
|
+
(doc !== undefined && doc.documentElement && doc.documentElement.clientWidth) || 0
|
|
1953
|
+
const height = (win !== undefined && win.innerHeight) ||
|
|
1954
|
+
(doc !== undefined && doc.documentElement && doc.documentElement.clientHeight) || 0
|
|
1955
|
+
return { width, height }
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// Put the box under the row it belongs to: same width, just below its lower
|
|
1959
|
+
// edge. The layer is fixed to the viewport, so a row near the bottom (or one
|
|
1960
|
+
// taller than the screen) used to push the footer - and its save button -
|
|
1961
|
+
// past the bottom edge where nothing could reach it. Flip above the row when
|
|
1962
|
+
// there is room, otherwise pin the box to the bottom of the viewport and let
|
|
1963
|
+
// its own scrollbar carry the content; the footer stays sticky either way.
|
|
1948
1964
|
function placeEditor(row, box) {
|
|
1949
1965
|
const layer = ensureEditorLayer()
|
|
1950
1966
|
if (box.parentElement !== layer) layer.appendChild(box)
|
|
1951
1967
|
const rect = row.getBoundingClientRect()
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1968
|
+
const viewport = viewportSize()
|
|
1969
|
+
const margin = 8
|
|
1970
|
+
const width = viewport.width > 0
|
|
1971
|
+
? Math.max(1, Math.min(Math.max(200, rect.width || 0), viewport.width - margin * 2))
|
|
1972
|
+
: Math.max(200, rect.width || 0)
|
|
1973
|
+
box.style.width = `${Math.round(width)}px`
|
|
1974
|
+
const measured = box.getBoundingClientRect()
|
|
1975
|
+
const height = (measured && measured.height) || box.offsetHeight || 0
|
|
1976
|
+
let top = rect.bottom + 6
|
|
1977
|
+
if (viewport.height > 0 && height > 0) {
|
|
1978
|
+
const maxTop = Math.max(margin, viewport.height - height - margin)
|
|
1979
|
+
if (top + height > viewport.height - margin) {
|
|
1980
|
+
const above = rect.top - height - 6
|
|
1981
|
+
top = above >= margin ? above : maxTop
|
|
1982
|
+
}
|
|
1983
|
+
// A row taller than the screen has its bottom above the viewport after
|
|
1984
|
+
// the user scrolls to its action bar; without this clamp the box follows
|
|
1985
|
+
// it off-screen and the buttons become unreachable again.
|
|
1986
|
+
top = Math.max(margin, Math.min(top, maxTop))
|
|
1987
|
+
}
|
|
1988
|
+
let left = rect.left
|
|
1989
|
+
if (viewport.width > 0 && left + width > viewport.width - margin) left = viewport.width - width - margin
|
|
1990
|
+
if (left < margin) left = margin
|
|
1991
|
+
box.style.left = `${Math.round(left)}px`
|
|
1992
|
+
box.style.top = `${Math.round(top)}px`
|
|
1955
1993
|
}
|
|
1956
1994
|
|
|
1957
1995
|
// Grow the textarea to its content, the way a normal input box behaves.
|
|
@@ -2297,8 +2335,16 @@ window.__ModuleLoader__.load({
|
|
|
2297
2335
|
// "eats" pinyin.
|
|
2298
2336
|
if (event.isComposing === true) return
|
|
2299
2337
|
fitEditorHeight(area)
|
|
2338
|
+
const row = controller.editorRow
|
|
2339
|
+
const box = controller.editorBox
|
|
2340
|
+
if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box)
|
|
2341
|
+
})
|
|
2342
|
+
area.addEventListener('compositionend', () => {
|
|
2343
|
+
fitEditorHeight(area)
|
|
2344
|
+
const row = controller.editorRow
|
|
2345
|
+
const box = controller.editorBox
|
|
2346
|
+
if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box)
|
|
2300
2347
|
})
|
|
2301
|
-
area.addEventListener('compositionend', () => fitEditorHeight(area))
|
|
2302
2348
|
area.addEventListener('focus', () => {
|
|
2303
2349
|
controller.editorFocus = true
|
|
2304
2350
|
})
|
|
@@ -2408,11 +2454,11 @@ window.__ModuleLoader__.load({
|
|
|
2408
2454
|
footer.appendChild(submit)
|
|
2409
2455
|
box.appendChild(footer)
|
|
2410
2456
|
|
|
2411
|
-
placeEditor(row, box)
|
|
2412
2457
|
controller.editorBox = box
|
|
2413
2458
|
controller.editorRow = row
|
|
2414
2459
|
controller.editorElement = area
|
|
2415
2460
|
fitEditorHeight(area)
|
|
2461
|
+
placeEditor(row, box)
|
|
2416
2462
|
if (!view.confirming && !view.pending && (fresh || wasFocused)) {
|
|
2417
2463
|
try {
|
|
2418
2464
|
area.focus()
|
|
@@ -2452,6 +2498,11 @@ window.__ModuleLoader__.load({
|
|
|
2452
2498
|
const PATCH_ATTR = 'data-dshet-patch'
|
|
2453
2499
|
const NOTE_ATTR = 'data-dshet-patch-note'
|
|
2454
2500
|
const patchOriginals = new WeakMap()
|
|
2501
|
+
// React may re-render the assistant container and reset its attributes and
|
|
2502
|
+
// style while leaving the text node we replaced. Remember the element by
|
|
2503
|
+
// seq:block so the next pass can restore the marker and, on a later edit,
|
|
2504
|
+
// update it without having to re-find the original text (which is gone).
|
|
2505
|
+
const patchedElements = new Map()
|
|
2455
2506
|
|
|
2456
2507
|
function normalizedText(value) {
|
|
2457
2508
|
return String(value ?? '').replace(/\s+/g, ' ').trim()
|
|
@@ -2464,6 +2515,27 @@ window.__ModuleLoader__.load({
|
|
|
2464
2515
|
return normalizedText(String(value ?? '').replace(/[`*_#>~[\]()!|]/g, ' ').replace(/\s+/g, ' '))
|
|
2465
2516
|
}
|
|
2466
2517
|
|
|
2518
|
+
// Identity, not display: markdown emphasis (`**1**`) renders as `1。`, and
|
|
2519
|
+
// the spaces left by stripping the markers must not defeat the comparison.
|
|
2520
|
+
// Whitespace carries no block identity here, so remove it entirely.
|
|
2521
|
+
function matchText(value) {
|
|
2522
|
+
return plainText(value).replace(/\s+/g, '')
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// What the reader sees for a patched block. The model context keeps the
|
|
2526
|
+
// raw Markdown the editor wrote; the transcript should not show the markers
|
|
2527
|
+
// as literal text (`**1**`), which is what made a successful edit look like
|
|
2528
|
+
// an unrendered draft. This is deliberately minimal: bold, inline code and
|
|
2529
|
+
// headings are unwrapped; everything else stays as written.
|
|
2530
|
+
function displayText(value) {
|
|
2531
|
+
return String(value ?? '')
|
|
2532
|
+
.replace(/```[\s\S]*?```/g, (fence) => fence.replace(/^```[^\n]*\n?/gm, '').replace(/```$/gm, ''))
|
|
2533
|
+
.replace(/`([^`]*)`/g, '$1')
|
|
2534
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
2535
|
+
.replace(/__([^_]+)__/g, '$1')
|
|
2536
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2467
2539
|
function isMarkdownContainer(el) {
|
|
2468
2540
|
if (el === null || el === undefined || !el.children) return false
|
|
2469
2541
|
const kids = el.children
|
|
@@ -2498,18 +2570,69 @@ window.__ModuleLoader__.load({
|
|
|
2498
2570
|
el.style.whiteSpace = ''
|
|
2499
2571
|
}
|
|
2500
2572
|
|
|
2573
|
+
// A short reply can be a bare text node (or a span) rather than the
|
|
2574
|
+
// block-level markdown container the picker looks for. Match the deepest
|
|
2575
|
+
// element in the owning row whose whole text is exactly the recorded text;
|
|
2576
|
+
// never a button, a timestamp, our own chrome, or an ancestor that merely
|
|
2577
|
+
// contains the text.
|
|
2578
|
+
function findPatchTextFallback(owner, wanted) {
|
|
2579
|
+
if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return null
|
|
2580
|
+
const original = matchText(wanted.original)
|
|
2581
|
+
if (original === '') return null
|
|
2582
|
+
let scope = owner
|
|
2583
|
+
try {
|
|
2584
|
+
const node = owner.querySelector('[data-slot="conversation.chat.node"]')
|
|
2585
|
+
if (node !== null && node !== undefined) scope = node
|
|
2586
|
+
} catch {
|
|
2587
|
+
/* keep the row as the scope */
|
|
2588
|
+
}
|
|
2589
|
+
for (const el of scope.querySelectorAll('*')) {
|
|
2590
|
+
if (el === scope) continue
|
|
2591
|
+
if (el.getAttribute(PATCH_ATTR) !== null) continue
|
|
2592
|
+
try {
|
|
2593
|
+
if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, .dshet-patch-note, button, time') !== null) continue
|
|
2594
|
+
} catch {
|
|
2595
|
+
continue
|
|
2596
|
+
}
|
|
2597
|
+
const text = matchText(el.textContent)
|
|
2598
|
+
if (text !== original) continue
|
|
2599
|
+
let deeper = false
|
|
2600
|
+
for (const child of el.children ?? []) {
|
|
2601
|
+
if (matchText(child.textContent) === text) {
|
|
2602
|
+
deeper = true
|
|
2603
|
+
break
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
if (!deeper) return el
|
|
2607
|
+
}
|
|
2608
|
+
return null
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2501
2611
|
function applyPatchDisplay(snapshot, view, t) {
|
|
2502
2612
|
// Map a reply seq to the host row that draws it, so a short answer like
|
|
2503
2613
|
// "好的" cannot be matched in a different row. Fall back to a global
|
|
2504
2614
|
// search when the row is not mounted (virtualised histories).
|
|
2505
2615
|
const rowBySeq = new Map()
|
|
2616
|
+
// A turn-process row also covers the turn's assistant seq (seqsFor reads
|
|
2617
|
+
// its anchor), and it comes before the assistant row in document order.
|
|
2618
|
+
// First-win mapping therefore pointed the patch at the process row, whose
|
|
2619
|
+
// subtree has no message body: zero matches. Prefer the row that actually
|
|
2620
|
+
// draws the message; the process/tail rows are only a last resort.
|
|
2621
|
+
const rowRank = new Map()
|
|
2506
2622
|
try {
|
|
2507
2623
|
for (const row of document.querySelectorAll('[data-chat-flow-key]')) {
|
|
2508
2624
|
const key = row.getAttribute('data-chat-flow-key')
|
|
2509
2625
|
if (!key || !snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') continue
|
|
2510
2626
|
const node = snapshot.nodes.get(key)
|
|
2511
2627
|
if (!node) continue
|
|
2512
|
-
|
|
2628
|
+
const rank = node.kind === 'assistant-step' ? 0 : node.kind === 'user' ? 1 : 2
|
|
2629
|
+
for (const seq of seqsFor(node)) {
|
|
2630
|
+
const previous = rowRank.get(seq)
|
|
2631
|
+
if (previous === undefined || rank < previous) {
|
|
2632
|
+
rowBySeq.set(seq, row)
|
|
2633
|
+
rowRank.set(seq, rank)
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2513
2636
|
}
|
|
2514
2637
|
} catch {
|
|
2515
2638
|
/* a host shape this pass cannot read falls back below */
|
|
@@ -2541,6 +2664,13 @@ window.__ModuleLoader__.load({
|
|
|
2541
2664
|
}
|
|
2542
2665
|
}
|
|
2543
2666
|
|
|
2667
|
+
// A patch that no longer exists must give the host's recorded text back,
|
|
2668
|
+
// even when a re-render already stripped our marker from the element.
|
|
2669
|
+
for (const [key, el] of [...patchedElements]) {
|
|
2670
|
+
if (wantedText.has(key)) continue
|
|
2671
|
+
patchedElements.delete(key)
|
|
2672
|
+
if (el.isConnected === true) restorePatchedBlock(el)
|
|
2673
|
+
}
|
|
2544
2674
|
// Remove overrides whose patch is gone (or was superseded by a prompt
|
|
2545
2675
|
// rollback). The original HTML is what the host rendered.
|
|
2546
2676
|
for (const el of [...root.querySelectorAll(`[${PATCH_ATTR}]`)]) {
|
|
@@ -2550,8 +2680,9 @@ window.__ModuleLoader__.load({
|
|
|
2550
2680
|
restorePatchedBlock(el)
|
|
2551
2681
|
continue
|
|
2552
2682
|
}
|
|
2553
|
-
|
|
2554
|
-
|
|
2683
|
+
const shown = displayText(wanted.text)
|
|
2684
|
+
if (normalizedText(el.textContent) !== normalizedText(shown)) {
|
|
2685
|
+
el.textContent = shown
|
|
2555
2686
|
}
|
|
2556
2687
|
}
|
|
2557
2688
|
for (const note of [...root.querySelectorAll(`[${NOTE_ATTR}]`)]) {
|
|
@@ -2572,13 +2703,33 @@ window.__ModuleLoader__.load({
|
|
|
2572
2703
|
}
|
|
2573
2704
|
}
|
|
2574
2705
|
for (const [key, wanted] of wantedText) {
|
|
2706
|
+
const owner = rowBySeq.get(wanted.seq)
|
|
2707
|
+
const remembered = patchedElements.get(key)
|
|
2708
|
+
if (remembered !== undefined) {
|
|
2709
|
+
const stillThere = remembered.isConnected === true &&
|
|
2710
|
+
(owner === undefined || remembered.closest('[data-chat-flow-key]') === owner)
|
|
2711
|
+
if (!stillThere) {
|
|
2712
|
+
patchedElements.delete(key)
|
|
2713
|
+
} else {
|
|
2714
|
+
const shown = displayText(wanted.text)
|
|
2715
|
+
if (normalizedText(remembered.textContent) !== normalizedText(shown)) {
|
|
2716
|
+
remembered.textContent = shown
|
|
2717
|
+
}
|
|
2718
|
+
if (remembered.getAttribute(PATCH_ATTR) !== key) remembered.setAttribute(PATCH_ATTR, key)
|
|
2719
|
+
remembered.style.whiteSpace = 'pre-wrap'
|
|
2720
|
+
continue
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2575
2723
|
const done = [...root.querySelectorAll(`[${PATCH_ATTR}]`)].find((el) => el.getAttribute(PATCH_ATTR) === key)
|
|
2576
|
-
if (done !== undefined)
|
|
2577
|
-
|
|
2724
|
+
if (done !== undefined) {
|
|
2725
|
+
patchedElements.set(key, done)
|
|
2726
|
+
if (done.style.whiteSpace !== 'pre-wrap') done.style.whiteSpace = 'pre-wrap'
|
|
2727
|
+
continue
|
|
2728
|
+
}
|
|
2729
|
+
const original = matchText(wanted.original)
|
|
2578
2730
|
if (original === '') continue
|
|
2579
2731
|
let best = null
|
|
2580
2732
|
let bestScore = Infinity
|
|
2581
|
-
const owner = rowBySeq.get(wanted.seq)
|
|
2582
2733
|
for (const el of containers) {
|
|
2583
2734
|
if (el.getAttribute(PATCH_ATTR) !== null) continue
|
|
2584
2735
|
if (owner !== undefined) {
|
|
@@ -2588,7 +2739,7 @@ window.__ModuleLoader__.load({
|
|
|
2588
2739
|
/* no closest on this node: keep the global match */
|
|
2589
2740
|
}
|
|
2590
2741
|
}
|
|
2591
|
-
const text =
|
|
2742
|
+
const text = matchText(el.textContent)
|
|
2592
2743
|
if (text === '') continue
|
|
2593
2744
|
const exact = text === original
|
|
2594
2745
|
const prefix = text.startsWith(original.slice(0, 60)) || original.startsWith(text.slice(0, 60))
|
|
@@ -2602,11 +2753,13 @@ window.__ModuleLoader__.load({
|
|
|
2602
2753
|
bestScore = score
|
|
2603
2754
|
}
|
|
2604
2755
|
}
|
|
2756
|
+
if (best === null) best = findPatchTextFallback(owner, wanted)
|
|
2605
2757
|
if (best === null) continue
|
|
2606
2758
|
patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
|
|
2607
|
-
best.textContent = wanted.text
|
|
2759
|
+
best.textContent = displayText(wanted.text)
|
|
2608
2760
|
best.setAttribute(PATCH_ATTR, key)
|
|
2609
2761
|
best.style.whiteSpace = 'pre-wrap'
|
|
2762
|
+
patchedElements.set(key, best)
|
|
2610
2763
|
}
|
|
2611
2764
|
|
|
2612
2765
|
// Reasoning notes: put one under the reply row, found by the reply's own
|
|
@@ -2614,15 +2767,15 @@ window.__ModuleLoader__.load({
|
|
|
2614
2767
|
for (const [key, wanted] of wantedNotes) {
|
|
2615
2768
|
let note = [...root.querySelectorAll(`[${NOTE_ATTR}]`)].find((el) => el.getAttribute(NOTE_ATTR) === key)
|
|
2616
2769
|
let row = rowBySeq.get(wanted.reply.seq) ?? null
|
|
2617
|
-
const replyText =
|
|
2770
|
+
const replyText = matchText(wanted.reply.text)
|
|
2618
2771
|
for (const node of row === null ? nodes : []) {
|
|
2619
|
-
const text =
|
|
2772
|
+
const text = matchText(node.textContent)
|
|
2620
2773
|
if (text === '') continue
|
|
2621
2774
|
if (replyText !== '' && text.includes(replyText)) {
|
|
2622
2775
|
row = node
|
|
2623
2776
|
break
|
|
2624
2777
|
}
|
|
2625
|
-
if (
|
|
2778
|
+
if (matchText(wanted.block.original) !== '' && text.includes(matchText(wanted.block.original))) {
|
|
2626
2779
|
row = node
|
|
2627
2780
|
break
|
|
2628
2781
|
}
|
|
@@ -2967,6 +3120,14 @@ window.__ModuleLoader__.load({
|
|
|
2967
3120
|
// --- plugin ---------------------------------------------------------------
|
|
2968
3121
|
|
|
2969
3122
|
function apply(ctx) {
|
|
3123
|
+
// Diagnostic only: the host restart does not reload the page's JS, so a
|
|
3124
|
+
// stale client bundle and a live host can coexist. This marker says which
|
|
3125
|
+
// browser half actually ran (the old bundle has none).
|
|
3126
|
+
try {
|
|
3127
|
+
document.documentElement.dataset.dshetVersion = PLUGIN_VERSION
|
|
3128
|
+
} catch {
|
|
3129
|
+
/* a non-DOM host is not worth failing over */
|
|
3130
|
+
}
|
|
2970
3131
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-edit-turn: dictionaries')
|
|
2971
3132
|
|
|
2972
3133
|
const controllers = new Map()
|
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.3.
|
|
83
|
+
export const PLUGIN_VERSION = '0.3.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
|
|
@@ -1239,7 +1239,20 @@ export function editableTurns(events, surfaceNodes, options = {}) {
|
|
|
1239
1239
|
if (!event || !isHumanPrompt(event)) continue
|
|
1240
1240
|
if (seq === head) continue
|
|
1241
1241
|
const { text, attachments } = readMessageText(event)
|
|
1242
|
-
|
|
1242
|
+
// A replacement carrier is appended after its original turn's bracket has
|
|
1243
|
+
// closed, so turnIndex cannot name its turn. It still stands at that
|
|
1244
|
+
// prompt's position in the conversation: inherit the turn from the event
|
|
1245
|
+
// it replaced, or the editor's turn list loses the row (turn: null).
|
|
1246
|
+
let turn = turnOf.get(seq)
|
|
1247
|
+
if (typeof turn !== 'number' && Array.isArray(event.sourceEventSeqs)) {
|
|
1248
|
+
for (const source of event.sourceEventSeqs) {
|
|
1249
|
+
const inherited = turnOf.get(source)
|
|
1250
|
+
if (typeof inherited === 'number') {
|
|
1251
|
+
turn = inherited
|
|
1252
|
+
break
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1243
1256
|
out.push({
|
|
1244
1257
|
seq,
|
|
1245
1258
|
turn: typeof turn === 'number' ? turn : null,
|
|
@@ -1645,6 +1658,13 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
1645
1658
|
const folded = foldSurface(events)
|
|
1646
1659
|
const ledger = rollbackLedger(events)
|
|
1647
1660
|
const patches = patchesOf(events)
|
|
1661
|
+
// A patch whose target has been retired (a later rerun shadowed it) is inert:
|
|
1662
|
+
// the projection skips it and no row can show it. Reporting it in /state made
|
|
1663
|
+
// the browser draw a patch that could never land, and made "which patches are
|
|
1664
|
+
// live" ambiguous. Keep the internal map for the replies; publish the active
|
|
1665
|
+
// subset only.
|
|
1666
|
+
const surfaceSet = new Set(folded.nodes)
|
|
1667
|
+
const activePatches = new Map([...patches].filter(([seq]) => surfaceSet.has(seq)))
|
|
1648
1668
|
const live = findLiveSession(ctx, sessionId)
|
|
1649
1669
|
// Probed per request: the editor offers what THIS deployment can do right
|
|
1650
1670
|
// now, so a store that is unmounted (or not yet mounted) cannot leave the
|
|
@@ -1676,7 +1696,7 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
1676
1696
|
// The landed in-place patches, latest per block. The browser half uses them
|
|
1677
1697
|
// to draw the projected text in place of the recorded one; the model already
|
|
1678
1698
|
// sees them through the host projection.
|
|
1679
|
-
patches: patchList(
|
|
1699
|
+
patches: patchList(activePatches),
|
|
1680
1700
|
mode: config.patch === true ? 'patch' : 'replay',
|
|
1681
1701
|
live: Boolean(live),
|
|
1682
1702
|
busy: isBusy(events),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-edit-turn",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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",
|