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