dsh-edit-turn 0.2.20 → 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 +99 -29
- package/lib/client.js +412 -17
- package/lib/index.js +693 -94
- 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
|
|
|
@@ -59,11 +59,17 @@ window.__ModuleLoader__.load({
|
|
|
59
59
|
'action.copied': '复制成功',
|
|
60
60
|
'editor.title': '编辑这条消息',
|
|
61
61
|
'editor.title-reply': '编辑这条回答',
|
|
62
|
-
'editor.placeholder': '
|
|
62
|
+
'editor.placeholder': '改成你希望自己当时说的内容。保存后就地替换这条消息,后面的对话保持不变。',
|
|
63
63
|
'editor.placeholder-reply': '改成你希望模型说过的内容,保存后它就成为这条回答。',
|
|
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': '正在保存…',
|
|
@@ -71,12 +77,13 @@ window.__ModuleLoader__.load({
|
|
|
71
77
|
'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
|
|
72
78
|
'editor.attach.add': '添加附件',
|
|
73
79
|
'editor.attach.remove': '移除这个附件',
|
|
74
|
-
'confirm.body': '
|
|
80
|
+
'confirm.body': '确认后会立即执行:只有这条消息被替换,后面的对话保持不变。原始会话日志不会改写。',
|
|
75
81
|
'error.invalid': '请求无效,请刷新后重试。',
|
|
76
82
|
'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
|
|
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': '请求来源不被允许。',
|
|
@@ -96,6 +103,7 @@ window.__ModuleLoader__.load({
|
|
|
96
103
|
'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
|
|
97
104
|
'error.attachment-too-large': '这个文件太大,无法作为附件。',
|
|
98
105
|
'error.too-large': '这次修改太大,服务器没有接收。',
|
|
106
|
+
'error.replay-partial': '已保存并改写了这条回答,但后面的轮次没能全部重放回来,请重新打开会话确认。',
|
|
99
107
|
}
|
|
100
108
|
|
|
101
109
|
const en = {
|
|
@@ -106,11 +114,17 @@ window.__ModuleLoader__.load({
|
|
|
106
114
|
'action.copied': 'Copied',
|
|
107
115
|
'editor.title': 'Edit this message',
|
|
108
116
|
'editor.title-reply': 'Edit this reply',
|
|
109
|
-
'editor.placeholder': '
|
|
117
|
+
'editor.placeholder': 'Write what you want to have said. Saving replaces this message in place; the conversation after it stays as it is.',
|
|
110
118
|
'editor.placeholder-reply': 'Write what you want the model to have said; saving makes it this reply.',
|
|
111
119
|
'editor.save': 'Save',
|
|
112
120
|
'editor.rerun': 'Re-run',
|
|
113
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)',
|
|
114
128
|
'editor.review': 'Confirm',
|
|
115
129
|
'editor.cancel': 'Cancel',
|
|
116
130
|
'editor.pending': 'Working...',
|
|
@@ -118,12 +132,13 @@ window.__ModuleLoader__.load({
|
|
|
118
132
|
'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
|
|
119
133
|
'editor.attach.add': 'Add an attachment',
|
|
120
134
|
'editor.attach.remove': 'Remove this attachment',
|
|
121
|
-
'confirm.body': 'Confirming applies right away:
|
|
135
|
+
'confirm.body': 'Confirming applies right away: only this message is replaced; the conversation after it stays as it is. The original session log is never rewritten.',
|
|
122
136
|
'error.invalid': 'Invalid request; refresh and try again.',
|
|
123
137
|
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
124
138
|
'error.session-not-found': 'No session log was found for this id.',
|
|
125
139
|
'error.busy': 'This session is still replying; wait for it to finish.',
|
|
126
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.',
|
|
127
142
|
'error.already-rolled-back': 'This message is no longer in the current context.',
|
|
128
143
|
'error.stale': 'The session just changed; try again.',
|
|
129
144
|
'error.forbidden': 'The request origin is not allowed.',
|
|
@@ -143,6 +158,7 @@ window.__ModuleLoader__.load({
|
|
|
143
158
|
'error.attachment-unreadable': 'That attachment could not be read; try another file.',
|
|
144
159
|
'error.attachment-too-large': 'That file is too large to attach.',
|
|
145
160
|
'error.too-large': 'That revision was too large for the host to accept.',
|
|
161
|
+
'error.replay-partial': 'Saved and corrected, but the turns after it could not all be replayed; reopen the session to check.',
|
|
146
162
|
}
|
|
147
163
|
|
|
148
164
|
// Every error code the host can return travels through here. A code is
|
|
@@ -281,6 +297,12 @@ window.__ModuleLoader__.load({
|
|
|
281
297
|
'.dshet-btn:disabled{cursor:default;opacity:.5}',
|
|
282
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))}',
|
|
283
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}',
|
|
284
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}',
|
|
285
307
|
'@media (prefers-reduced-motion:reduce){.dshet-collapsing{transition:none}.dshet-floating{transition:none}}',
|
|
286
308
|
].join('')
|
|
@@ -368,6 +390,15 @@ window.__ModuleLoader__.load({
|
|
|
368
390
|
editable: new Map(),
|
|
369
391
|
replies: new Map(),
|
|
370
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,
|
|
371
402
|
editing: null,
|
|
372
403
|
draft: '',
|
|
373
404
|
parts: [],
|
|
@@ -446,6 +477,22 @@ window.__ModuleLoader__.load({
|
|
|
446
477
|
for (const turn of Array.isArray(data.turns) ? data.turns : []) {
|
|
447
478
|
if (turn && typeof turn.seq === 'number') editable.set(turn.seq, turn)
|
|
448
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
|
+
}
|
|
449
496
|
// The model's replies are editable too, as a separate list: they are
|
|
450
497
|
// rewritten rather than re-run, so the editor words the consequence
|
|
451
498
|
// differently.
|
|
@@ -464,6 +511,9 @@ window.__ModuleLoader__.load({
|
|
|
464
511
|
editable,
|
|
465
512
|
replies,
|
|
466
513
|
repliesByMessage,
|
|
514
|
+
patches,
|
|
515
|
+
patchBySeq,
|
|
516
|
+
replyMode: data.mode === 'patch' ? 'patch' : 'replay',
|
|
467
517
|
surfaceReady: true,
|
|
468
518
|
loaded: true,
|
|
469
519
|
loadError: false,
|
|
@@ -491,10 +541,34 @@ window.__ModuleLoader__.load({
|
|
|
491
541
|
const same = this.editor !== null && this.editor.seq === target.seq && this.editor.mode === target.mode
|
|
492
542
|
const previous = this.editorElement
|
|
493
543
|
this.load()
|
|
494
|
-
|
|
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
|
|
495
569
|
if (!same) {
|
|
496
|
-
this.editorDraft = target.text
|
|
497
|
-
this.editorParts = blocksToParts(
|
|
570
|
+
this.editorDraft = chosen === undefined ? target.text : chosen.text
|
|
571
|
+
this.editorParts = blocksToParts(normalized, this.sessionId)
|
|
498
572
|
}
|
|
499
573
|
this.confirming = false
|
|
500
574
|
this.editorElement = null
|
|
@@ -511,7 +585,16 @@ window.__ModuleLoader__.load({
|
|
|
511
585
|
/* focus is best-effort */
|
|
512
586
|
}
|
|
513
587
|
}
|
|
514
|
-
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
|
+
})
|
|
515
598
|
this.probeSiblingRerun()
|
|
516
599
|
}
|
|
517
600
|
|
|
@@ -539,6 +622,22 @@ window.__ModuleLoader__.load({
|
|
|
539
622
|
if (this.view.siblingRerun !== next) this.publish({ siblingRerun: next })
|
|
540
623
|
}
|
|
541
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
|
+
|
|
542
641
|
close() {
|
|
543
642
|
if (this.view.pending) return
|
|
544
643
|
this.pendingRerun = false
|
|
@@ -551,7 +650,10 @@ window.__ModuleLoader__.load({
|
|
|
551
650
|
this.editorParts = []
|
|
552
651
|
this.confirming = false
|
|
553
652
|
this.editorElement = null
|
|
554
|
-
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 })
|
|
555
657
|
}
|
|
556
658
|
|
|
557
659
|
setDraft(value) {
|
|
@@ -681,6 +783,11 @@ window.__ModuleLoader__.load({
|
|
|
681
783
|
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
682
784
|
text,
|
|
683
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
|
+
: {}),
|
|
684
791
|
}),
|
|
685
792
|
})
|
|
686
793
|
const data = await res.json().catch(() => ({}))
|
|
@@ -722,6 +829,40 @@ window.__ModuleLoader__.load({
|
|
|
722
829
|
// replies here and re-enters through the next /state refresh (the
|
|
723
830
|
// appended correction is itself an editable reply).
|
|
724
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
|
+
}
|
|
725
866
|
for (const entry of replies.values()) {
|
|
726
867
|
if (hidden.has(entry.seq)) replies.delete(entry.seq)
|
|
727
868
|
}
|
|
@@ -742,6 +883,11 @@ window.__ModuleLoader__.load({
|
|
|
742
883
|
revisions,
|
|
743
884
|
editable,
|
|
744
885
|
replies,
|
|
886
|
+
patches,
|
|
887
|
+
patchBySeq,
|
|
888
|
+
blocks: [],
|
|
889
|
+
blockIndex: null,
|
|
890
|
+
blockKind: null,
|
|
745
891
|
parts: [],
|
|
746
892
|
failure: null,
|
|
747
893
|
})
|
|
@@ -775,6 +921,11 @@ window.__ModuleLoader__.load({
|
|
|
775
921
|
// the only place the user can still be told (see applyEdit's
|
|
776
922
|
// `dropped`).
|
|
777
923
|
if (data.kind !== 'reply' && data.applied === true && data.dropped === true) this.notify('attachments-dropped')
|
|
924
|
+
// The correction landed, but replaying the turns behind it did not:
|
|
925
|
+
// the editor is already gone, so this is the only place left to say
|
|
926
|
+
// that the tail is missing from the context instead of silently
|
|
927
|
+
// hiding rows the user still expects.
|
|
928
|
+
if (data.applied === true && data.replay && data.replay.error) this.notify('replay-partial')
|
|
778
929
|
}
|
|
779
930
|
// Save first, re-run second - always: the sibling re-runs the turn
|
|
780
931
|
// from the prompt the surface NOW shows, so the revised wording has
|
|
@@ -1958,6 +2109,8 @@ window.__ModuleLoader__.load({
|
|
|
1958
2109
|
// saving would write, so both have to redraw the strip - and the chip
|
|
1959
2110
|
// keys are enough to tell one list from another.
|
|
1960
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),
|
|
1961
2114
|
].join('|')
|
|
1962
2115
|
}
|
|
1963
2116
|
|
|
@@ -1966,9 +2119,10 @@ window.__ModuleLoader__.load({
|
|
|
1966
2119
|
// from drifting apart in wording.
|
|
1967
2120
|
function editorTexts(t, target) {
|
|
1968
2121
|
const reply = target.mode === 'reply'
|
|
2122
|
+
const block = reply && target.patch === true
|
|
1969
2123
|
return {
|
|
1970
2124
|
title: t(reply ? 'editor.title-reply' : 'editor.title'),
|
|
1971
|
-
placeholder: t(reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
|
|
2125
|
+
placeholder: t(block ? 'editor.placeholder-block' : reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
|
|
1972
2126
|
warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
|
|
1973
2127
|
// Saving writes the revision into the context; only the opt-in config asks
|
|
1974
2128
|
// the model again, and the button says which one will happen.
|
|
@@ -2101,6 +2255,34 @@ window.__ModuleLoader__.load({
|
|
|
2101
2255
|
box.dataset[EDITOR_ATTR] = '1'
|
|
2102
2256
|
box.dataset.dshetFor = marker
|
|
2103
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
|
+
|
|
2104
2286
|
const area = document.createElement('textarea')
|
|
2105
2287
|
area.rows = 1
|
|
2106
2288
|
area.value = controller.editorDraft
|
|
@@ -2160,12 +2342,12 @@ window.__ModuleLoader__.load({
|
|
|
2160
2342
|
if (view.confirming) {
|
|
2161
2343
|
const note = document.createElement('p')
|
|
2162
2344
|
note.className = 'dshet-note'
|
|
2163
|
-
note.textContent = t('confirm.body')
|
|
2345
|
+
note.textContent = t(target.mode === 'reply' && target.patch === true ? 'confirm.body-block' : 'confirm.body')
|
|
2164
2346
|
box.appendChild(note)
|
|
2165
2347
|
}
|
|
2166
2348
|
// Shown when saving really would drop something: a reply's tool calls
|
|
2167
2349
|
// always, and a prompt's blocks when this host cannot carry them.
|
|
2168
|
-
if (target.attachments > 0 && !chips) {
|
|
2350
|
+
if (target.attachments > 0 && !chips && !(target.mode === 'reply' && target.patch === true)) {
|
|
2169
2351
|
const warn = document.createElement('p')
|
|
2170
2352
|
warn.className = 'dshet-warn'
|
|
2171
2353
|
warn.textContent = texts.warn
|
|
@@ -2257,6 +2439,211 @@ window.__ModuleLoader__.load({
|
|
|
2257
2439
|
controller.load(true)
|
|
2258
2440
|
}
|
|
2259
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
|
+
|
|
2260
2647
|
function applyDom(snapshot, view, controller, t) {
|
|
2261
2648
|
if (!snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') return
|
|
2262
2649
|
const animate = controller.consumeAnimate()
|
|
@@ -2322,6 +2709,13 @@ window.__ModuleLoader__.load({
|
|
|
2322
2709
|
controller.editorBox = null
|
|
2323
2710
|
controller.editorRow = null
|
|
2324
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
|
+
}
|
|
2325
2719
|
// Whatever of ours no row of this pass claimed is a ghost of an earlier
|
|
2326
2720
|
// apply: the host rebuilt the row under it, or the bundle was reloaded
|
|
2327
2721
|
// over it. Contract §5 - a button nobody can update must not pile up.
|
|
@@ -2391,10 +2785,11 @@ window.__ModuleLoader__.load({
|
|
|
2391
2785
|
messageId: entry.messageId,
|
|
2392
2786
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
2393
2787
|
attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
|
|
2394
|
-
//
|
|
2395
|
-
//
|
|
2396
|
-
//
|
|
2397
|
-
|
|
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: [] }),
|
|
2398
2793
|
})
|
|
2399
2794
|
}
|
|
2400
2795
|
// The config object is always an object: this runtime's `jsx` reads
|