dsh-as-aistudio 0.2.7 → 0.2.8
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/package.json +2 -2
- package/src/client.js +368 -6
- package/src/components.js +1 -1
- package/src/studio.js +1 -1
- package/src/vendor/dsh-edit-turn.js +368 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-as-aistudio",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Google-AI-Studio-style turn controls for DeepSeek Harness, in one install: edit a prompt and re-run it, re-run a reply in place, delete a turn from the model context, and render sent prompts as Markdown. Self-contained: the plugin list shows this one row; the four components are dependencies mounted by it. Each still installs alone and works alone.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"dsh-delete-turn": "^0.1.8",
|
|
56
|
-
"dsh-edit-turn": "^0.2.
|
|
56
|
+
"dsh-edit-turn": "^0.2.19",
|
|
57
57
|
"dsh-markdown-bubble": "^0.1.4",
|
|
58
58
|
"dsh-rerun-turn": "^0.1.25"
|
|
59
59
|
},
|
package/src/client.js
CHANGED
|
@@ -26,13 +26,21 @@ window.__ModuleLoader__.load({
|
|
|
26
26
|
|
|
27
27
|
const NS = 'dsh-edit-turn'
|
|
28
28
|
const ROUTE_PREFIX = '/dsh-edit-turn'
|
|
29
|
+
// One picked file may not exceed this. It mirrors the platform's own default
|
|
30
|
+
// per-image ceiling (dsh-attachment-local's 20 MiB), because a payload above
|
|
31
|
+
// it would be refused by the store anyway - saying so in the picker is
|
|
32
|
+
// kinder than a round trip that fails.
|
|
33
|
+
const MAX_ATTACH_BYTES = 20 * 1024 * 1024
|
|
34
|
+
// Above this a chip keeps its label instead of trying to draw itself: a
|
|
35
|
+
// thumbnail is not worth decoding a 30 MB attachment for.
|
|
36
|
+
const MAX_THUMB_BYTES = 8 * 1024 * 1024
|
|
29
37
|
// The sibling that owns re-running: it shadows a turn and regenerates it
|
|
30
38
|
// from the prompt the surface now shows, then replays what followed. The
|
|
31
39
|
// editor's re-run button is only offered when it is mounted.
|
|
32
40
|
const RERUN_PREFIX = '/dsh-rerun-turn'
|
|
33
41
|
|
|
34
42
|
/** Keep in sync with package.json and lib/index.js. */
|
|
35
|
-
const PLUGIN_VERSION = '0.2.
|
|
43
|
+
const PLUGIN_VERSION = '0.2.19'
|
|
36
44
|
|
|
37
45
|
// --- copy -----------------------------------------------------------------
|
|
38
46
|
|
|
@@ -59,6 +67,8 @@ window.__ModuleLoader__.load({
|
|
|
59
67
|
'editor.pending': '正在保存…',
|
|
60
68
|
'editor.warn.attachments': '这条消息包含图片或文件附件,改写会丢弃它们,只保留文字。',
|
|
61
69
|
'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
|
|
70
|
+
'editor.attach.add': '添加附件',
|
|
71
|
+
'editor.attach.remove': '移除这个附件',
|
|
62
72
|
'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
|
|
63
73
|
'error.invalid': '请求无效,请刷新后重试。',
|
|
64
74
|
'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
|
|
@@ -80,6 +90,11 @@ window.__ModuleLoader__.load({
|
|
|
80
90
|
'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
|
|
81
91
|
'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
|
|
82
92
|
'error.internal': '服务器内部错误,请稍后重试。',
|
|
93
|
+
'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
|
|
94
|
+
'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
|
|
95
|
+
'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
|
|
96
|
+
'error.attachment-too-large': '这个文件太大,无法作为附件。',
|
|
97
|
+
'error.too-large': '这次修改太大,服务器没有接收。',
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
const en = {
|
|
@@ -100,6 +115,8 @@ window.__ModuleLoader__.load({
|
|
|
100
115
|
'editor.pending': 'Working...',
|
|
101
116
|
'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
|
|
102
117
|
'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
|
|
118
|
+
'editor.attach.add': 'Add an attachment',
|
|
119
|
+
'editor.attach.remove': 'Remove this attachment',
|
|
103
120
|
'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
|
|
104
121
|
'error.invalid': 'Invalid request; refresh and try again.',
|
|
105
122
|
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
@@ -121,6 +138,11 @@ window.__ModuleLoader__.load({
|
|
|
121
138
|
'error.already-retired': 'This turn is no longer part of the current context.',
|
|
122
139
|
'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
|
|
123
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.',
|
|
124
146
|
}
|
|
125
147
|
|
|
126
148
|
// Every error code the host can return travels through here. A code is
|
|
@@ -229,6 +251,25 @@ window.__ModuleLoader__.load({
|
|
|
229
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}',
|
|
230
252
|
'.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
|
|
231
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}',
|
|
232
273
|
'.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
|
|
233
274
|
'.dshet-note{color:var(--dshet-ink-dim)}',
|
|
234
275
|
'.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
|
|
@@ -303,6 +344,16 @@ window.__ModuleLoader__.load({
|
|
|
303
344
|
this.editorRow = null
|
|
304
345
|
this.editorFocus = false
|
|
305
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
|
|
306
357
|
this.clearTimer = null
|
|
307
358
|
this.pendingRerun = false
|
|
308
359
|
this.siblingRerun = 'unknown'
|
|
@@ -319,6 +370,7 @@ window.__ModuleLoader__.load({
|
|
|
319
370
|
repliesByMessage: new Map(),
|
|
320
371
|
editing: null,
|
|
321
372
|
draft: '',
|
|
373
|
+
parts: [],
|
|
322
374
|
confirming: false,
|
|
323
375
|
pending: false,
|
|
324
376
|
failure: null,
|
|
@@ -326,6 +378,12 @@ window.__ModuleLoader__.load({
|
|
|
326
378
|
surfaceReady: false,
|
|
327
379
|
loaded: false,
|
|
328
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,
|
|
329
387
|
// Matches the host's default: one click applies. Only an explicit
|
|
330
388
|
// `confirm: true` in the profile turns the second step back on.
|
|
331
389
|
confirmStep: false,
|
|
@@ -410,6 +468,10 @@ window.__ModuleLoader__.load({
|
|
|
410
468
|
loaded: true,
|
|
411
469
|
loadError: false,
|
|
412
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),
|
|
413
475
|
})
|
|
414
476
|
})
|
|
415
477
|
.catch(() => {
|
|
@@ -430,7 +492,10 @@ window.__ModuleLoader__.load({
|
|
|
430
492
|
const previous = this.editorElement
|
|
431
493
|
this.load()
|
|
432
494
|
this.editor = target
|
|
433
|
-
if (!same)
|
|
495
|
+
if (!same) {
|
|
496
|
+
this.editorDraft = target.text
|
|
497
|
+
this.editorParts = blocksToParts(target, this.sessionId)
|
|
498
|
+
}
|
|
434
499
|
this.confirming = false
|
|
435
500
|
this.editorElement = null
|
|
436
501
|
// The intent to keep the user's caret in this box. It is set when the
|
|
@@ -446,7 +511,7 @@ window.__ModuleLoader__.load({
|
|
|
446
511
|
/* focus is best-effort */
|
|
447
512
|
}
|
|
448
513
|
}
|
|
449
|
-
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 })
|
|
450
515
|
this.probeSiblingRerun()
|
|
451
516
|
}
|
|
452
517
|
|
|
@@ -483,15 +548,81 @@ window.__ModuleLoader__.load({
|
|
|
483
548
|
this.editorFocus = false
|
|
484
549
|
this.editor = null
|
|
485
550
|
this.editorDraft = ''
|
|
551
|
+
this.editorParts = []
|
|
486
552
|
this.confirming = false
|
|
487
553
|
this.editorElement = null
|
|
488
|
-
this.publish({ editing: null, draft: '', confirming: false, failure: null })
|
|
554
|
+
this.publish({ editing: null, draft: '', parts: [], confirming: false, failure: null })
|
|
489
555
|
}
|
|
490
556
|
|
|
491
557
|
setDraft(value) {
|
|
492
558
|
this.editorDraft = value
|
|
493
559
|
}
|
|
494
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
|
+
|
|
495
626
|
review() {
|
|
496
627
|
this.pendingRerun = false
|
|
497
628
|
this.runReview()
|
|
@@ -532,6 +663,13 @@ window.__ModuleLoader__.load({
|
|
|
532
663
|
this.publish({ failure: 'empty' })
|
|
533
664
|
return
|
|
534
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
|
|
535
673
|
this.publish({ pending: true, failure: null })
|
|
536
674
|
try {
|
|
537
675
|
const res = await fetch(`${ROUTE_PREFIX}/apply`, {
|
|
@@ -542,6 +680,7 @@ window.__ModuleLoader__.load({
|
|
|
542
680
|
seq: target.seq,
|
|
543
681
|
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
544
682
|
text,
|
|
683
|
+
...(parts === undefined ? {} : { parts }),
|
|
545
684
|
}),
|
|
546
685
|
})
|
|
547
686
|
const data = await res.json().catch(() => ({}))
|
|
@@ -572,6 +711,11 @@ window.__ModuleLoader__.load({
|
|
|
572
711
|
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
573
712
|
text,
|
|
574
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 : [],
|
|
575
719
|
})
|
|
576
720
|
}
|
|
577
721
|
// A reply edit shadows its own reply too, so it leaves the editable
|
|
@@ -584,6 +728,7 @@ window.__ModuleLoader__.load({
|
|
|
584
728
|
this.animateOnce = true
|
|
585
729
|
this.editor = null
|
|
586
730
|
this.editorDraft = ''
|
|
731
|
+
this.editorParts = []
|
|
587
732
|
this.editorElement = null
|
|
588
733
|
this.editorBox = null
|
|
589
734
|
this.editorRow = null
|
|
@@ -597,6 +742,7 @@ window.__ModuleLoader__.load({
|
|
|
597
742
|
revisions,
|
|
598
743
|
editable,
|
|
599
744
|
replies,
|
|
745
|
+
parts: [],
|
|
600
746
|
failure: null,
|
|
601
747
|
})
|
|
602
748
|
// `unchanged` means the host wrote NOTHING: the draft is (verbatim)
|
|
@@ -618,6 +764,11 @@ window.__ModuleLoader__.load({
|
|
|
618
764
|
// failed second half has to be reported somewhere that survives that.
|
|
619
765
|
if (data.kind === 'reply' && data.applied === false) this.notify('reply')
|
|
620
766
|
if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
|
|
767
|
+
// The save landed, but it left non-text blocks behind (the host says
|
|
768
|
+
// so): the editor that could have warned is gone by now, so this is
|
|
769
|
+
// the only place the user can still be told (see applyEdit's
|
|
770
|
+
// `dropped`).
|
|
771
|
+
if (data.kind !== 'reply' && data.applied === true && data.dropped === true) this.notify('attachments-dropped')
|
|
621
772
|
}
|
|
622
773
|
// Save first, re-run second - always: the sibling re-runs the turn
|
|
623
774
|
// from the prompt the surface NOW shows, so the revised wording has
|
|
@@ -725,6 +876,10 @@ window.__ModuleLoader__.load({
|
|
|
725
876
|
messageId: entry.messageId,
|
|
726
877
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
727
878
|
attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
|
|
879
|
+
// The blocks this message carries, as /state described them. They seed
|
|
880
|
+
// the editor's chips; a host that does not describe them (or an
|
|
881
|
+
// optimistic entry that predates its refresh) simply has none.
|
|
882
|
+
blocks: Array.isArray(entry.blocks) ? entry.blocks : [],
|
|
728
883
|
}
|
|
729
884
|
}
|
|
730
885
|
return null
|
|
@@ -1686,6 +1841,94 @@ window.__ModuleLoader__.load({
|
|
|
1686
1841
|
// injecting a second controlled tree into it would fight its reconciler.
|
|
1687
1842
|
// The draft is mirrored into the controller on every input event, so a
|
|
1688
1843
|
// host re-render that wipes the textarea cannot lose what was typed.
|
|
1844
|
+
// --- block chips ----------------------------------------------------------
|
|
1845
|
+
//
|
|
1846
|
+
// A message's non-text blocks, as the editor shows them: one chip per block,
|
|
1847
|
+
// each with what the block itself reported about itself (a name, a size, and
|
|
1848
|
+
// whether its bytes are reachable) and a remove button. Nothing here reads a
|
|
1849
|
+
// block TYPE: a chip is drawn from the fields a block carries, so a block
|
|
1850
|
+
// kind the platform adds later shows up with its own name and needs no
|
|
1851
|
+
// change in this file.
|
|
1852
|
+
|
|
1853
|
+
/** One chip's display fields, taken from whatever the block said about itself. */
|
|
1854
|
+
function chipOf(block) {
|
|
1855
|
+
const name = typeof block.name === 'string' && block.name !== '' ? block.name : null
|
|
1856
|
+
const mediaType = typeof block.mediaType === 'string' && block.mediaType !== '' ? block.mediaType : null
|
|
1857
|
+
const type = typeof block.type === 'string' && block.type !== '' ? block.type : 'block'
|
|
1858
|
+
const bytes = typeof block.bytes === 'number' ? block.bytes : null
|
|
1859
|
+
return { label: name !== null ? name : mediaType !== null ? mediaType : type, bytes }
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/**
|
|
1863
|
+
* The block list an editor starts from: every block of the message, kept.
|
|
1864
|
+
*
|
|
1865
|
+
* A reply starts from none. What a reply carries beside its text - tool
|
|
1866
|
+
* calls, reasoning - cannot survive a replacement (their results no longer
|
|
1867
|
+
* hold), so offering a chip for them would promise something the edit cannot
|
|
1868
|
+
* keep; the editor warns about them instead.
|
|
1869
|
+
*/
|
|
1870
|
+
function blocksToParts(target, sessionId) {
|
|
1871
|
+
if (!target || target.mode !== 'prompt') return []
|
|
1872
|
+
const blocks = Array.isArray(target.blocks) ? target.blocks : []
|
|
1873
|
+
const parts = []
|
|
1874
|
+
for (const block of blocks) {
|
|
1875
|
+
if (!block || typeof block.index !== 'number') continue
|
|
1876
|
+
const chip = chipOf(block)
|
|
1877
|
+
parts.push({
|
|
1878
|
+
keep: block.index,
|
|
1879
|
+
key: `k${block.index}`,
|
|
1880
|
+
label: chip.label,
|
|
1881
|
+
bytes: chip.bytes,
|
|
1882
|
+
thumbnail: block.preview === true ? previewUrl(sessionId, target.seq, block.index) : null,
|
|
1883
|
+
})
|
|
1884
|
+
}
|
|
1885
|
+
return parts
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/** Where one stored block's bytes are read from, for its thumbnail. */
|
|
1889
|
+
function previewUrl(sessionId, seq, index) {
|
|
1890
|
+
return `${ROUTE_PREFIX}/attachment?sessionId=${encodeURIComponent(sessionId)}&seq=${seq}&index=${index}`
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/** A byte count a human reads at a glance. */
|
|
1894
|
+
function formatBytes(bytes) {
|
|
1895
|
+
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return ''
|
|
1896
|
+
if (bytes < 1024) return `${bytes} B`
|
|
1897
|
+
const units = ['KB', 'MB', 'GB']
|
|
1898
|
+
let value = bytes / 1024
|
|
1899
|
+
let unit = 0
|
|
1900
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
1901
|
+
value /= 1024
|
|
1902
|
+
unit += 1
|
|
1903
|
+
}
|
|
1904
|
+
return `${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10} ${units[unit]}`
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* One picked file's bytes, base64, without the Data URL prefix.
|
|
1909
|
+
*
|
|
1910
|
+
* Read here rather than at save time so a save is one request: the payload
|
|
1911
|
+
* travels with the revision the user committed to.
|
|
1912
|
+
*/
|
|
1913
|
+
function readAsBase64(file, done) {
|
|
1914
|
+
if (typeof FileReader !== 'function') {
|
|
1915
|
+
done(null)
|
|
1916
|
+
return
|
|
1917
|
+
}
|
|
1918
|
+
try {
|
|
1919
|
+
const reader = new FileReader()
|
|
1920
|
+
reader.onload = () => {
|
|
1921
|
+
const result = typeof reader.result === 'string' ? reader.result : ''
|
|
1922
|
+
const comma = result.indexOf(',')
|
|
1923
|
+
done(comma === -1 ? null : result.slice(comma + 1))
|
|
1924
|
+
}
|
|
1925
|
+
reader.onerror = () => done(null)
|
|
1926
|
+
reader.readAsDataURL(file)
|
|
1927
|
+
} catch {
|
|
1928
|
+
done(null)
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1689
1932
|
// The re-render marker for the in-place editor.
|
|
1690
1933
|
//
|
|
1691
1934
|
// Every field `renderEditor` reads belongs here. A marker that only tracks
|
|
@@ -1702,6 +1945,13 @@ window.__ModuleLoader__.load({
|
|
|
1702
1945
|
// The re-run button appears when the sibling probe lands; without it in
|
|
1703
1946
|
// the marker the editor would never be rebuilt to show the button.
|
|
1704
1947
|
view.siblingRerun === 'present' ? 'r' : 'n',
|
|
1948
|
+
// Whether chips are drawn at all: a host that cannot admit blocks asks
|
|
1949
|
+
// for the warning instead, and the box has to change with that answer.
|
|
1950
|
+
view.attachmentsOk === true ? 'A' : 'a',
|
|
1951
|
+
// The block list itself. Removing a chip and adding one both change what
|
|
1952
|
+
// saving would write, so both have to redraw the strip - and the chip
|
|
1953
|
+
// keys are enough to tell one list from another.
|
|
1954
|
+
view.parts.map((part) => part.key).join('.'),
|
|
1705
1955
|
].join('|')
|
|
1706
1956
|
}
|
|
1707
1957
|
|
|
@@ -1720,6 +1970,100 @@ window.__ModuleLoader__.load({
|
|
|
1720
1970
|
}
|
|
1721
1971
|
}
|
|
1722
1972
|
|
|
1973
|
+
/**
|
|
1974
|
+
* The chips: one per block on the block list.
|
|
1975
|
+
*
|
|
1976
|
+
* A chip IS the block's presence in the edit. Removing it takes the block
|
|
1977
|
+
* off the list, and a block that is not on the list is not written when the
|
|
1978
|
+
* carrier is rebuilt - the whole mechanism, and the same one for a picture,
|
|
1979
|
+
* a file and whatever the platform carries next. A chip draws itself from
|
|
1980
|
+
* the bytes the block already has, and gives up on its thumbnail when the
|
|
1981
|
+
* browser says they are not a picture: no type is consulted either way.
|
|
1982
|
+
*/
|
|
1983
|
+
function editorChips(controller, view, t) {
|
|
1984
|
+
const strip = document.createElement('div')
|
|
1985
|
+
strip.className = 'dshet-chips'
|
|
1986
|
+
for (const part of view.parts) {
|
|
1987
|
+
const chip = document.createElement('div')
|
|
1988
|
+
chip.className = 'dshet-chip'
|
|
1989
|
+
chip.dataset.dshetChip = part.key
|
|
1990
|
+
const drawable = typeof part.thumbnail === 'string' && part.thumbnail !== '' &&
|
|
1991
|
+
(typeof part.bytes !== 'number' || part.bytes <= MAX_THUMB_BYTES)
|
|
1992
|
+
if (drawable) {
|
|
1993
|
+
const image = document.createElement('img')
|
|
1994
|
+
image.className = 'dshet-thumb'
|
|
1995
|
+
image.alt = ''
|
|
1996
|
+
image.src = part.thumbnail
|
|
1997
|
+
image.addEventListener('error', () => {
|
|
1998
|
+
const parent = image.parentElement
|
|
1999
|
+
if (typeof image.remove === 'function') image.remove()
|
|
2000
|
+
else if (parent !== null && parent !== undefined && typeof parent.removeChild === 'function') parent.removeChild(image)
|
|
2001
|
+
})
|
|
2002
|
+
chip.appendChild(image)
|
|
2003
|
+
}
|
|
2004
|
+
const label = document.createElement('span')
|
|
2005
|
+
label.className = 'dshet-chip-label'
|
|
2006
|
+
label.textContent = part.label
|
|
2007
|
+
chip.appendChild(label)
|
|
2008
|
+
const size = formatBytes(part.bytes)
|
|
2009
|
+
if (size !== '') {
|
|
2010
|
+
const measured = document.createElement('span')
|
|
2011
|
+
measured.className = 'dshet-chip-size'
|
|
2012
|
+
measured.textContent = size
|
|
2013
|
+
chip.appendChild(measured)
|
|
2014
|
+
}
|
|
2015
|
+
const remove = document.createElement('button')
|
|
2016
|
+
remove.type = 'button'
|
|
2017
|
+
remove.className = 'dshet-chip-remove'
|
|
2018
|
+
remove.textContent = '×'
|
|
2019
|
+
remove.title = t('editor.attach.remove')
|
|
2020
|
+
remove.setAttribute('aria-label', t('editor.attach.remove'))
|
|
2021
|
+
remove.disabled = view.pending
|
|
2022
|
+
pressable(remove, () => controller.removePart(part.key))
|
|
2023
|
+
chip.appendChild(remove)
|
|
2024
|
+
strip.appendChild(chip)
|
|
2025
|
+
}
|
|
2026
|
+
return strip
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
/**
|
|
2030
|
+
* The add/replace entry: a picker that puts a block on the list.
|
|
2031
|
+
*
|
|
2032
|
+
* Drawn only when the host answered that it can admit a block. A deployment
|
|
2033
|
+
* with no attachment store gets no picker, because a picker that cannot be
|
|
2034
|
+
* honoured is worse than none - the warning below says what a save will
|
|
2035
|
+
* drop instead.
|
|
2036
|
+
*/
|
|
2037
|
+
function attachEntry(controller, view, t) {
|
|
2038
|
+
const row = document.createElement('div')
|
|
2039
|
+
row.className = 'dshet-attach-row'
|
|
2040
|
+
const picker = document.createElement('input')
|
|
2041
|
+
picker.type = 'file'
|
|
2042
|
+
picker.multiple = true
|
|
2043
|
+
picker.className = 'dshet-attach-input'
|
|
2044
|
+
picker.style.display = 'none'
|
|
2045
|
+
picker.addEventListener('change', () => {
|
|
2046
|
+
controller.attachFiles(picker.files)
|
|
2047
|
+
// Cleared so the same file can be picked again after a removal.
|
|
2048
|
+
try {
|
|
2049
|
+
picker.value = ''
|
|
2050
|
+
} catch {
|
|
2051
|
+
/* not every input lets this be written */
|
|
2052
|
+
}
|
|
2053
|
+
})
|
|
2054
|
+
const add = document.createElement('button')
|
|
2055
|
+
add.type = 'button'
|
|
2056
|
+
add.className = 'dshet-attach'
|
|
2057
|
+
add.textContent = t('editor.attach.add')
|
|
2058
|
+
add.disabled = view.pending
|
|
2059
|
+
pressable(add, () => {
|
|
2060
|
+
if (typeof picker.click === 'function') picker.click()
|
|
2061
|
+
})
|
|
2062
|
+
row.appendChild(picker)
|
|
2063
|
+
row.appendChild(add)
|
|
2064
|
+
return row
|
|
2065
|
+
}
|
|
2066
|
+
|
|
1723
2067
|
function renderEditor(row, controller, view, t) {
|
|
1724
2068
|
const target = view.editing
|
|
1725
2069
|
const texts = editorTexts(t, target)
|
|
@@ -1792,6 +2136,18 @@ window.__ModuleLoader__.load({
|
|
|
1792
2136
|
})
|
|
1793
2137
|
box.appendChild(area)
|
|
1794
2138
|
|
|
2139
|
+
// The blocks this message carries, each one a chip the user can remove,
|
|
2140
|
+
// and the entry that adds another. Drawn only when the host answered that
|
|
2141
|
+
// it can admit what a chip promises - otherwise the warning below states
|
|
2142
|
+
// what a save would drop, which is exactly what this editor did before
|
|
2143
|
+
// blocks travelled at all. A reply never gets chips: what it carries
|
|
2144
|
+
// beside its text cannot survive a replacement.
|
|
2145
|
+
const chips = target.mode === 'prompt' && view.attachmentsOk === true
|
|
2146
|
+
if (chips) {
|
|
2147
|
+
if (view.parts.length > 0) box.appendChild(editorChips(controller, view, t))
|
|
2148
|
+
box.appendChild(attachEntry(controller, view, t))
|
|
2149
|
+
}
|
|
2150
|
+
|
|
1795
2151
|
// Only what the user has to know right now: what the replacement will
|
|
1796
2152
|
// discard, and what went wrong. The running explanation lives in the
|
|
1797
2153
|
// README and in the buttons' tooltips, the way a normal input box does it.
|
|
@@ -1801,7 +2157,9 @@ window.__ModuleLoader__.load({
|
|
|
1801
2157
|
note.textContent = t('confirm.body')
|
|
1802
2158
|
box.appendChild(note)
|
|
1803
2159
|
}
|
|
1804
|
-
|
|
2160
|
+
// Shown when saving really would drop something: a reply's tool calls
|
|
2161
|
+
// always, and a prompt's blocks when this host cannot carry them.
|
|
2162
|
+
if (target.attachments > 0 && !chips) {
|
|
1805
2163
|
const warn = document.createElement('p')
|
|
1806
2164
|
warn.className = 'dshet-warn'
|
|
1807
2165
|
warn.textContent = texts.warn
|
|
@@ -2027,6 +2385,10 @@ window.__ModuleLoader__.load({
|
|
|
2027
2385
|
messageId: entry.messageId,
|
|
2028
2386
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
2029
2387
|
attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
|
|
2388
|
+
// A reply edits text only on purpose: the tool calls and reasoning it
|
|
2389
|
+
// carries stop being true the moment it is replaced, so they are not
|
|
2390
|
+
// offered as chips (the editor warns about them instead).
|
|
2391
|
+
blocks: [],
|
|
2030
2392
|
})
|
|
2031
2393
|
}
|
|
2032
2394
|
// The config object is always an object: this runtime's `jsx` reads
|
|
@@ -5103,7 +5465,7 @@ window.__ModuleLoader__.load({
|
|
|
5103
5465
|
const NS = 'dsh-as-aistudio'
|
|
5104
5466
|
|
|
5105
5467
|
/** Keep in sync with package.json and src/components.js. */
|
|
5106
|
-
const PLUGIN_VERSION = '0.2.
|
|
5468
|
+
const PLUGIN_VERSION = '0.2.8'
|
|
5107
5469
|
|
|
5108
5470
|
/** Presence marker the live verifier reads. */
|
|
5109
5471
|
const DEBUG_KEY = '__DSH_AS_AISTUDIO__'
|
package/src/components.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
export const STUDIO_ID = 'dsh-as-aistudio'
|
|
27
27
|
|
|
28
28
|
/** Keep in sync with package.json and src/client.js. */
|
|
29
|
-
export const STUDIO_VERSION = '0.2.
|
|
29
|
+
export const STUDIO_VERSION = '0.2.8'
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* The AI Studio action strip this studio composes, in the order the user meets
|
package/src/studio.js
CHANGED
|
@@ -48,7 +48,7 @@ window.__ModuleLoader__.load({
|
|
|
48
48
|
const NS = 'dsh-as-aistudio'
|
|
49
49
|
|
|
50
50
|
/** Keep in sync with package.json and src/components.js. */
|
|
51
|
-
const PLUGIN_VERSION = '0.2.
|
|
51
|
+
const PLUGIN_VERSION = '0.2.8'
|
|
52
52
|
|
|
53
53
|
/** Presence marker the live verifier reads. */
|
|
54
54
|
const DEBUG_KEY = '__DSH_AS_AISTUDIO__'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-edit-turn browser half, VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-edit-turn/lib/client.js (v0.2.
|
|
3
|
+
// Source: ../dsh-edit-turn/lib/client.js (v0.2.19, sha256 618c7394fb3880c4)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/vendor-sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// The component registers its factory with the host module loader; inside this
|
|
@@ -18,13 +18,21 @@ export default (require) => {
|
|
|
18
18
|
|
|
19
19
|
const NS = 'dsh-edit-turn'
|
|
20
20
|
const ROUTE_PREFIX = '/dsh-edit-turn'
|
|
21
|
+
// One picked file may not exceed this. It mirrors the platform's own default
|
|
22
|
+
// per-image ceiling (dsh-attachment-local's 20 MiB), because a payload above
|
|
23
|
+
// it would be refused by the store anyway - saying so in the picker is
|
|
24
|
+
// kinder than a round trip that fails.
|
|
25
|
+
const MAX_ATTACH_BYTES = 20 * 1024 * 1024
|
|
26
|
+
// Above this a chip keeps its label instead of trying to draw itself: a
|
|
27
|
+
// thumbnail is not worth decoding a 30 MB attachment for.
|
|
28
|
+
const MAX_THUMB_BYTES = 8 * 1024 * 1024
|
|
21
29
|
// The sibling that owns re-running: it shadows a turn and regenerates it
|
|
22
30
|
// from the prompt the surface now shows, then replays what followed. The
|
|
23
31
|
// editor's re-run button is only offered when it is mounted.
|
|
24
32
|
const RERUN_PREFIX = '/dsh-rerun-turn'
|
|
25
33
|
|
|
26
34
|
/** Keep in sync with package.json and lib/index.js. */
|
|
27
|
-
const PLUGIN_VERSION = '0.2.
|
|
35
|
+
const PLUGIN_VERSION = '0.2.19'
|
|
28
36
|
|
|
29
37
|
// --- copy -----------------------------------------------------------------
|
|
30
38
|
|
|
@@ -51,6 +59,8 @@ export default (require) => {
|
|
|
51
59
|
'editor.pending': '正在保存…',
|
|
52
60
|
'editor.warn.attachments': '这条消息包含图片或文件附件,改写会丢弃它们,只保留文字。',
|
|
53
61
|
'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
|
|
62
|
+
'editor.attach.add': '添加附件',
|
|
63
|
+
'editor.attach.remove': '移除这个附件',
|
|
54
64
|
'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
|
|
55
65
|
'error.invalid': '请求无效,请刷新后重试。',
|
|
56
66
|
'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
|
|
@@ -72,6 +82,11 @@ export default (require) => {
|
|
|
72
82
|
'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
|
|
73
83
|
'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
|
|
74
84
|
'error.internal': '服务器内部错误,请稍后重试。',
|
|
85
|
+
'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
|
|
86
|
+
'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
|
|
87
|
+
'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
|
|
88
|
+
'error.attachment-too-large': '这个文件太大,无法作为附件。',
|
|
89
|
+
'error.too-large': '这次修改太大,服务器没有接收。',
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
const en = {
|
|
@@ -92,6 +107,8 @@ export default (require) => {
|
|
|
92
107
|
'editor.pending': 'Working...',
|
|
93
108
|
'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
|
|
94
109
|
'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
|
|
110
|
+
'editor.attach.add': 'Add an attachment',
|
|
111
|
+
'editor.attach.remove': 'Remove this attachment',
|
|
95
112
|
'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
|
|
96
113
|
'error.invalid': 'Invalid request; refresh and try again.',
|
|
97
114
|
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
@@ -113,6 +130,11 @@ export default (require) => {
|
|
|
113
130
|
'error.already-retired': 'This turn is no longer part of the current context.',
|
|
114
131
|
'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
|
|
115
132
|
'error.internal': 'The host hit an internal error; try again shortly.',
|
|
133
|
+
'error.attachment-refused': 'The attachment could not be stored, so nothing was changed.',
|
|
134
|
+
'error.attachments-dropped': 'Saved, but the images or files on this message could not be kept: this deployment mounts no attachment store.',
|
|
135
|
+
'error.attachment-unreadable': 'That attachment could not be read; try another file.',
|
|
136
|
+
'error.attachment-too-large': 'That file is too large to attach.',
|
|
137
|
+
'error.too-large': 'That revision was too large for the host to accept.',
|
|
116
138
|
}
|
|
117
139
|
|
|
118
140
|
// Every error code the host can return travels through here. A code is
|
|
@@ -221,6 +243,25 @@ export default (require) => {
|
|
|
221
243
|
'.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}',
|
|
222
244
|
'.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
|
|
223
245
|
'.dshet-editor textarea:disabled{opacity:.65}',
|
|
246
|
+
// The blocks this message carries: one chip each, drawn from the same
|
|
247
|
+
// tokens as the panel so a skin cannot wash them out. A chip only takes
|
|
248
|
+
// the width it needs; the label is the only part that may shrink, and the
|
|
249
|
+
// remove button never does.
|
|
250
|
+
'.dshet-chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 14px 0}',
|
|
251
|
+
'.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}',
|
|
252
|
+
'.dshet-thumb{width:26px;height:26px;flex:none;border-radius:6px;object-fit:cover;background:var(--dshet-field)}',
|
|
253
|
+
'.dshet-chip-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
254
|
+
'.dshet-chip-size{flex:none;color:var(--dshet-ink-dim)}',
|
|
255
|
+
'.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}',
|
|
256
|
+
'.dshet-chip-remove:hover:not(:disabled){background:var(--dshet-hover);color:var(--dsw-alias-state-error-primary,#c93a31)}',
|
|
257
|
+
'.dshet-chip-remove:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:1px}',
|
|
258
|
+
'.dshet-chip-remove:disabled{cursor:default;opacity:.5}',
|
|
259
|
+
'.dshet-attach-row{display:flex;align-items:center;gap:8px;margin:8px 14px 0}',
|
|
260
|
+
'.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}',
|
|
261
|
+
'.dshet-attach:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink)}',
|
|
262
|
+
'.dshet-attach:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
|
|
263
|
+
'.dshet-attach:disabled{cursor:default;opacity:.5}',
|
|
264
|
+
'.dshet-attach-input{display:none}',
|
|
224
265
|
'.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
|
|
225
266
|
'.dshet-note{color:var(--dshet-ink-dim)}',
|
|
226
267
|
'.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
|
|
@@ -295,6 +336,16 @@ export default (require) => {
|
|
|
295
336
|
this.editorRow = null
|
|
296
337
|
this.editorFocus = false
|
|
297
338
|
this.editorDraft = ''
|
|
339
|
+
// The block list being edited: what the carrier will be rebuilt from.
|
|
340
|
+
// Every entry is either `{ keep: <index> }` - a block of this message the
|
|
341
|
+
// user left alone - or `{ add: { data, mediaType?, name } }` for
|
|
342
|
+
// something they just picked, with the display fields the chip needs.
|
|
343
|
+
// The list is the ONLY thing that decides what the host writes, which is
|
|
344
|
+
// why a chip the user removes simply leaves it.
|
|
345
|
+
this.editorParts = []
|
|
346
|
+
// Names the added chips, so removing one is a matter of identity rather
|
|
347
|
+
// than of position (a DOM pass may run between two of them).
|
|
348
|
+
this.uploadSeq = 0
|
|
298
349
|
this.clearTimer = null
|
|
299
350
|
this.pendingRerun = false
|
|
300
351
|
this.siblingRerun = 'unknown'
|
|
@@ -311,6 +362,7 @@ export default (require) => {
|
|
|
311
362
|
repliesByMessage: new Map(),
|
|
312
363
|
editing: null,
|
|
313
364
|
draft: '',
|
|
365
|
+
parts: [],
|
|
314
366
|
confirming: false,
|
|
315
367
|
pending: false,
|
|
316
368
|
failure: null,
|
|
@@ -318,6 +370,12 @@ export default (require) => {
|
|
|
318
370
|
surfaceReady: false,
|
|
319
371
|
loaded: false,
|
|
320
372
|
loadError: false,
|
|
373
|
+
// What the host said it can do with a block list (see /state's
|
|
374
|
+
// `capabilities`). False is the safe reading: an older host, a failed
|
|
375
|
+
// load and a deployment without an attachment store all mean "text
|
|
376
|
+
// only", which is what the editor then offers - with the warning that
|
|
377
|
+
// says so - instead of a chip it cannot honour.
|
|
378
|
+
attachmentsOk: false,
|
|
321
379
|
// Matches the host's default: one click applies. Only an explicit
|
|
322
380
|
// `confirm: true` in the profile turns the second step back on.
|
|
323
381
|
confirmStep: false,
|
|
@@ -402,6 +460,10 @@ export default (require) => {
|
|
|
402
460
|
loaded: true,
|
|
403
461
|
loadError: false,
|
|
404
462
|
confirmStep: !(data.config && data.config.confirm === false),
|
|
463
|
+
// What this host can do with blocks, taken from its own answer.
|
|
464
|
+
// An older host says nothing here, which reads as "text only" -
|
|
465
|
+
// the behaviour every release before blocks had.
|
|
466
|
+
attachmentsOk: Boolean(data.capabilities && data.capabilities.attachments === true),
|
|
405
467
|
})
|
|
406
468
|
})
|
|
407
469
|
.catch(() => {
|
|
@@ -422,7 +484,10 @@ export default (require) => {
|
|
|
422
484
|
const previous = this.editorElement
|
|
423
485
|
this.load()
|
|
424
486
|
this.editor = target
|
|
425
|
-
if (!same)
|
|
487
|
+
if (!same) {
|
|
488
|
+
this.editorDraft = target.text
|
|
489
|
+
this.editorParts = blocksToParts(target, this.sessionId)
|
|
490
|
+
}
|
|
426
491
|
this.confirming = false
|
|
427
492
|
this.editorElement = null
|
|
428
493
|
// The intent to keep the user's caret in this box. It is set when the
|
|
@@ -438,7 +503,7 @@ export default (require) => {
|
|
|
438
503
|
/* focus is best-effort */
|
|
439
504
|
}
|
|
440
505
|
}
|
|
441
|
-
this.publish({ editing: target, draft: this.editorDraft, confirming: false, failure: null })
|
|
506
|
+
this.publish({ editing: target, draft: this.editorDraft, parts: this.editorParts.slice(), confirming: false, failure: null })
|
|
442
507
|
this.probeSiblingRerun()
|
|
443
508
|
}
|
|
444
509
|
|
|
@@ -475,15 +540,81 @@ export default (require) => {
|
|
|
475
540
|
this.editorFocus = false
|
|
476
541
|
this.editor = null
|
|
477
542
|
this.editorDraft = ''
|
|
543
|
+
this.editorParts = []
|
|
478
544
|
this.confirming = false
|
|
479
545
|
this.editorElement = null
|
|
480
|
-
this.publish({ editing: null, draft: '', confirming: false, failure: null })
|
|
546
|
+
this.publish({ editing: null, draft: '', parts: [], confirming: false, failure: null })
|
|
481
547
|
}
|
|
482
548
|
|
|
483
549
|
setDraft(value) {
|
|
484
550
|
this.editorDraft = value
|
|
485
551
|
}
|
|
486
552
|
|
|
553
|
+
/**
|
|
554
|
+
* One chip the user removed leaves the block list, and nothing else.
|
|
555
|
+
*
|
|
556
|
+
* The list IS the edit: a block that is no longer in it is not written
|
|
557
|
+
* when the carrier is rebuilt, so removing a chip is the whole
|
|
558
|
+
* implementation of "this attachment should not be here any more".
|
|
559
|
+
*/
|
|
560
|
+
removePart(key) {
|
|
561
|
+
const next = this.editorParts.filter((part) => part.key !== key)
|
|
562
|
+
if (next.length === this.editorParts.length) return
|
|
563
|
+
this.editorParts = next
|
|
564
|
+
this.publish({ parts: next.slice() })
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** One payload the user picked joins the block list, at the end. */
|
|
568
|
+
addUpload(upload) {
|
|
569
|
+
this.editorParts = [...this.editorParts, upload]
|
|
570
|
+
this.publish({ parts: this.editorParts.slice() })
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Read picked files and append one chip each.
|
|
575
|
+
*
|
|
576
|
+
* The bytes stay in the browser until the save: the host admits them
|
|
577
|
+
* through the platform's attachment store only when the user commits, so
|
|
578
|
+
* opening the editor and cancelling stores nothing.
|
|
579
|
+
*/
|
|
580
|
+
attachFiles(files) {
|
|
581
|
+
let list = []
|
|
582
|
+
try {
|
|
583
|
+
list = files === null || files === undefined ? [] : Array.prototype.slice.call(files)
|
|
584
|
+
} catch {
|
|
585
|
+
list = []
|
|
586
|
+
}
|
|
587
|
+
for (const file of list) {
|
|
588
|
+
if (!file || typeof file !== 'object') continue
|
|
589
|
+
if (typeof file.size === 'number' && file.size > MAX_ATTACH_BYTES) {
|
|
590
|
+
this.notify('attachment-too-large')
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
const name = typeof file.name === 'string' && file.name !== '' ? file.name : null
|
|
594
|
+
const mediaType = typeof file.type === 'string' && file.type !== '' ? file.type : null
|
|
595
|
+
readAsBase64(file, (data) => {
|
|
596
|
+
if (data === null) {
|
|
597
|
+
this.notify('attachment-unreadable')
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
this.uploadSeq += 1
|
|
601
|
+
this.addUpload({
|
|
602
|
+
key: `a${this.uploadSeq}`,
|
|
603
|
+
add: {
|
|
604
|
+
data,
|
|
605
|
+
...(mediaType === null ? {} : { mediaType }),
|
|
606
|
+
...(name === null ? {} : { name }),
|
|
607
|
+
},
|
|
608
|
+
// The chip's own fields. A picked image can show itself through
|
|
609
|
+
// the very bytes it is carrying - no round trip, no store.
|
|
610
|
+
label: name !== null ? name : mediaType !== null ? mediaType : 'attachment',
|
|
611
|
+
bytes: typeof file.size === 'number' ? file.size : null,
|
|
612
|
+
thumbnail: `data:${mediaType === null ? 'application/octet-stream' : mediaType};base64,${data}`,
|
|
613
|
+
})
|
|
614
|
+
})
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
487
618
|
review() {
|
|
488
619
|
this.pendingRerun = false
|
|
489
620
|
this.runReview()
|
|
@@ -524,6 +655,13 @@ export default (require) => {
|
|
|
524
655
|
this.publish({ failure: 'empty' })
|
|
525
656
|
return
|
|
526
657
|
}
|
|
658
|
+
// What the host will rebuild the carrier from: the blocks still on the
|
|
659
|
+
// list, in order, plus anything picked. Sent only when the host answered
|
|
660
|
+
// that it can carry blocks - a request that names blocks this host would
|
|
661
|
+
// have to drop is a request that lies about what the save did.
|
|
662
|
+
const parts = target.mode === 'prompt' && this.view.attachmentsOk === true
|
|
663
|
+
? this.editorParts.map((part) => (part.keep !== undefined ? { keep: part.keep } : { add: part.add }))
|
|
664
|
+
: undefined
|
|
527
665
|
this.publish({ pending: true, failure: null })
|
|
528
666
|
try {
|
|
529
667
|
const res = await fetch(`${ROUTE_PREFIX}/apply`, {
|
|
@@ -534,6 +672,7 @@ export default (require) => {
|
|
|
534
672
|
seq: target.seq,
|
|
535
673
|
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
536
674
|
text,
|
|
675
|
+
...(parts === undefined ? {} : { parts }),
|
|
537
676
|
}),
|
|
538
677
|
})
|
|
539
678
|
const data = await res.json().catch(() => ({}))
|
|
@@ -564,6 +703,11 @@ export default (require) => {
|
|
|
564
703
|
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
565
704
|
text,
|
|
566
705
|
attachments: typeof target.attachments === 'number' ? target.attachments : 0,
|
|
706
|
+
// The host answers with what the carrier now carries, described
|
|
707
|
+
// the way /state describes it. Adopted for the row drawn before
|
|
708
|
+
// the refresh lands: without it a second save could not know
|
|
709
|
+
// which blocks are still there and would rewrite text-only.
|
|
710
|
+
blocks: Array.isArray(data.blocks) ? data.blocks : [],
|
|
567
711
|
})
|
|
568
712
|
}
|
|
569
713
|
// A reply edit shadows its own reply too, so it leaves the editable
|
|
@@ -576,6 +720,7 @@ export default (require) => {
|
|
|
576
720
|
this.animateOnce = true
|
|
577
721
|
this.editor = null
|
|
578
722
|
this.editorDraft = ''
|
|
723
|
+
this.editorParts = []
|
|
579
724
|
this.editorElement = null
|
|
580
725
|
this.editorBox = null
|
|
581
726
|
this.editorRow = null
|
|
@@ -589,6 +734,7 @@ export default (require) => {
|
|
|
589
734
|
revisions,
|
|
590
735
|
editable,
|
|
591
736
|
replies,
|
|
737
|
+
parts: [],
|
|
592
738
|
failure: null,
|
|
593
739
|
})
|
|
594
740
|
// `unchanged` means the host wrote NOTHING: the draft is (verbatim)
|
|
@@ -610,6 +756,11 @@ export default (require) => {
|
|
|
610
756
|
// failed second half has to be reported somewhere that survives that.
|
|
611
757
|
if (data.kind === 'reply' && data.applied === false) this.notify('reply')
|
|
612
758
|
if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
|
|
759
|
+
// The save landed, but it left non-text blocks behind (the host says
|
|
760
|
+
// so): the editor that could have warned is gone by now, so this is
|
|
761
|
+
// the only place the user can still be told (see applyEdit's
|
|
762
|
+
// `dropped`).
|
|
763
|
+
if (data.kind !== 'reply' && data.applied === true && data.dropped === true) this.notify('attachments-dropped')
|
|
613
764
|
}
|
|
614
765
|
// Save first, re-run second - always: the sibling re-runs the turn
|
|
615
766
|
// from the prompt the surface NOW shows, so the revised wording has
|
|
@@ -717,6 +868,10 @@ export default (require) => {
|
|
|
717
868
|
messageId: entry.messageId,
|
|
718
869
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
719
870
|
attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
|
|
871
|
+
// The blocks this message carries, as /state described them. They seed
|
|
872
|
+
// the editor's chips; a host that does not describe them (or an
|
|
873
|
+
// optimistic entry that predates its refresh) simply has none.
|
|
874
|
+
blocks: Array.isArray(entry.blocks) ? entry.blocks : [],
|
|
720
875
|
}
|
|
721
876
|
}
|
|
722
877
|
return null
|
|
@@ -1678,6 +1833,94 @@ export default (require) => {
|
|
|
1678
1833
|
// injecting a second controlled tree into it would fight its reconciler.
|
|
1679
1834
|
// The draft is mirrored into the controller on every input event, so a
|
|
1680
1835
|
// host re-render that wipes the textarea cannot lose what was typed.
|
|
1836
|
+
// --- block chips ----------------------------------------------------------
|
|
1837
|
+
//
|
|
1838
|
+
// A message's non-text blocks, as the editor shows them: one chip per block,
|
|
1839
|
+
// each with what the block itself reported about itself (a name, a size, and
|
|
1840
|
+
// whether its bytes are reachable) and a remove button. Nothing here reads a
|
|
1841
|
+
// block TYPE: a chip is drawn from the fields a block carries, so a block
|
|
1842
|
+
// kind the platform adds later shows up with its own name and needs no
|
|
1843
|
+
// change in this file.
|
|
1844
|
+
|
|
1845
|
+
/** One chip's display fields, taken from whatever the block said about itself. */
|
|
1846
|
+
function chipOf(block) {
|
|
1847
|
+
const name = typeof block.name === 'string' && block.name !== '' ? block.name : null
|
|
1848
|
+
const mediaType = typeof block.mediaType === 'string' && block.mediaType !== '' ? block.mediaType : null
|
|
1849
|
+
const type = typeof block.type === 'string' && block.type !== '' ? block.type : 'block'
|
|
1850
|
+
const bytes = typeof block.bytes === 'number' ? block.bytes : null
|
|
1851
|
+
return { label: name !== null ? name : mediaType !== null ? mediaType : type, bytes }
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
/**
|
|
1855
|
+
* The block list an editor starts from: every block of the message, kept.
|
|
1856
|
+
*
|
|
1857
|
+
* A reply starts from none. What a reply carries beside its text - tool
|
|
1858
|
+
* calls, reasoning - cannot survive a replacement (their results no longer
|
|
1859
|
+
* hold), so offering a chip for them would promise something the edit cannot
|
|
1860
|
+
* keep; the editor warns about them instead.
|
|
1861
|
+
*/
|
|
1862
|
+
function blocksToParts(target, sessionId) {
|
|
1863
|
+
if (!target || target.mode !== 'prompt') return []
|
|
1864
|
+
const blocks = Array.isArray(target.blocks) ? target.blocks : []
|
|
1865
|
+
const parts = []
|
|
1866
|
+
for (const block of blocks) {
|
|
1867
|
+
if (!block || typeof block.index !== 'number') continue
|
|
1868
|
+
const chip = chipOf(block)
|
|
1869
|
+
parts.push({
|
|
1870
|
+
keep: block.index,
|
|
1871
|
+
key: `k${block.index}`,
|
|
1872
|
+
label: chip.label,
|
|
1873
|
+
bytes: chip.bytes,
|
|
1874
|
+
thumbnail: block.preview === true ? previewUrl(sessionId, target.seq, block.index) : null,
|
|
1875
|
+
})
|
|
1876
|
+
}
|
|
1877
|
+
return parts
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
/** Where one stored block's bytes are read from, for its thumbnail. */
|
|
1881
|
+
function previewUrl(sessionId, seq, index) {
|
|
1882
|
+
return `${ROUTE_PREFIX}/attachment?sessionId=${encodeURIComponent(sessionId)}&seq=${seq}&index=${index}`
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/** A byte count a human reads at a glance. */
|
|
1886
|
+
function formatBytes(bytes) {
|
|
1887
|
+
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return ''
|
|
1888
|
+
if (bytes < 1024) return `${bytes} B`
|
|
1889
|
+
const units = ['KB', 'MB', 'GB']
|
|
1890
|
+
let value = bytes / 1024
|
|
1891
|
+
let unit = 0
|
|
1892
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
1893
|
+
value /= 1024
|
|
1894
|
+
unit += 1
|
|
1895
|
+
}
|
|
1896
|
+
return `${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10} ${units[unit]}`
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/**
|
|
1900
|
+
* One picked file's bytes, base64, without the Data URL prefix.
|
|
1901
|
+
*
|
|
1902
|
+
* Read here rather than at save time so a save is one request: the payload
|
|
1903
|
+
* travels with the revision the user committed to.
|
|
1904
|
+
*/
|
|
1905
|
+
function readAsBase64(file, done) {
|
|
1906
|
+
if (typeof FileReader !== 'function') {
|
|
1907
|
+
done(null)
|
|
1908
|
+
return
|
|
1909
|
+
}
|
|
1910
|
+
try {
|
|
1911
|
+
const reader = new FileReader()
|
|
1912
|
+
reader.onload = () => {
|
|
1913
|
+
const result = typeof reader.result === 'string' ? reader.result : ''
|
|
1914
|
+
const comma = result.indexOf(',')
|
|
1915
|
+
done(comma === -1 ? null : result.slice(comma + 1))
|
|
1916
|
+
}
|
|
1917
|
+
reader.onerror = () => done(null)
|
|
1918
|
+
reader.readAsDataURL(file)
|
|
1919
|
+
} catch {
|
|
1920
|
+
done(null)
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1681
1924
|
// The re-render marker for the in-place editor.
|
|
1682
1925
|
//
|
|
1683
1926
|
// Every field `renderEditor` reads belongs here. A marker that only tracks
|
|
@@ -1694,6 +1937,13 @@ export default (require) => {
|
|
|
1694
1937
|
// The re-run button appears when the sibling probe lands; without it in
|
|
1695
1938
|
// the marker the editor would never be rebuilt to show the button.
|
|
1696
1939
|
view.siblingRerun === 'present' ? 'r' : 'n',
|
|
1940
|
+
// Whether chips are drawn at all: a host that cannot admit blocks asks
|
|
1941
|
+
// for the warning instead, and the box has to change with that answer.
|
|
1942
|
+
view.attachmentsOk === true ? 'A' : 'a',
|
|
1943
|
+
// The block list itself. Removing a chip and adding one both change what
|
|
1944
|
+
// saving would write, so both have to redraw the strip - and the chip
|
|
1945
|
+
// keys are enough to tell one list from another.
|
|
1946
|
+
view.parts.map((part) => part.key).join('.'),
|
|
1697
1947
|
].join('|')
|
|
1698
1948
|
}
|
|
1699
1949
|
|
|
@@ -1712,6 +1962,100 @@ export default (require) => {
|
|
|
1712
1962
|
}
|
|
1713
1963
|
}
|
|
1714
1964
|
|
|
1965
|
+
/**
|
|
1966
|
+
* The chips: one per block on the block list.
|
|
1967
|
+
*
|
|
1968
|
+
* A chip IS the block's presence in the edit. Removing it takes the block
|
|
1969
|
+
* off the list, and a block that is not on the list is not written when the
|
|
1970
|
+
* carrier is rebuilt - the whole mechanism, and the same one for a picture,
|
|
1971
|
+
* a file and whatever the platform carries next. A chip draws itself from
|
|
1972
|
+
* the bytes the block already has, and gives up on its thumbnail when the
|
|
1973
|
+
* browser says they are not a picture: no type is consulted either way.
|
|
1974
|
+
*/
|
|
1975
|
+
function editorChips(controller, view, t) {
|
|
1976
|
+
const strip = document.createElement('div')
|
|
1977
|
+
strip.className = 'dshet-chips'
|
|
1978
|
+
for (const part of view.parts) {
|
|
1979
|
+
const chip = document.createElement('div')
|
|
1980
|
+
chip.className = 'dshet-chip'
|
|
1981
|
+
chip.dataset.dshetChip = part.key
|
|
1982
|
+
const drawable = typeof part.thumbnail === 'string' && part.thumbnail !== '' &&
|
|
1983
|
+
(typeof part.bytes !== 'number' || part.bytes <= MAX_THUMB_BYTES)
|
|
1984
|
+
if (drawable) {
|
|
1985
|
+
const image = document.createElement('img')
|
|
1986
|
+
image.className = 'dshet-thumb'
|
|
1987
|
+
image.alt = ''
|
|
1988
|
+
image.src = part.thumbnail
|
|
1989
|
+
image.addEventListener('error', () => {
|
|
1990
|
+
const parent = image.parentElement
|
|
1991
|
+
if (typeof image.remove === 'function') image.remove()
|
|
1992
|
+
else if (parent !== null && parent !== undefined && typeof parent.removeChild === 'function') parent.removeChild(image)
|
|
1993
|
+
})
|
|
1994
|
+
chip.appendChild(image)
|
|
1995
|
+
}
|
|
1996
|
+
const label = document.createElement('span')
|
|
1997
|
+
label.className = 'dshet-chip-label'
|
|
1998
|
+
label.textContent = part.label
|
|
1999
|
+
chip.appendChild(label)
|
|
2000
|
+
const size = formatBytes(part.bytes)
|
|
2001
|
+
if (size !== '') {
|
|
2002
|
+
const measured = document.createElement('span')
|
|
2003
|
+
measured.className = 'dshet-chip-size'
|
|
2004
|
+
measured.textContent = size
|
|
2005
|
+
chip.appendChild(measured)
|
|
2006
|
+
}
|
|
2007
|
+
const remove = document.createElement('button')
|
|
2008
|
+
remove.type = 'button'
|
|
2009
|
+
remove.className = 'dshet-chip-remove'
|
|
2010
|
+
remove.textContent = '×'
|
|
2011
|
+
remove.title = t('editor.attach.remove')
|
|
2012
|
+
remove.setAttribute('aria-label', t('editor.attach.remove'))
|
|
2013
|
+
remove.disabled = view.pending
|
|
2014
|
+
pressable(remove, () => controller.removePart(part.key))
|
|
2015
|
+
chip.appendChild(remove)
|
|
2016
|
+
strip.appendChild(chip)
|
|
2017
|
+
}
|
|
2018
|
+
return strip
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* The add/replace entry: a picker that puts a block on the list.
|
|
2023
|
+
*
|
|
2024
|
+
* Drawn only when the host answered that it can admit a block. A deployment
|
|
2025
|
+
* with no attachment store gets no picker, because a picker that cannot be
|
|
2026
|
+
* honoured is worse than none - the warning below says what a save will
|
|
2027
|
+
* drop instead.
|
|
2028
|
+
*/
|
|
2029
|
+
function attachEntry(controller, view, t) {
|
|
2030
|
+
const row = document.createElement('div')
|
|
2031
|
+
row.className = 'dshet-attach-row'
|
|
2032
|
+
const picker = document.createElement('input')
|
|
2033
|
+
picker.type = 'file'
|
|
2034
|
+
picker.multiple = true
|
|
2035
|
+
picker.className = 'dshet-attach-input'
|
|
2036
|
+
picker.style.display = 'none'
|
|
2037
|
+
picker.addEventListener('change', () => {
|
|
2038
|
+
controller.attachFiles(picker.files)
|
|
2039
|
+
// Cleared so the same file can be picked again after a removal.
|
|
2040
|
+
try {
|
|
2041
|
+
picker.value = ''
|
|
2042
|
+
} catch {
|
|
2043
|
+
/* not every input lets this be written */
|
|
2044
|
+
}
|
|
2045
|
+
})
|
|
2046
|
+
const add = document.createElement('button')
|
|
2047
|
+
add.type = 'button'
|
|
2048
|
+
add.className = 'dshet-attach'
|
|
2049
|
+
add.textContent = t('editor.attach.add')
|
|
2050
|
+
add.disabled = view.pending
|
|
2051
|
+
pressable(add, () => {
|
|
2052
|
+
if (typeof picker.click === 'function') picker.click()
|
|
2053
|
+
})
|
|
2054
|
+
row.appendChild(picker)
|
|
2055
|
+
row.appendChild(add)
|
|
2056
|
+
return row
|
|
2057
|
+
}
|
|
2058
|
+
|
|
1715
2059
|
function renderEditor(row, controller, view, t) {
|
|
1716
2060
|
const target = view.editing
|
|
1717
2061
|
const texts = editorTexts(t, target)
|
|
@@ -1784,6 +2128,18 @@ export default (require) => {
|
|
|
1784
2128
|
})
|
|
1785
2129
|
box.appendChild(area)
|
|
1786
2130
|
|
|
2131
|
+
// The blocks this message carries, each one a chip the user can remove,
|
|
2132
|
+
// and the entry that adds another. Drawn only when the host answered that
|
|
2133
|
+
// it can admit what a chip promises - otherwise the warning below states
|
|
2134
|
+
// what a save would drop, which is exactly what this editor did before
|
|
2135
|
+
// blocks travelled at all. A reply never gets chips: what it carries
|
|
2136
|
+
// beside its text cannot survive a replacement.
|
|
2137
|
+
const chips = target.mode === 'prompt' && view.attachmentsOk === true
|
|
2138
|
+
if (chips) {
|
|
2139
|
+
if (view.parts.length > 0) box.appendChild(editorChips(controller, view, t))
|
|
2140
|
+
box.appendChild(attachEntry(controller, view, t))
|
|
2141
|
+
}
|
|
2142
|
+
|
|
1787
2143
|
// Only what the user has to know right now: what the replacement will
|
|
1788
2144
|
// discard, and what went wrong. The running explanation lives in the
|
|
1789
2145
|
// README and in the buttons' tooltips, the way a normal input box does it.
|
|
@@ -1793,7 +2149,9 @@ export default (require) => {
|
|
|
1793
2149
|
note.textContent = t('confirm.body')
|
|
1794
2150
|
box.appendChild(note)
|
|
1795
2151
|
}
|
|
1796
|
-
|
|
2152
|
+
// Shown when saving really would drop something: a reply's tool calls
|
|
2153
|
+
// always, and a prompt's blocks when this host cannot carry them.
|
|
2154
|
+
if (target.attachments > 0 && !chips) {
|
|
1797
2155
|
const warn = document.createElement('p')
|
|
1798
2156
|
warn.className = 'dshet-warn'
|
|
1799
2157
|
warn.textContent = texts.warn
|
|
@@ -2019,6 +2377,10 @@ export default (require) => {
|
|
|
2019
2377
|
messageId: entry.messageId,
|
|
2020
2378
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
2021
2379
|
attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
|
|
2380
|
+
// A reply edits text only on purpose: the tool calls and reasoning it
|
|
2381
|
+
// carries stop being true the moment it is replaced, so they are not
|
|
2382
|
+
// offered as chips (the editor warns about them instead).
|
|
2383
|
+
blocks: [],
|
|
2022
2384
|
})
|
|
2023
2385
|
}
|
|
2024
2386
|
// The config object is always an object: this runtime's `jsx` reads
|