dsh-edit-turn 0.2.18 → 0.2.20
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 +81 -23
- package/lib/client.js +380 -14
- package/lib/index.js +631 -24
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -28,13 +28,21 @@ window.__ModuleLoader__.load({
|
|
|
28
28
|
|
|
29
29
|
const NS = 'dsh-edit-turn'
|
|
30
30
|
const ROUTE_PREFIX = '/dsh-edit-turn'
|
|
31
|
+
// One picked file may not exceed this. It mirrors the platform's own default
|
|
32
|
+
// per-image ceiling (dsh-attachment-local's 20 MiB), because a payload above
|
|
33
|
+
// it would be refused by the store anyway - saying so in the picker is
|
|
34
|
+
// kinder than a round trip that fails.
|
|
35
|
+
const MAX_ATTACH_BYTES = 20 * 1024 * 1024
|
|
36
|
+
// Above this a chip keeps its label instead of trying to draw itself: a
|
|
37
|
+
// thumbnail is not worth decoding a 30 MB attachment for.
|
|
38
|
+
const MAX_THUMB_BYTES = 8 * 1024 * 1024
|
|
31
39
|
// The sibling that owns re-running: it shadows a turn and regenerates it
|
|
32
40
|
// from the prompt the surface now shows, then replays what followed. The
|
|
33
41
|
// editor's re-run button is only offered when it is mounted.
|
|
34
42
|
const RERUN_PREFIX = '/dsh-rerun-turn'
|
|
35
43
|
|
|
36
44
|
/** Keep in sync with package.json and lib/index.js. */
|
|
37
|
-
const PLUGIN_VERSION = '0.2.
|
|
45
|
+
const PLUGIN_VERSION = '0.2.20'
|
|
38
46
|
|
|
39
47
|
// --- copy -----------------------------------------------------------------
|
|
40
48
|
|
|
@@ -61,6 +69,8 @@ window.__ModuleLoader__.load({
|
|
|
61
69
|
'editor.pending': '正在保存…',
|
|
62
70
|
'editor.warn.attachments': '这条消息包含图片或文件附件,改写会丢弃它们,只保留文字。',
|
|
63
71
|
'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
|
|
72
|
+
'editor.attach.add': '添加附件',
|
|
73
|
+
'editor.attach.remove': '移除这个附件',
|
|
64
74
|
'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
|
|
65
75
|
'error.invalid': '请求无效,请刷新后重试。',
|
|
66
76
|
'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
|
|
@@ -75,13 +85,17 @@ window.__ModuleLoader__.load({
|
|
|
75
85
|
'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
|
|
76
86
|
'error.reply': '已回退,但替换没有落地。请重试。',
|
|
77
87
|
'error.empty': '改写后的内容不能为空。',
|
|
78
|
-
'error.unchanged': '内容没有变化,未做任何修改。',
|
|
79
88
|
'error.rerun-nothing': '已保存,但这一轮没有可重跑的作答。',
|
|
80
89
|
'error.rerun-unavailable': '已保存,但重跑没有开始,请稍后重试。',
|
|
81
90
|
'error.not-rerunnable': '这一轮没有可重跑的作答。',
|
|
82
91
|
'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
|
|
83
92
|
'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
|
|
84
93
|
'error.internal': '服务器内部错误,请稍后重试。',
|
|
94
|
+
'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
|
|
95
|
+
'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
|
|
96
|
+
'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
|
|
97
|
+
'error.attachment-too-large': '这个文件太大,无法作为附件。',
|
|
98
|
+
'error.too-large': '这次修改太大,服务器没有接收。',
|
|
85
99
|
}
|
|
86
100
|
|
|
87
101
|
const en = {
|
|
@@ -102,6 +116,8 @@ window.__ModuleLoader__.load({
|
|
|
102
116
|
'editor.pending': 'Working...',
|
|
103
117
|
'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
|
|
104
118
|
'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
|
|
119
|
+
'editor.attach.add': 'Add an attachment',
|
|
120
|
+
'editor.attach.remove': 'Remove this attachment',
|
|
105
121
|
'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
|
|
106
122
|
'error.invalid': 'Invalid request; refresh and try again.',
|
|
107
123
|
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
@@ -116,13 +132,17 @@ window.__ModuleLoader__.load({
|
|
|
116
132
|
'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
|
|
117
133
|
'error.reply': 'Rolled back, but the replacement did not land. Try again.',
|
|
118
134
|
'error.empty': 'The revised message cannot be empty.',
|
|
119
|
-
'error.unchanged': 'Nothing changed, so no edit was made.',
|
|
120
135
|
'error.rerun-nothing': 'Saved, but this turn has no reply that can be re-run.',
|
|
121
136
|
'error.rerun-unavailable': 'Saved, but the re-run did not start; try again.',
|
|
122
137
|
'error.not-rerunnable': 'This turn has no reply that can be re-run.',
|
|
123
138
|
'error.already-retired': 'This turn is no longer part of the current context.',
|
|
124
139
|
'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
|
|
125
140
|
'error.internal': 'The host hit an internal error; try again shortly.',
|
|
141
|
+
'error.attachment-refused': 'The attachment could not be stored, so nothing was changed.',
|
|
142
|
+
'error.attachments-dropped': 'Saved, but the images or files on this message could not be kept: this deployment mounts no attachment store.',
|
|
143
|
+
'error.attachment-unreadable': 'That attachment could not be read; try another file.',
|
|
144
|
+
'error.attachment-too-large': 'That file is too large to attach.',
|
|
145
|
+
'error.too-large': 'That revision was too large for the host to accept.',
|
|
126
146
|
}
|
|
127
147
|
|
|
128
148
|
// Every error code the host can return travels through here. A code is
|
|
@@ -231,6 +251,25 @@ window.__ModuleLoader__.load({
|
|
|
231
251
|
'.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}',
|
|
232
252
|
'.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
|
|
233
253
|
'.dshet-editor textarea:disabled{opacity:.65}',
|
|
254
|
+
// The blocks this message carries: one chip each, drawn from the same
|
|
255
|
+
// tokens as the panel so a skin cannot wash them out. A chip only takes
|
|
256
|
+
// the width it needs; the label is the only part that may shrink, and the
|
|
257
|
+
// remove button never does.
|
|
258
|
+
'.dshet-chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 14px 0}',
|
|
259
|
+
'.dshet-chip{display:inline-flex;align-items:center;gap:6px;max-width:100%;padding:3px 4px 3px 6px;border:1px solid var(--dshet-line);border-radius:10px;background:var(--dshet-chip);color:var(--dshet-ink);font-size:12px;line-height:18px}',
|
|
260
|
+
'.dshet-thumb{width:26px;height:26px;flex:none;border-radius:6px;object-fit:cover;background:var(--dshet-field)}',
|
|
261
|
+
'.dshet-chip-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
262
|
+
'.dshet-chip-size{flex:none;color:var(--dshet-ink-dim)}',
|
|
263
|
+
'.dshet-chip-remove{flex:none;width:18px;height:18px;padding:0;border:0;border-radius:999px;background:transparent;color:var(--dshet-ink-dim);font:inherit;font-size:13px;line-height:16px;cursor:pointer}',
|
|
264
|
+
'.dshet-chip-remove:hover:not(:disabled){background:var(--dshet-hover);color:var(--dsw-alias-state-error-primary,#c93a31)}',
|
|
265
|
+
'.dshet-chip-remove:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:1px}',
|
|
266
|
+
'.dshet-chip-remove:disabled{cursor:default;opacity:.5}',
|
|
267
|
+
'.dshet-attach-row{display:flex;align-items:center;gap:8px;margin:8px 14px 0}',
|
|
268
|
+
'.dshet-attach{padding:3px 10px;border:1px dashed var(--dshet-line);border-radius:999px;background:transparent;color:var(--dshet-ink-dim);font:inherit;font-size:12px;line-height:18px;cursor:pointer;transition:background-color .12s,color .12s}',
|
|
269
|
+
'.dshet-attach:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink)}',
|
|
270
|
+
'.dshet-attach:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
|
|
271
|
+
'.dshet-attach:disabled{cursor:default;opacity:.5}',
|
|
272
|
+
'.dshet-attach-input{display:none}',
|
|
234
273
|
'.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
|
|
235
274
|
'.dshet-note{color:var(--dshet-ink-dim)}',
|
|
236
275
|
'.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
|
|
@@ -305,6 +344,16 @@ window.__ModuleLoader__.load({
|
|
|
305
344
|
this.editorRow = null
|
|
306
345
|
this.editorFocus = false
|
|
307
346
|
this.editorDraft = ''
|
|
347
|
+
// The block list being edited: what the carrier will be rebuilt from.
|
|
348
|
+
// Every entry is either `{ keep: <index> }` - a block of this message the
|
|
349
|
+
// user left alone - or `{ add: { data, mediaType?, name } }` for
|
|
350
|
+
// something they just picked, with the display fields the chip needs.
|
|
351
|
+
// The list is the ONLY thing that decides what the host writes, which is
|
|
352
|
+
// why a chip the user removes simply leaves it.
|
|
353
|
+
this.editorParts = []
|
|
354
|
+
// Names the added chips, so removing one is a matter of identity rather
|
|
355
|
+
// than of position (a DOM pass may run between two of them).
|
|
356
|
+
this.uploadSeq = 0
|
|
308
357
|
this.clearTimer = null
|
|
309
358
|
this.pendingRerun = false
|
|
310
359
|
this.siblingRerun = 'unknown'
|
|
@@ -321,6 +370,7 @@ window.__ModuleLoader__.load({
|
|
|
321
370
|
repliesByMessage: new Map(),
|
|
322
371
|
editing: null,
|
|
323
372
|
draft: '',
|
|
373
|
+
parts: [],
|
|
324
374
|
confirming: false,
|
|
325
375
|
pending: false,
|
|
326
376
|
failure: null,
|
|
@@ -328,6 +378,12 @@ window.__ModuleLoader__.load({
|
|
|
328
378
|
surfaceReady: false,
|
|
329
379
|
loaded: false,
|
|
330
380
|
loadError: false,
|
|
381
|
+
// What the host said it can do with a block list (see /state's
|
|
382
|
+
// `capabilities`). False is the safe reading: an older host, a failed
|
|
383
|
+
// load and a deployment without an attachment store all mean "text
|
|
384
|
+
// only", which is what the editor then offers - with the warning that
|
|
385
|
+
// says so - instead of a chip it cannot honour.
|
|
386
|
+
attachmentsOk: false,
|
|
331
387
|
// Matches the host's default: one click applies. Only an explicit
|
|
332
388
|
// `confirm: true` in the profile turns the second step back on.
|
|
333
389
|
confirmStep: false,
|
|
@@ -412,6 +468,10 @@ window.__ModuleLoader__.load({
|
|
|
412
468
|
loaded: true,
|
|
413
469
|
loadError: false,
|
|
414
470
|
confirmStep: !(data.config && data.config.confirm === false),
|
|
471
|
+
// What this host can do with blocks, taken from its own answer.
|
|
472
|
+
// An older host says nothing here, which reads as "text only" -
|
|
473
|
+
// the behaviour every release before blocks had.
|
|
474
|
+
attachmentsOk: Boolean(data.capabilities && data.capabilities.attachments === true),
|
|
415
475
|
})
|
|
416
476
|
})
|
|
417
477
|
.catch(() => {
|
|
@@ -432,7 +492,10 @@ window.__ModuleLoader__.load({
|
|
|
432
492
|
const previous = this.editorElement
|
|
433
493
|
this.load()
|
|
434
494
|
this.editor = target
|
|
435
|
-
if (!same)
|
|
495
|
+
if (!same) {
|
|
496
|
+
this.editorDraft = target.text
|
|
497
|
+
this.editorParts = blocksToParts(target, this.sessionId)
|
|
498
|
+
}
|
|
436
499
|
this.confirming = false
|
|
437
500
|
this.editorElement = null
|
|
438
501
|
// The intent to keep the user's caret in this box. It is set when the
|
|
@@ -448,7 +511,7 @@ window.__ModuleLoader__.load({
|
|
|
448
511
|
/* focus is best-effort */
|
|
449
512
|
}
|
|
450
513
|
}
|
|
451
|
-
this.publish({ editing: target, draft: this.editorDraft, confirming: false, failure: null })
|
|
514
|
+
this.publish({ editing: target, draft: this.editorDraft, parts: this.editorParts.slice(), confirming: false, failure: null })
|
|
452
515
|
this.probeSiblingRerun()
|
|
453
516
|
}
|
|
454
517
|
|
|
@@ -485,15 +548,81 @@ window.__ModuleLoader__.load({
|
|
|
485
548
|
this.editorFocus = false
|
|
486
549
|
this.editor = null
|
|
487
550
|
this.editorDraft = ''
|
|
551
|
+
this.editorParts = []
|
|
488
552
|
this.confirming = false
|
|
489
553
|
this.editorElement = null
|
|
490
|
-
this.publish({ editing: null, draft: '', confirming: false, failure: null })
|
|
554
|
+
this.publish({ editing: null, draft: '', parts: [], confirming: false, failure: null })
|
|
491
555
|
}
|
|
492
556
|
|
|
493
557
|
setDraft(value) {
|
|
494
558
|
this.editorDraft = value
|
|
495
559
|
}
|
|
496
560
|
|
|
561
|
+
/**
|
|
562
|
+
* One chip the user removed leaves the block list, and nothing else.
|
|
563
|
+
*
|
|
564
|
+
* The list IS the edit: a block that is no longer in it is not written
|
|
565
|
+
* when the carrier is rebuilt, so removing a chip is the whole
|
|
566
|
+
* implementation of "this attachment should not be here any more".
|
|
567
|
+
*/
|
|
568
|
+
removePart(key) {
|
|
569
|
+
const next = this.editorParts.filter((part) => part.key !== key)
|
|
570
|
+
if (next.length === this.editorParts.length) return
|
|
571
|
+
this.editorParts = next
|
|
572
|
+
this.publish({ parts: next.slice() })
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** One payload the user picked joins the block list, at the end. */
|
|
576
|
+
addUpload(upload) {
|
|
577
|
+
this.editorParts = [...this.editorParts, upload]
|
|
578
|
+
this.publish({ parts: this.editorParts.slice() })
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Read picked files and append one chip each.
|
|
583
|
+
*
|
|
584
|
+
* The bytes stay in the browser until the save: the host admits them
|
|
585
|
+
* through the platform's attachment store only when the user commits, so
|
|
586
|
+
* opening the editor and cancelling stores nothing.
|
|
587
|
+
*/
|
|
588
|
+
attachFiles(files) {
|
|
589
|
+
let list = []
|
|
590
|
+
try {
|
|
591
|
+
list = files === null || files === undefined ? [] : Array.prototype.slice.call(files)
|
|
592
|
+
} catch {
|
|
593
|
+
list = []
|
|
594
|
+
}
|
|
595
|
+
for (const file of list) {
|
|
596
|
+
if (!file || typeof file !== 'object') continue
|
|
597
|
+
if (typeof file.size === 'number' && file.size > MAX_ATTACH_BYTES) {
|
|
598
|
+
this.notify('attachment-too-large')
|
|
599
|
+
continue
|
|
600
|
+
}
|
|
601
|
+
const name = typeof file.name === 'string' && file.name !== '' ? file.name : null
|
|
602
|
+
const mediaType = typeof file.type === 'string' && file.type !== '' ? file.type : null
|
|
603
|
+
readAsBase64(file, (data) => {
|
|
604
|
+
if (data === null) {
|
|
605
|
+
this.notify('attachment-unreadable')
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
this.uploadSeq += 1
|
|
609
|
+
this.addUpload({
|
|
610
|
+
key: `a${this.uploadSeq}`,
|
|
611
|
+
add: {
|
|
612
|
+
data,
|
|
613
|
+
...(mediaType === null ? {} : { mediaType }),
|
|
614
|
+
...(name === null ? {} : { name }),
|
|
615
|
+
},
|
|
616
|
+
// The chip's own fields. A picked image can show itself through
|
|
617
|
+
// the very bytes it is carrying - no round trip, no store.
|
|
618
|
+
label: name !== null ? name : mediaType !== null ? mediaType : 'attachment',
|
|
619
|
+
bytes: typeof file.size === 'number' ? file.size : null,
|
|
620
|
+
thumbnail: `data:${mediaType === null ? 'application/octet-stream' : mediaType};base64,${data}`,
|
|
621
|
+
})
|
|
622
|
+
})
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
497
626
|
review() {
|
|
498
627
|
this.pendingRerun = false
|
|
499
628
|
this.runReview()
|
|
@@ -534,6 +663,13 @@ window.__ModuleLoader__.load({
|
|
|
534
663
|
this.publish({ failure: 'empty' })
|
|
535
664
|
return
|
|
536
665
|
}
|
|
666
|
+
// What the host will rebuild the carrier from: the blocks still on the
|
|
667
|
+
// list, in order, plus anything picked. Sent only when the host answered
|
|
668
|
+
// that it can carry blocks - a request that names blocks this host would
|
|
669
|
+
// have to drop is a request that lies about what the save did.
|
|
670
|
+
const parts = target.mode === 'prompt' && this.view.attachmentsOk === true
|
|
671
|
+
? this.editorParts.map((part) => (part.keep !== undefined ? { keep: part.keep } : { add: part.add }))
|
|
672
|
+
: undefined
|
|
537
673
|
this.publish({ pending: true, failure: null })
|
|
538
674
|
try {
|
|
539
675
|
const res = await fetch(`${ROUTE_PREFIX}/apply`, {
|
|
@@ -544,6 +680,7 @@ window.__ModuleLoader__.load({
|
|
|
544
680
|
seq: target.seq,
|
|
545
681
|
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
546
682
|
text,
|
|
683
|
+
...(parts === undefined ? {} : { parts }),
|
|
547
684
|
}),
|
|
548
685
|
})
|
|
549
686
|
const data = await res.json().catch(() => ({}))
|
|
@@ -574,6 +711,11 @@ window.__ModuleLoader__.load({
|
|
|
574
711
|
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
575
712
|
text,
|
|
576
713
|
attachments: typeof target.attachments === 'number' ? target.attachments : 0,
|
|
714
|
+
// The host answers with what the carrier now carries, described
|
|
715
|
+
// the way /state describes it. Adopted for the row drawn before
|
|
716
|
+
// the refresh lands: without it a second save could not know
|
|
717
|
+
// which blocks are still there and would rewrite text-only.
|
|
718
|
+
blocks: Array.isArray(data.blocks) ? data.blocks : [],
|
|
577
719
|
})
|
|
578
720
|
}
|
|
579
721
|
// A reply edit shadows its own reply too, so it leaves the editable
|
|
@@ -586,6 +728,7 @@ window.__ModuleLoader__.load({
|
|
|
586
728
|
this.animateOnce = true
|
|
587
729
|
this.editor = null
|
|
588
730
|
this.editorDraft = ''
|
|
731
|
+
this.editorParts = []
|
|
589
732
|
this.editorElement = null
|
|
590
733
|
this.editorBox = null
|
|
591
734
|
this.editorRow = null
|
|
@@ -599,13 +742,22 @@ window.__ModuleLoader__.load({
|
|
|
599
742
|
revisions,
|
|
600
743
|
editable,
|
|
601
744
|
replies,
|
|
745
|
+
parts: [],
|
|
602
746
|
failure: null,
|
|
603
747
|
})
|
|
604
748
|
// `unchanged` means the host wrote NOTHING: the draft is (verbatim)
|
|
605
|
-
// the text the message already carries.
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
749
|
+
// the text the message already carries. The flag is read here for one
|
|
750
|
+
// reason, and it is not the user's: a no-op is not a failure. The
|
|
751
|
+
// `applied === false` wording below would claim the rollback landed
|
|
752
|
+
// and the replacement did not, which is a lie about an edit that was
|
|
753
|
+
// never attempted.
|
|
754
|
+
//
|
|
755
|
+
// Nothing is announced for it, deliberately (0.2.20 removed the
|
|
756
|
+
// notice this used to draw): the edit was simply not an edit, so the
|
|
757
|
+
// editor closes like any save and the screen is exactly as it was -
|
|
758
|
+
// no row was shadowed, so none is hidden, and the message keeps the
|
|
759
|
+
// pencil it had. (The banner also landed on top of the composer's
|
|
760
|
+
// placeholder text, which is not a place to report anything from.)
|
|
609
761
|
//
|
|
610
762
|
// The re-run intent survives it. "Re-run" saves first only because the
|
|
611
763
|
// sibling regenerates the turn from the prompt the surface shows; a
|
|
@@ -613,13 +765,16 @@ window.__ModuleLoader__.load({
|
|
|
613
765
|
// wording moved, so the chain below is NOT gated on a write having
|
|
614
766
|
// happened. (The decision itself is the host's - the text is parsed
|
|
615
767
|
// there, once - and this half only reads the flag it sends back.)
|
|
616
|
-
if (data.unchanged
|
|
617
|
-
if (!rerunIntent) this.notify('unchanged')
|
|
618
|
-
} else {
|
|
768
|
+
if (data.unchanged !== true) {
|
|
619
769
|
// The rollback has landed, so the editor is gone with its row: a
|
|
620
770
|
// failed second half has to be reported somewhere that survives that.
|
|
621
771
|
if (data.kind === 'reply' && data.applied === false) this.notify('reply')
|
|
622
772
|
if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
|
|
773
|
+
// The save landed, but it left non-text blocks behind (the host says
|
|
774
|
+
// so): the editor that could have warned is gone by now, so this is
|
|
775
|
+
// the only place the user can still be told (see applyEdit's
|
|
776
|
+
// `dropped`).
|
|
777
|
+
if (data.kind !== 'reply' && data.applied === true && data.dropped === true) this.notify('attachments-dropped')
|
|
623
778
|
}
|
|
624
779
|
// Save first, re-run second - always: the sibling re-runs the turn
|
|
625
780
|
// from the prompt the surface NOW shows, so the revised wording has
|
|
@@ -727,6 +882,10 @@ window.__ModuleLoader__.load({
|
|
|
727
882
|
messageId: entry.messageId,
|
|
728
883
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
729
884
|
attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
|
|
885
|
+
// The blocks this message carries, as /state described them. They seed
|
|
886
|
+
// the editor's chips; a host that does not describe them (or an
|
|
887
|
+
// optimistic entry that predates its refresh) simply has none.
|
|
888
|
+
blocks: Array.isArray(entry.blocks) ? entry.blocks : [],
|
|
730
889
|
}
|
|
731
890
|
}
|
|
732
891
|
return null
|
|
@@ -1688,6 +1847,94 @@ window.__ModuleLoader__.load({
|
|
|
1688
1847
|
// injecting a second controlled tree into it would fight its reconciler.
|
|
1689
1848
|
// The draft is mirrored into the controller on every input event, so a
|
|
1690
1849
|
// host re-render that wipes the textarea cannot lose what was typed.
|
|
1850
|
+
// --- block chips ----------------------------------------------------------
|
|
1851
|
+
//
|
|
1852
|
+
// A message's non-text blocks, as the editor shows them: one chip per block,
|
|
1853
|
+
// each with what the block itself reported about itself (a name, a size, and
|
|
1854
|
+
// whether its bytes are reachable) and a remove button. Nothing here reads a
|
|
1855
|
+
// block TYPE: a chip is drawn from the fields a block carries, so a block
|
|
1856
|
+
// kind the platform adds later shows up with its own name and needs no
|
|
1857
|
+
// change in this file.
|
|
1858
|
+
|
|
1859
|
+
/** One chip's display fields, taken from whatever the block said about itself. */
|
|
1860
|
+
function chipOf(block) {
|
|
1861
|
+
const name = typeof block.name === 'string' && block.name !== '' ? block.name : null
|
|
1862
|
+
const mediaType = typeof block.mediaType === 'string' && block.mediaType !== '' ? block.mediaType : null
|
|
1863
|
+
const type = typeof block.type === 'string' && block.type !== '' ? block.type : 'block'
|
|
1864
|
+
const bytes = typeof block.bytes === 'number' ? block.bytes : null
|
|
1865
|
+
return { label: name !== null ? name : mediaType !== null ? mediaType : type, bytes }
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* The block list an editor starts from: every block of the message, kept.
|
|
1870
|
+
*
|
|
1871
|
+
* A reply starts from none. What a reply carries beside its text - tool
|
|
1872
|
+
* calls, reasoning - cannot survive a replacement (their results no longer
|
|
1873
|
+
* hold), so offering a chip for them would promise something the edit cannot
|
|
1874
|
+
* keep; the editor warns about them instead.
|
|
1875
|
+
*/
|
|
1876
|
+
function blocksToParts(target, sessionId) {
|
|
1877
|
+
if (!target || target.mode !== 'prompt') return []
|
|
1878
|
+
const blocks = Array.isArray(target.blocks) ? target.blocks : []
|
|
1879
|
+
const parts = []
|
|
1880
|
+
for (const block of blocks) {
|
|
1881
|
+
if (!block || typeof block.index !== 'number') continue
|
|
1882
|
+
const chip = chipOf(block)
|
|
1883
|
+
parts.push({
|
|
1884
|
+
keep: block.index,
|
|
1885
|
+
key: `k${block.index}`,
|
|
1886
|
+
label: chip.label,
|
|
1887
|
+
bytes: chip.bytes,
|
|
1888
|
+
thumbnail: block.preview === true ? previewUrl(sessionId, target.seq, block.index) : null,
|
|
1889
|
+
})
|
|
1890
|
+
}
|
|
1891
|
+
return parts
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/** Where one stored block's bytes are read from, for its thumbnail. */
|
|
1895
|
+
function previewUrl(sessionId, seq, index) {
|
|
1896
|
+
return `${ROUTE_PREFIX}/attachment?sessionId=${encodeURIComponent(sessionId)}&seq=${seq}&index=${index}`
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/** A byte count a human reads at a glance. */
|
|
1900
|
+
function formatBytes(bytes) {
|
|
1901
|
+
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return ''
|
|
1902
|
+
if (bytes < 1024) return `${bytes} B`
|
|
1903
|
+
const units = ['KB', 'MB', 'GB']
|
|
1904
|
+
let value = bytes / 1024
|
|
1905
|
+
let unit = 0
|
|
1906
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
1907
|
+
value /= 1024
|
|
1908
|
+
unit += 1
|
|
1909
|
+
}
|
|
1910
|
+
return `${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10} ${units[unit]}`
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
/**
|
|
1914
|
+
* One picked file's bytes, base64, without the Data URL prefix.
|
|
1915
|
+
*
|
|
1916
|
+
* Read here rather than at save time so a save is one request: the payload
|
|
1917
|
+
* travels with the revision the user committed to.
|
|
1918
|
+
*/
|
|
1919
|
+
function readAsBase64(file, done) {
|
|
1920
|
+
if (typeof FileReader !== 'function') {
|
|
1921
|
+
done(null)
|
|
1922
|
+
return
|
|
1923
|
+
}
|
|
1924
|
+
try {
|
|
1925
|
+
const reader = new FileReader()
|
|
1926
|
+
reader.onload = () => {
|
|
1927
|
+
const result = typeof reader.result === 'string' ? reader.result : ''
|
|
1928
|
+
const comma = result.indexOf(',')
|
|
1929
|
+
done(comma === -1 ? null : result.slice(comma + 1))
|
|
1930
|
+
}
|
|
1931
|
+
reader.onerror = () => done(null)
|
|
1932
|
+
reader.readAsDataURL(file)
|
|
1933
|
+
} catch {
|
|
1934
|
+
done(null)
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1691
1938
|
// The re-render marker for the in-place editor.
|
|
1692
1939
|
//
|
|
1693
1940
|
// Every field `renderEditor` reads belongs here. A marker that only tracks
|
|
@@ -1704,6 +1951,13 @@ window.__ModuleLoader__.load({
|
|
|
1704
1951
|
// The re-run button appears when the sibling probe lands; without it in
|
|
1705
1952
|
// the marker the editor would never be rebuilt to show the button.
|
|
1706
1953
|
view.siblingRerun === 'present' ? 'r' : 'n',
|
|
1954
|
+
// Whether chips are drawn at all: a host that cannot admit blocks asks
|
|
1955
|
+
// for the warning instead, and the box has to change with that answer.
|
|
1956
|
+
view.attachmentsOk === true ? 'A' : 'a',
|
|
1957
|
+
// The block list itself. Removing a chip and adding one both change what
|
|
1958
|
+
// saving would write, so both have to redraw the strip - and the chip
|
|
1959
|
+
// keys are enough to tell one list from another.
|
|
1960
|
+
view.parts.map((part) => part.key).join('.'),
|
|
1707
1961
|
].join('|')
|
|
1708
1962
|
}
|
|
1709
1963
|
|
|
@@ -1722,6 +1976,100 @@ window.__ModuleLoader__.load({
|
|
|
1722
1976
|
}
|
|
1723
1977
|
}
|
|
1724
1978
|
|
|
1979
|
+
/**
|
|
1980
|
+
* The chips: one per block on the block list.
|
|
1981
|
+
*
|
|
1982
|
+
* A chip IS the block's presence in the edit. Removing it takes the block
|
|
1983
|
+
* off the list, and a block that is not on the list is not written when the
|
|
1984
|
+
* carrier is rebuilt - the whole mechanism, and the same one for a picture,
|
|
1985
|
+
* a file and whatever the platform carries next. A chip draws itself from
|
|
1986
|
+
* the bytes the block already has, and gives up on its thumbnail when the
|
|
1987
|
+
* browser says they are not a picture: no type is consulted either way.
|
|
1988
|
+
*/
|
|
1989
|
+
function editorChips(controller, view, t) {
|
|
1990
|
+
const strip = document.createElement('div')
|
|
1991
|
+
strip.className = 'dshet-chips'
|
|
1992
|
+
for (const part of view.parts) {
|
|
1993
|
+
const chip = document.createElement('div')
|
|
1994
|
+
chip.className = 'dshet-chip'
|
|
1995
|
+
chip.dataset.dshetChip = part.key
|
|
1996
|
+
const drawable = typeof part.thumbnail === 'string' && part.thumbnail !== '' &&
|
|
1997
|
+
(typeof part.bytes !== 'number' || part.bytes <= MAX_THUMB_BYTES)
|
|
1998
|
+
if (drawable) {
|
|
1999
|
+
const image = document.createElement('img')
|
|
2000
|
+
image.className = 'dshet-thumb'
|
|
2001
|
+
image.alt = ''
|
|
2002
|
+
image.src = part.thumbnail
|
|
2003
|
+
image.addEventListener('error', () => {
|
|
2004
|
+
const parent = image.parentElement
|
|
2005
|
+
if (typeof image.remove === 'function') image.remove()
|
|
2006
|
+
else if (parent !== null && parent !== undefined && typeof parent.removeChild === 'function') parent.removeChild(image)
|
|
2007
|
+
})
|
|
2008
|
+
chip.appendChild(image)
|
|
2009
|
+
}
|
|
2010
|
+
const label = document.createElement('span')
|
|
2011
|
+
label.className = 'dshet-chip-label'
|
|
2012
|
+
label.textContent = part.label
|
|
2013
|
+
chip.appendChild(label)
|
|
2014
|
+
const size = formatBytes(part.bytes)
|
|
2015
|
+
if (size !== '') {
|
|
2016
|
+
const measured = document.createElement('span')
|
|
2017
|
+
measured.className = 'dshet-chip-size'
|
|
2018
|
+
measured.textContent = size
|
|
2019
|
+
chip.appendChild(measured)
|
|
2020
|
+
}
|
|
2021
|
+
const remove = document.createElement('button')
|
|
2022
|
+
remove.type = 'button'
|
|
2023
|
+
remove.className = 'dshet-chip-remove'
|
|
2024
|
+
remove.textContent = '×'
|
|
2025
|
+
remove.title = t('editor.attach.remove')
|
|
2026
|
+
remove.setAttribute('aria-label', t('editor.attach.remove'))
|
|
2027
|
+
remove.disabled = view.pending
|
|
2028
|
+
pressable(remove, () => controller.removePart(part.key))
|
|
2029
|
+
chip.appendChild(remove)
|
|
2030
|
+
strip.appendChild(chip)
|
|
2031
|
+
}
|
|
2032
|
+
return strip
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
/**
|
|
2036
|
+
* The add/replace entry: a picker that puts a block on the list.
|
|
2037
|
+
*
|
|
2038
|
+
* Drawn only when the host answered that it can admit a block. A deployment
|
|
2039
|
+
* with no attachment store gets no picker, because a picker that cannot be
|
|
2040
|
+
* honoured is worse than none - the warning below says what a save will
|
|
2041
|
+
* drop instead.
|
|
2042
|
+
*/
|
|
2043
|
+
function attachEntry(controller, view, t) {
|
|
2044
|
+
const row = document.createElement('div')
|
|
2045
|
+
row.className = 'dshet-attach-row'
|
|
2046
|
+
const picker = document.createElement('input')
|
|
2047
|
+
picker.type = 'file'
|
|
2048
|
+
picker.multiple = true
|
|
2049
|
+
picker.className = 'dshet-attach-input'
|
|
2050
|
+
picker.style.display = 'none'
|
|
2051
|
+
picker.addEventListener('change', () => {
|
|
2052
|
+
controller.attachFiles(picker.files)
|
|
2053
|
+
// Cleared so the same file can be picked again after a removal.
|
|
2054
|
+
try {
|
|
2055
|
+
picker.value = ''
|
|
2056
|
+
} catch {
|
|
2057
|
+
/* not every input lets this be written */
|
|
2058
|
+
}
|
|
2059
|
+
})
|
|
2060
|
+
const add = document.createElement('button')
|
|
2061
|
+
add.type = 'button'
|
|
2062
|
+
add.className = 'dshet-attach'
|
|
2063
|
+
add.textContent = t('editor.attach.add')
|
|
2064
|
+
add.disabled = view.pending
|
|
2065
|
+
pressable(add, () => {
|
|
2066
|
+
if (typeof picker.click === 'function') picker.click()
|
|
2067
|
+
})
|
|
2068
|
+
row.appendChild(picker)
|
|
2069
|
+
row.appendChild(add)
|
|
2070
|
+
return row
|
|
2071
|
+
}
|
|
2072
|
+
|
|
1725
2073
|
function renderEditor(row, controller, view, t) {
|
|
1726
2074
|
const target = view.editing
|
|
1727
2075
|
const texts = editorTexts(t, target)
|
|
@@ -1794,6 +2142,18 @@ window.__ModuleLoader__.load({
|
|
|
1794
2142
|
})
|
|
1795
2143
|
box.appendChild(area)
|
|
1796
2144
|
|
|
2145
|
+
// The blocks this message carries, each one a chip the user can remove,
|
|
2146
|
+
// and the entry that adds another. Drawn only when the host answered that
|
|
2147
|
+
// it can admit what a chip promises - otherwise the warning below states
|
|
2148
|
+
// what a save would drop, which is exactly what this editor did before
|
|
2149
|
+
// blocks travelled at all. A reply never gets chips: what it carries
|
|
2150
|
+
// beside its text cannot survive a replacement.
|
|
2151
|
+
const chips = target.mode === 'prompt' && view.attachmentsOk === true
|
|
2152
|
+
if (chips) {
|
|
2153
|
+
if (view.parts.length > 0) box.appendChild(editorChips(controller, view, t))
|
|
2154
|
+
box.appendChild(attachEntry(controller, view, t))
|
|
2155
|
+
}
|
|
2156
|
+
|
|
1797
2157
|
// Only what the user has to know right now: what the replacement will
|
|
1798
2158
|
// discard, and what went wrong. The running explanation lives in the
|
|
1799
2159
|
// README and in the buttons' tooltips, the way a normal input box does it.
|
|
@@ -1803,7 +2163,9 @@ window.__ModuleLoader__.load({
|
|
|
1803
2163
|
note.textContent = t('confirm.body')
|
|
1804
2164
|
box.appendChild(note)
|
|
1805
2165
|
}
|
|
1806
|
-
|
|
2166
|
+
// Shown when saving really would drop something: a reply's tool calls
|
|
2167
|
+
// always, and a prompt's blocks when this host cannot carry them.
|
|
2168
|
+
if (target.attachments > 0 && !chips) {
|
|
1807
2169
|
const warn = document.createElement('p')
|
|
1808
2170
|
warn.className = 'dshet-warn'
|
|
1809
2171
|
warn.textContent = texts.warn
|
|
@@ -2029,6 +2391,10 @@ window.__ModuleLoader__.load({
|
|
|
2029
2391
|
messageId: entry.messageId,
|
|
2030
2392
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
2031
2393
|
attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
|
|
2394
|
+
// A reply edits text only on purpose: the tool calls and reasoning it
|
|
2395
|
+
// carries stop being true the moment it is replaced, so they are not
|
|
2396
|
+
// offered as chips (the editor warns about them instead).
|
|
2397
|
+
blocks: [],
|
|
2032
2398
|
})
|
|
2033
2399
|
}
|
|
2034
2400
|
// The config object is always an object: this runtime's `jsx` reads
|