dsh-edit-turn 0.2.21 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -25
- package/lib/client.js +401 -13
- package/lib/index.js +211 -30
- package/lib/patch.js +260 -0
- package/package.json +11 -7
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.
|
|
45
|
+
const PLUGIN_VERSION = '0.3.0'
|
|
46
46
|
|
|
47
47
|
// --- copy -----------------------------------------------------------------
|
|
48
48
|
|
|
@@ -64,6 +64,12 @@ window.__ModuleLoader__.load({
|
|
|
64
64
|
'editor.save': '保存',
|
|
65
65
|
'editor.rerun': '重跑',
|
|
66
66
|
'editor.save-reply': '保存',
|
|
67
|
+
'editor.block': '编辑哪一块',
|
|
68
|
+
'editor.block.text': '正文',
|
|
69
|
+
'editor.block.reasoning': '思维链',
|
|
70
|
+
'editor.placeholder-block': '只改这一块;工具调用、其余内容和后续对话保持不变。',
|
|
71
|
+
'confirm.body-block': '确认后只替换这一块,工具调用、其他内容和后续对话保持不变。原始会话日志不会改写。',
|
|
72
|
+
'editor.patch-note': '思维链已改写(模型可见)',
|
|
67
73
|
'editor.review': '确认执行',
|
|
68
74
|
'editor.cancel': '取消',
|
|
69
75
|
'editor.pending': '正在保存…',
|
|
@@ -77,6 +83,7 @@ window.__ModuleLoader__.load({
|
|
|
77
83
|
'error.session-not-found': '找不到该会话的日志。',
|
|
78
84
|
'error.busy': '该会话正在回复中,请等回复结束后再编辑。',
|
|
79
85
|
'error.not-editable': '这条消息不支持编辑。',
|
|
86
|
+
'error.unsupported': '当前宿主不支持就地改写回复,需要走重放路径。',
|
|
80
87
|
'error.already-rolled-back': '这条消息已经不在当前上下文里了。',
|
|
81
88
|
'error.stale': '会话刚刚发生了变化,请重试。',
|
|
82
89
|
'error.forbidden': '请求来源不被允许。',
|
|
@@ -112,6 +119,12 @@ window.__ModuleLoader__.load({
|
|
|
112
119
|
'editor.save': 'Save',
|
|
113
120
|
'editor.rerun': 'Re-run',
|
|
114
121
|
'editor.save-reply': 'Save',
|
|
122
|
+
'editor.block': 'Block',
|
|
123
|
+
'editor.block.text': 'Answer',
|
|
124
|
+
'editor.block.reasoning': 'Reasoning',
|
|
125
|
+
'editor.placeholder-block': 'Only this block changes; tool calls, the rest of the reply and later turns stay.',
|
|
126
|
+
'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.',
|
|
127
|
+
'editor.patch-note': 'Reasoning patched (model-visible)',
|
|
115
128
|
'editor.review': 'Confirm',
|
|
116
129
|
'editor.cancel': 'Cancel',
|
|
117
130
|
'editor.pending': 'Working...',
|
|
@@ -125,6 +138,7 @@ window.__ModuleLoader__.load({
|
|
|
125
138
|
'error.session-not-found': 'No session log was found for this id.',
|
|
126
139
|
'error.busy': 'This session is still replying; wait for it to finish.',
|
|
127
140
|
'error.not-editable': 'This message cannot be edited.',
|
|
141
|
+
'error.unsupported': 'This harness does not expose the in-place reply path; the replay path is needed.',
|
|
128
142
|
'error.already-rolled-back': 'This message is no longer in the current context.',
|
|
129
143
|
'error.stale': 'The session just changed; try again.',
|
|
130
144
|
'error.forbidden': 'The request origin is not allowed.',
|
|
@@ -283,6 +297,12 @@ window.__ModuleLoader__.load({
|
|
|
283
297
|
'.dshet-btn:disabled{cursor:default;opacity:.5}',
|
|
284
298
|
'.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))}',
|
|
285
299
|
'.dshet-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-fill,var(--dshet-accent));filter:brightness(1.08)}',
|
|
300
|
+
'.dshet-pick{display:flex;align-items:center;gap:8px;padding:8px 12px 0;font-size:12px;color:var(--dshet-ink-dim)}',
|
|
301
|
+
'.dshet-select{padding:3px 8px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:12px;line-height:18px}',
|
|
302
|
+
'[data-dshet-patch]{white-space:pre-wrap}',
|
|
303
|
+
'.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}',
|
|
304
|
+
'.dshet-patch-note summary{cursor:pointer}',
|
|
305
|
+
'.dshet-patch-note-body{margin:6px 0 0;white-space:pre-wrap;color:var(--dshet-ink);font-size:12px;line-height:18px}',
|
|
286
306
|
'.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}',
|
|
287
307
|
'@media (prefers-reduced-motion:reduce){.dshet-collapsing{transition:none}.dshet-floating{transition:none}}',
|
|
288
308
|
].join('')
|
|
@@ -370,6 +390,15 @@ window.__ModuleLoader__.load({
|
|
|
370
390
|
editable: new Map(),
|
|
371
391
|
replies: new Map(),
|
|
372
392
|
repliesByMessage: new Map(),
|
|
393
|
+
// Landed in-place reply patches, latest per block, and the same data
|
|
394
|
+
// indexed by seq for the in-place text display.
|
|
395
|
+
patches: [],
|
|
396
|
+
patchBySeq: new Map(),
|
|
397
|
+
replyMode: 'replay',
|
|
398
|
+
// The block being edited in the reply editor (null for a prompt).
|
|
399
|
+
blocks: [],
|
|
400
|
+
blockIndex: null,
|
|
401
|
+
blockKind: null,
|
|
373
402
|
editing: null,
|
|
374
403
|
draft: '',
|
|
375
404
|
parts: [],
|
|
@@ -448,6 +477,22 @@ window.__ModuleLoader__.load({
|
|
|
448
477
|
for (const turn of Array.isArray(data.turns) ? data.turns : []) {
|
|
449
478
|
if (turn && typeof turn.seq === 'number') editable.set(turn.seq, turn)
|
|
450
479
|
}
|
|
480
|
+
// The in-place reply patches, latest per block. The model sees them
|
|
481
|
+
// through the host projection; this half uses them to draw the
|
|
482
|
+
// projected text over the recorded row.
|
|
483
|
+
const patches = []
|
|
484
|
+
const patchBySeq = new Map()
|
|
485
|
+
for (const patch of Array.isArray(data.patches) ? data.patches : []) {
|
|
486
|
+
if (!patch || typeof patch.seq !== 'number' || typeof patch.blockIndex !== 'number') continue
|
|
487
|
+
if (patch.kind !== 'text' && patch.kind !== 'reasoning') continue
|
|
488
|
+
const normalized = { seq: patch.seq, blockIndex: patch.blockIndex, kind: patch.kind, text: String(patch.text ?? '') }
|
|
489
|
+
const previous = patches.findIndex((entry) => entry.seq === normalized.seq && entry.blockIndex === normalized.blockIndex)
|
|
490
|
+
if (previous >= 0) patches[previous] = normalized
|
|
491
|
+
else patches.push(normalized)
|
|
492
|
+
let byBlock = patchBySeq.get(normalized.seq)
|
|
493
|
+
if (byBlock === undefined) { byBlock = new Map(); patchBySeq.set(normalized.seq, byBlock) }
|
|
494
|
+
byBlock.set(normalized.blockIndex, normalized)
|
|
495
|
+
}
|
|
451
496
|
// The model's replies are editable too, as a separate list: they are
|
|
452
497
|
// rewritten rather than re-run, so the editor words the consequence
|
|
453
498
|
// differently.
|
|
@@ -466,6 +511,9 @@ window.__ModuleLoader__.load({
|
|
|
466
511
|
editable,
|
|
467
512
|
replies,
|
|
468
513
|
repliesByMessage,
|
|
514
|
+
patches,
|
|
515
|
+
patchBySeq,
|
|
516
|
+
replyMode: data.mode === 'patch' ? 'patch' : 'replay',
|
|
469
517
|
surfaceReady: true,
|
|
470
518
|
loaded: true,
|
|
471
519
|
loadError: false,
|
|
@@ -493,10 +541,34 @@ window.__ModuleLoader__.load({
|
|
|
493
541
|
const same = this.editor !== null && this.editor.seq === target.seq && this.editor.mode === target.mode
|
|
494
542
|
const previous = this.editorElement
|
|
495
543
|
this.load()
|
|
496
|
-
|
|
544
|
+
// A reply on the patch path carries its editable blocks; open the one
|
|
545
|
+
// the editor was asked for, or the answer text when none was named.
|
|
546
|
+
const replyBlocks = target.mode === 'reply' && Array.isArray(target.blocks) ? target.blocks : []
|
|
547
|
+
const requested = typeof target.blockIndex === 'number'
|
|
548
|
+
? replyBlocks.find((block) => block.index === target.blockIndex)
|
|
549
|
+
: undefined
|
|
550
|
+
// Pressing the same pencil again keeps the block the user already
|
|
551
|
+
// switched to; only a fresh open picks the answer block by default.
|
|
552
|
+
const current = same && typeof this.editorBlockIndex === 'number'
|
|
553
|
+
? replyBlocks.find((block) => block.index === this.editorBlockIndex)
|
|
554
|
+
: undefined
|
|
555
|
+
const chosen = current ?? requested ?? replyBlocks.find((block) => block.kind === 'text') ?? replyBlocks[0]
|
|
556
|
+
const normalized = replyBlocks.length > 0
|
|
557
|
+
? {
|
|
558
|
+
...target,
|
|
559
|
+
patch: true,
|
|
560
|
+
blocks: replyBlocks,
|
|
561
|
+
blockIndex: chosen === undefined ? undefined : chosen.index,
|
|
562
|
+
blockKind: chosen === undefined ? undefined : chosen.kind,
|
|
563
|
+
}
|
|
564
|
+
: target
|
|
565
|
+
this.editor = normalized
|
|
566
|
+
this.editorBlocks = replyBlocks
|
|
567
|
+
this.editorBlockIndex = chosen === undefined ? null : chosen.index
|
|
568
|
+
this.editorBlockKind = chosen === undefined ? null : chosen.kind
|
|
497
569
|
if (!same) {
|
|
498
|
-
this.editorDraft = target.text
|
|
499
|
-
this.editorParts = blocksToParts(
|
|
570
|
+
this.editorDraft = chosen === undefined ? target.text : chosen.text
|
|
571
|
+
this.editorParts = blocksToParts(normalized, this.sessionId)
|
|
500
572
|
}
|
|
501
573
|
this.confirming = false
|
|
502
574
|
this.editorElement = null
|
|
@@ -513,7 +585,16 @@ window.__ModuleLoader__.load({
|
|
|
513
585
|
/* focus is best-effort */
|
|
514
586
|
}
|
|
515
587
|
}
|
|
516
|
-
this.publish({
|
|
588
|
+
this.publish({
|
|
589
|
+
editing: normalized,
|
|
590
|
+
draft: this.editorDraft,
|
|
591
|
+
parts: this.editorParts.slice(),
|
|
592
|
+
blocks: replyBlocks,
|
|
593
|
+
blockIndex: this.editorBlockIndex,
|
|
594
|
+
blockKind: this.editorBlockKind,
|
|
595
|
+
confirming: false,
|
|
596
|
+
failure: null,
|
|
597
|
+
})
|
|
517
598
|
this.probeSiblingRerun()
|
|
518
599
|
}
|
|
519
600
|
|
|
@@ -541,6 +622,22 @@ window.__ModuleLoader__.load({
|
|
|
541
622
|
if (this.view.siblingRerun !== next) this.publish({ siblingRerun: next })
|
|
542
623
|
}
|
|
543
624
|
|
|
625
|
+
// Pick another block of the same reply. The editor is rebuilt by the
|
|
626
|
+
// block index in its marker; the draft becomes that block's current
|
|
627
|
+
// (already projected) text.
|
|
628
|
+
selectBlock(index) {
|
|
629
|
+
if (this.view.pending) return
|
|
630
|
+
const target = this.editor
|
|
631
|
+
if (target === null || !Array.isArray(target.blocks)) return
|
|
632
|
+
const block = target.blocks.find((entry) => entry.index === index)
|
|
633
|
+
if (block === undefined || block.kind === undefined) return
|
|
634
|
+
this.editorBlockIndex = block.index
|
|
635
|
+
this.editorBlockKind = block.kind
|
|
636
|
+
this.editorDraft = block.text
|
|
637
|
+
this.editorElement = null
|
|
638
|
+
this.publish({ draft: block.text, blockIndex: block.index, blockKind: block.kind, failure: null })
|
|
639
|
+
}
|
|
640
|
+
|
|
544
641
|
close() {
|
|
545
642
|
if (this.view.pending) return
|
|
546
643
|
this.pendingRerun = false
|
|
@@ -553,7 +650,10 @@ window.__ModuleLoader__.load({
|
|
|
553
650
|
this.editorParts = []
|
|
554
651
|
this.confirming = false
|
|
555
652
|
this.editorElement = null
|
|
556
|
-
this.
|
|
653
|
+
this.editorBlocks = []
|
|
654
|
+
this.editorBlockIndex = null
|
|
655
|
+
this.editorBlockKind = null
|
|
656
|
+
this.publish({ editing: null, draft: '', parts: [], blocks: [], blockIndex: null, blockKind: null, confirming: false, failure: null })
|
|
557
657
|
}
|
|
558
658
|
|
|
559
659
|
setDraft(value) {
|
|
@@ -683,6 +783,11 @@ window.__ModuleLoader__.load({
|
|
|
683
783
|
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
684
784
|
text,
|
|
685
785
|
...(parts === undefined ? {} : { parts }),
|
|
786
|
+
// The in-place reply path names one block; the host validates that
|
|
787
|
+
// it is still that kind at that index before writing the record.
|
|
788
|
+
...(target.patch === true && typeof this.editorBlockIndex === 'number'
|
|
789
|
+
? { blockIndex: this.editorBlockIndex, kind: this.editorBlockKind }
|
|
790
|
+
: {}),
|
|
686
791
|
}),
|
|
687
792
|
})
|
|
688
793
|
const data = await res.json().catch(() => ({}))
|
|
@@ -724,6 +829,40 @@ window.__ModuleLoader__.load({
|
|
|
724
829
|
// replies here and re-enters through the next /state refresh (the
|
|
725
830
|
// appended correction is itself an editable reply).
|
|
726
831
|
const replies = new Map(this.view.replies)
|
|
832
|
+
// The in-place path wrote one small record and touched no surface
|
|
833
|
+
// row: the reply stays where it is, and its block list is updated
|
|
834
|
+
// optimistically so the next render already shows the new text.
|
|
835
|
+
let patches = this.view.patches
|
|
836
|
+
let patchBySeq = this.view.patchBySeq
|
|
837
|
+
if (data.mode === 'patch' && data.target && typeof data.target.blockIndex === 'number') {
|
|
838
|
+
const targetPatch = {
|
|
839
|
+
seq: target.seq,
|
|
840
|
+
blockIndex: data.target.blockIndex,
|
|
841
|
+
kind: data.target.kind === 'reasoning' ? 'reasoning' : 'text',
|
|
842
|
+
text: String(data.target.text ?? ''),
|
|
843
|
+
}
|
|
844
|
+
patches = patches
|
|
845
|
+
.filter((entry) => !(entry.seq === targetPatch.seq && entry.blockIndex === targetPatch.blockIndex))
|
|
846
|
+
.concat([targetPatch])
|
|
847
|
+
patchBySeq = new Map(patchBySeq)
|
|
848
|
+
const byBlock = new Map(patchBySeq.get(targetPatch.seq) ?? [])
|
|
849
|
+
byBlock.set(targetPatch.blockIndex, targetPatch)
|
|
850
|
+
patchBySeq.set(targetPatch.seq, byBlock)
|
|
851
|
+
const entry = replies.get(target.seq)
|
|
852
|
+
if (entry !== undefined && Array.isArray(entry.blocks)) {
|
|
853
|
+
const blocks = entry.blocks.map((block) =>
|
|
854
|
+
block.index === targetPatch.blockIndex
|
|
855
|
+
? { ...block, text: targetPatch.text, patched: true }
|
|
856
|
+
: block)
|
|
857
|
+
const text = blocks.filter((block) => block.kind === 'text').map((block) => block.text).join('\n')
|
|
858
|
+
replies.set(target.seq, {
|
|
859
|
+
...entry,
|
|
860
|
+
blocks,
|
|
861
|
+
text,
|
|
862
|
+
patched: blocks.some((block) => block.patched === true),
|
|
863
|
+
})
|
|
864
|
+
}
|
|
865
|
+
}
|
|
727
866
|
for (const entry of replies.values()) {
|
|
728
867
|
if (hidden.has(entry.seq)) replies.delete(entry.seq)
|
|
729
868
|
}
|
|
@@ -744,6 +883,11 @@ window.__ModuleLoader__.load({
|
|
|
744
883
|
revisions,
|
|
745
884
|
editable,
|
|
746
885
|
replies,
|
|
886
|
+
patches,
|
|
887
|
+
patchBySeq,
|
|
888
|
+
blocks: [],
|
|
889
|
+
blockIndex: null,
|
|
890
|
+
blockKind: null,
|
|
747
891
|
parts: [],
|
|
748
892
|
failure: null,
|
|
749
893
|
})
|
|
@@ -1965,6 +2109,8 @@ window.__ModuleLoader__.load({
|
|
|
1965
2109
|
// saving would write, so both have to redraw the strip - and the chip
|
|
1966
2110
|
// keys are enough to tell one list from another.
|
|
1967
2111
|
view.parts.map((part) => part.key).join('.'),
|
|
2112
|
+
// The selected reply block: switching blocks has to rebuild the box.
|
|
2113
|
+
view.blockIndex === null || view.blockIndex === undefined ? '' : String(view.blockIndex),
|
|
1968
2114
|
].join('|')
|
|
1969
2115
|
}
|
|
1970
2116
|
|
|
@@ -1973,9 +2119,10 @@ window.__ModuleLoader__.load({
|
|
|
1973
2119
|
// from drifting apart in wording.
|
|
1974
2120
|
function editorTexts(t, target) {
|
|
1975
2121
|
const reply = target.mode === 'reply'
|
|
2122
|
+
const block = reply && target.patch === true
|
|
1976
2123
|
return {
|
|
1977
2124
|
title: t(reply ? 'editor.title-reply' : 'editor.title'),
|
|
1978
|
-
placeholder: t(reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
|
|
2125
|
+
placeholder: t(block ? 'editor.placeholder-block' : reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
|
|
1979
2126
|
warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
|
|
1980
2127
|
// Saving writes the revision into the context; only the opt-in config asks
|
|
1981
2128
|
// the model again, and the button says which one will happen.
|
|
@@ -2108,6 +2255,34 @@ window.__ModuleLoader__.load({
|
|
|
2108
2255
|
box.dataset[EDITOR_ATTR] = '1'
|
|
2109
2256
|
box.dataset.dshetFor = marker
|
|
2110
2257
|
|
|
2258
|
+
// A reply on the patch path may carry several editable blocks (answer
|
|
2259
|
+
// text and reasoning). The picker chooses which one this save targets;
|
|
2260
|
+
// every option carries the block's CURRENT projected text, so switching
|
|
2261
|
+
// never shows the recorded original over a landed patch.
|
|
2262
|
+
if (target.mode === 'reply' && target.patch === true && Array.isArray(target.blocks) && target.blocks.length > 1) {
|
|
2263
|
+
const picker = document.createElement('label')
|
|
2264
|
+
picker.className = 'dshet-pick'
|
|
2265
|
+
const caption = document.createElement('span')
|
|
2266
|
+
caption.textContent = t('editor.block')
|
|
2267
|
+
const select = document.createElement('select')
|
|
2268
|
+
select.className = 'dshet-select'
|
|
2269
|
+
select.disabled = view.pending || view.confirming
|
|
2270
|
+
select.setAttribute('aria-label', t('editor.block'))
|
|
2271
|
+
for (const block of target.blocks) {
|
|
2272
|
+
const option = document.createElement('option')
|
|
2273
|
+
option.value = String(block.index)
|
|
2274
|
+
const kind = block.kind === 'reasoning' ? 'editor.block.reasoning' : 'editor.block.text'
|
|
2275
|
+
option.textContent = t(kind)
|
|
2276
|
+
option.selected = block.index === view.blockIndex
|
|
2277
|
+
select.appendChild(option)
|
|
2278
|
+
}
|
|
2279
|
+
if (view.blockIndex !== null && view.blockIndex !== undefined) select.value = String(view.blockIndex)
|
|
2280
|
+
select.addEventListener('change', () => controller.selectBlock(Number(select.value)))
|
|
2281
|
+
picker.appendChild(caption)
|
|
2282
|
+
picker.appendChild(select)
|
|
2283
|
+
box.appendChild(picker)
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2111
2286
|
const area = document.createElement('textarea')
|
|
2112
2287
|
area.rows = 1
|
|
2113
2288
|
area.value = controller.editorDraft
|
|
@@ -2167,12 +2342,12 @@ window.__ModuleLoader__.load({
|
|
|
2167
2342
|
if (view.confirming) {
|
|
2168
2343
|
const note = document.createElement('p')
|
|
2169
2344
|
note.className = 'dshet-note'
|
|
2170
|
-
note.textContent = t('confirm.body')
|
|
2345
|
+
note.textContent = t(target.mode === 'reply' && target.patch === true ? 'confirm.body-block' : 'confirm.body')
|
|
2171
2346
|
box.appendChild(note)
|
|
2172
2347
|
}
|
|
2173
2348
|
// Shown when saving really would drop something: a reply's tool calls
|
|
2174
2349
|
// always, and a prompt's blocks when this host cannot carry them.
|
|
2175
|
-
if (target.attachments > 0 && !chips) {
|
|
2350
|
+
if (target.attachments > 0 && !chips && !(target.mode === 'reply' && target.patch === true)) {
|
|
2176
2351
|
const warn = document.createElement('p')
|
|
2177
2352
|
warn.className = 'dshet-warn'
|
|
2178
2353
|
warn.textContent = texts.warn
|
|
@@ -2264,6 +2439,211 @@ window.__ModuleLoader__.load({
|
|
|
2264
2439
|
controller.load(true)
|
|
2265
2440
|
}
|
|
2266
2441
|
|
|
2442
|
+
// --- projected reply display ---------------------------------------------
|
|
2443
|
+
//
|
|
2444
|
+
// The host draws a reply from the recorded event, not from the projected
|
|
2445
|
+
// message, so a landed patch would be invisible in the transcript. The
|
|
2446
|
+
// projection is the model's copy; this is the reader's copy. Text blocks are
|
|
2447
|
+
// replaced in place inside the host's own markdown container (the row,
|
|
2448
|
+
// actions and tool cards stay untouched); a patched reasoning block gets a
|
|
2449
|
+
// small note under the reply, because the host renders reasoning as a
|
|
2450
|
+
// collapsed panel that may not be in the DOM at all.
|
|
2451
|
+
|
|
2452
|
+
const PATCH_ATTR = 'data-dshet-patch'
|
|
2453
|
+
const NOTE_ATTR = 'data-dshet-patch-note'
|
|
2454
|
+
const patchOriginals = new WeakMap()
|
|
2455
|
+
|
|
2456
|
+
function normalizedText(value) {
|
|
2457
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim()
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
// Markdown syntax is not part of the rendered textContent. Strip the
|
|
2461
|
+
// structural characters before comparing, so a reply that uses **bold** or
|
|
2462
|
+
// a fenced block still matches its own recorded text.
|
|
2463
|
+
function plainText(value) {
|
|
2464
|
+
return normalizedText(String(value ?? '').replace(/[`*_#>~[\]()!|]/g, ' ').replace(/\s+/g, ' '))
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
function isMarkdownContainer(el) {
|
|
2468
|
+
if (el === null || el === undefined || !el.children) return false
|
|
2469
|
+
const kids = el.children
|
|
2470
|
+
if (kids.length === 0) return false
|
|
2471
|
+
let blocks = 0
|
|
2472
|
+
for (const kid of kids) {
|
|
2473
|
+
try {
|
|
2474
|
+
if (kid.matches && kid.matches('p, pre, ul, ol, blockquote, h1, h2, h3, h4, h5, h6, table')) blocks += 1
|
|
2475
|
+
} catch {
|
|
2476
|
+
return false
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
return blocks >= 1 && blocks * 2 >= kids.length
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
function conversationRootForPatches() {
|
|
2483
|
+
const root = document.querySelector('[data-slot="main.conversation"], [data-slot="conversation"]')
|
|
2484
|
+
return root ?? document.body
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
function restorePatchedBlock(el) {
|
|
2488
|
+
const saved = patchOriginals.get(el)
|
|
2489
|
+
if (saved !== null && saved !== undefined) {
|
|
2490
|
+
try {
|
|
2491
|
+
if (typeof saved.html === 'string' && saved.html !== '') el.innerHTML = saved.html
|
|
2492
|
+
else if (typeof saved.text === 'string') el.textContent = saved.text
|
|
2493
|
+
} catch {
|
|
2494
|
+
/* a detached row is not worth a throw */
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
el.removeAttribute(PATCH_ATTR)
|
|
2498
|
+
el.style.whiteSpace = ''
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
function applyPatchDisplay(snapshot, view, t) {
|
|
2502
|
+
// Map a reply seq to the host row that draws it, so a short answer like
|
|
2503
|
+
// "好的" cannot be matched in a different row. Fall back to a global
|
|
2504
|
+
// search when the row is not mounted (virtualised histories).
|
|
2505
|
+
const rowBySeq = new Map()
|
|
2506
|
+
try {
|
|
2507
|
+
for (const row of document.querySelectorAll('[data-chat-flow-key]')) {
|
|
2508
|
+
const key = row.getAttribute('data-chat-flow-key')
|
|
2509
|
+
if (!key || !snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') continue
|
|
2510
|
+
const node = snapshot.nodes.get(key)
|
|
2511
|
+
if (!node) continue
|
|
2512
|
+
for (const seq of seqsFor(node)) if (!rowBySeq.has(seq)) rowBySeq.set(seq, row)
|
|
2513
|
+
}
|
|
2514
|
+
} catch {
|
|
2515
|
+
/* a host shape this pass cannot read falls back below */
|
|
2516
|
+
}
|
|
2517
|
+
const root = conversationRootForPatches()
|
|
2518
|
+
if (root === null) return
|
|
2519
|
+
const replies = view.replies instanceof Map ? view.replies : new Map()
|
|
2520
|
+
const wantedText = new Map()
|
|
2521
|
+
const wantedNotes = new Map()
|
|
2522
|
+
for (const patch of Array.isArray(view.patches) ? view.patches : []) {
|
|
2523
|
+
const reply = replies.get(patch.seq)
|
|
2524
|
+
if (reply === undefined || !Array.isArray(reply.blocks)) continue
|
|
2525
|
+
const block = reply.blocks.find((entry) => entry.index === patch.blockIndex)
|
|
2526
|
+
if (block === undefined) continue
|
|
2527
|
+
if (patch.kind === 'text') {
|
|
2528
|
+
wantedText.set(`${patch.seq}:${patch.blockIndex}`, {
|
|
2529
|
+
key: `${patch.seq}:${patch.blockIndex}`,
|
|
2530
|
+
seq: patch.seq,
|
|
2531
|
+
original: String(block.original ?? ''),
|
|
2532
|
+
text: String(patch.text ?? ''),
|
|
2533
|
+
})
|
|
2534
|
+
} else if (patch.kind === 'reasoning') {
|
|
2535
|
+
wantedNotes.set(`${patch.seq}:${patch.blockIndex}`, {
|
|
2536
|
+
key: `${patch.seq}:${patch.blockIndex}`,
|
|
2537
|
+
text: String(patch.text ?? ''),
|
|
2538
|
+
reply,
|
|
2539
|
+
block,
|
|
2540
|
+
})
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// Remove overrides whose patch is gone (or was superseded by a prompt
|
|
2545
|
+
// rollback). The original HTML is what the host rendered.
|
|
2546
|
+
for (const el of [...root.querySelectorAll(`[${PATCH_ATTR}]`)]) {
|
|
2547
|
+
const key = el.getAttribute(PATCH_ATTR)
|
|
2548
|
+
const wanted = wantedText.get(key)
|
|
2549
|
+
if (wanted === undefined) {
|
|
2550
|
+
restorePatchedBlock(el)
|
|
2551
|
+
continue
|
|
2552
|
+
}
|
|
2553
|
+
if (normalizedText(el.textContent) !== normalizedText(wanted.text)) {
|
|
2554
|
+
el.textContent = wanted.text
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
for (const note of [...root.querySelectorAll(`[${NOTE_ATTR}]`)]) {
|
|
2558
|
+
const key = note.getAttribute(NOTE_ATTR)
|
|
2559
|
+
if (!wantedNotes.has(key)) note.remove()
|
|
2560
|
+
}
|
|
2561
|
+
if (wantedText.size === 0 && wantedNotes.size === 0) return
|
|
2562
|
+
|
|
2563
|
+
const nodes = [...root.querySelectorAll('[data-slot="conversation.chat.node"]')]
|
|
2564
|
+
const containers = []
|
|
2565
|
+
for (const node of nodes) {
|
|
2566
|
+
for (const el of node.querySelectorAll('*')) {
|
|
2567
|
+
try {
|
|
2568
|
+
if (isMarkdownContainer(el) && el.closest('.dshet-editor, .dshet-pick, .dshet-patch-note') === null) containers.push(el)
|
|
2569
|
+
} catch {
|
|
2570
|
+
/* a detached node is skipped */
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
for (const [key, wanted] of wantedText) {
|
|
2575
|
+
const done = [...root.querySelectorAll(`[${PATCH_ATTR}]`)].find((el) => el.getAttribute(PATCH_ATTR) === key)
|
|
2576
|
+
if (done !== undefined) continue
|
|
2577
|
+
const original = plainText(wanted.original)
|
|
2578
|
+
if (original === '') continue
|
|
2579
|
+
let best = null
|
|
2580
|
+
let bestScore = Infinity
|
|
2581
|
+
const owner = rowBySeq.get(wanted.seq)
|
|
2582
|
+
for (const el of containers) {
|
|
2583
|
+
if (el.getAttribute(PATCH_ATTR) !== null) continue
|
|
2584
|
+
if (owner !== undefined) {
|
|
2585
|
+
try {
|
|
2586
|
+
if (el.closest('[data-chat-flow-key]') !== owner) continue
|
|
2587
|
+
} catch {
|
|
2588
|
+
/* no closest on this node: keep the global match */
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
const text = plainText(el.textContent)
|
|
2592
|
+
if (text === '') continue
|
|
2593
|
+
const exact = text === original
|
|
2594
|
+
const prefix = text.startsWith(original.slice(0, 60)) || original.startsWith(text.slice(0, 60))
|
|
2595
|
+
if (!exact && !prefix) continue
|
|
2596
|
+
// Prefer the exact match, then the container closest in length: a
|
|
2597
|
+
// nested paragraph and the whole markdown body can both match a
|
|
2598
|
+
// prefix, and the paragraph is the one to rewrite.
|
|
2599
|
+
const score = (exact ? 0 : 1000) + Math.abs(text.length - original.length)
|
|
2600
|
+
if (score < bestScore) {
|
|
2601
|
+
best = el
|
|
2602
|
+
bestScore = score
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
if (best === null) continue
|
|
2606
|
+
patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
|
|
2607
|
+
best.textContent = wanted.text
|
|
2608
|
+
best.setAttribute(PATCH_ATTR, key)
|
|
2609
|
+
best.style.whiteSpace = 'pre-wrap'
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// Reasoning notes: put one under the reply row, found by the reply's own
|
|
2613
|
+
// answer text (the panel itself may be collapsed and absent).
|
|
2614
|
+
for (const [key, wanted] of wantedNotes) {
|
|
2615
|
+
let note = [...root.querySelectorAll(`[${NOTE_ATTR}]`)].find((el) => el.getAttribute(NOTE_ATTR) === key)
|
|
2616
|
+
let row = rowBySeq.get(wanted.reply.seq) ?? null
|
|
2617
|
+
const replyText = plainText(wanted.reply.text)
|
|
2618
|
+
for (const node of row === null ? nodes : []) {
|
|
2619
|
+
const text = plainText(node.textContent)
|
|
2620
|
+
if (text === '') continue
|
|
2621
|
+
if (replyText !== '' && text.includes(replyText)) {
|
|
2622
|
+
row = node
|
|
2623
|
+
break
|
|
2624
|
+
}
|
|
2625
|
+
if (plainText(wanted.block.original) !== '' && text.includes(plainText(wanted.block.original))) {
|
|
2626
|
+
row = node
|
|
2627
|
+
break
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
if (note === undefined || note === null) {
|
|
2631
|
+
note = document.createElement('details')
|
|
2632
|
+
note.className = 'dshet-patch-note'
|
|
2633
|
+
note.setAttribute(NOTE_ATTR, key)
|
|
2634
|
+
const summary = document.createElement('summary')
|
|
2635
|
+
summary.textContent = t('editor.patch-note')
|
|
2636
|
+
const body = document.createElement('pre')
|
|
2637
|
+
body.className = 'dshet-patch-note-body'
|
|
2638
|
+
note.appendChild(summary)
|
|
2639
|
+
note.appendChild(body)
|
|
2640
|
+
}
|
|
2641
|
+
const body = note.querySelector('.dshet-patch-note-body')
|
|
2642
|
+
if (body !== null && body.textContent !== wanted.text) body.textContent = wanted.text
|
|
2643
|
+
if (note.isConnected !== true && row !== null) row.after(note)
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2267
2647
|
function applyDom(snapshot, view, controller, t) {
|
|
2268
2648
|
if (!snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') return
|
|
2269
2649
|
const animate = controller.consumeAnimate()
|
|
@@ -2329,6 +2709,13 @@ window.__ModuleLoader__.load({
|
|
|
2329
2709
|
controller.editorBox = null
|
|
2330
2710
|
controller.editorRow = null
|
|
2331
2711
|
}
|
|
2712
|
+
// Draw the projected reply text last: the host has just rebuilt the rows,
|
|
2713
|
+
// and an override applied before that would be wiped by the rebuild.
|
|
2714
|
+
try {
|
|
2715
|
+
applyPatchDisplay(snapshot, view, t)
|
|
2716
|
+
} catch (error) {
|
|
2717
|
+
console.error('[dsh-edit-turn] patch display failed:', error)
|
|
2718
|
+
}
|
|
2332
2719
|
// Whatever of ours no row of this pass claimed is a ghost of an earlier
|
|
2333
2720
|
// apply: the host rebuilt the row under it, or the bundle was reloaded
|
|
2334
2721
|
// over it. Contract §5 - a button nobody can update must not pile up.
|
|
@@ -2398,10 +2785,11 @@ window.__ModuleLoader__.load({
|
|
|
2398
2785
|
messageId: entry.messageId,
|
|
2399
2786
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
2400
2787
|
attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
|
|
2401
|
-
//
|
|
2402
|
-
//
|
|
2403
|
-
//
|
|
2404
|
-
|
|
2788
|
+
// On the in-place path the reply keeps its tool calls and other
|
|
2789
|
+
// blocks, so the editor can offer each editable block (output text
|
|
2790
|
+
// and reasoning). On the replay path there is no block picker: the
|
|
2791
|
+
// whole reply is replaced.
|
|
2792
|
+
...(Array.isArray(entry.blocks) ? { blocks: entry.blocks, patch: true } : { blocks: [] }),
|
|
2405
2793
|
})
|
|
2406
2794
|
}
|
|
2407
2795
|
// The config object is always an object: this runtime's `jsx` reads
|