dsh-as-aistudio 0.2.6 → 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 +390 -10
- package/src/components.js +1 -1
- package/src/studio.js +1 -1
- package/src/vendor/dsh-edit-turn.js +390 -10
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': '这个会话当前未激活,请先打开该会话再编辑。',
|
|
@@ -73,12 +83,18 @@ window.__ModuleLoader__.load({
|
|
|
73
83
|
'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
|
|
74
84
|
'error.reply': '已回退,但替换没有落地。请重试。',
|
|
75
85
|
'error.empty': '改写后的内容不能为空。',
|
|
86
|
+
'error.unchanged': '内容没有变化,未做任何修改。',
|
|
76
87
|
'error.rerun-nothing': '已保存,但这一轮没有可重跑的作答。',
|
|
77
88
|
'error.rerun-unavailable': '已保存,但重跑没有开始,请稍后重试。',
|
|
78
89
|
'error.not-rerunnable': '这一轮没有可重跑的作答。',
|
|
79
90
|
'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
|
|
80
91
|
'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
|
|
81
92
|
'error.internal': '服务器内部错误,请稍后重试。',
|
|
93
|
+
'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
|
|
94
|
+
'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
|
|
95
|
+
'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
|
|
96
|
+
'error.attachment-too-large': '这个文件太大,无法作为附件。',
|
|
97
|
+
'error.too-large': '这次修改太大,服务器没有接收。',
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
const en = {
|
|
@@ -99,6 +115,8 @@ window.__ModuleLoader__.load({
|
|
|
99
115
|
'editor.pending': 'Working...',
|
|
100
116
|
'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
|
|
101
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',
|
|
102
120
|
'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
|
|
103
121
|
'error.invalid': 'Invalid request; refresh and try again.',
|
|
104
122
|
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
@@ -113,12 +131,18 @@ window.__ModuleLoader__.load({
|
|
|
113
131
|
'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
|
|
114
132
|
'error.reply': 'Rolled back, but the replacement did not land. Try again.',
|
|
115
133
|
'error.empty': 'The revised message cannot be empty.',
|
|
134
|
+
'error.unchanged': 'Nothing changed, so no edit was made.',
|
|
116
135
|
'error.rerun-nothing': 'Saved, but this turn has no reply that can be re-run.',
|
|
117
136
|
'error.rerun-unavailable': 'Saved, but the re-run did not start; try again.',
|
|
118
137
|
'error.not-rerunnable': 'This turn has no reply that can be re-run.',
|
|
119
138
|
'error.already-retired': 'This turn is no longer part of the current context.',
|
|
120
139
|
'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
|
|
121
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.',
|
|
122
146
|
}
|
|
123
147
|
|
|
124
148
|
// Every error code the host can return travels through here. A code is
|
|
@@ -227,6 +251,25 @@ window.__ModuleLoader__.load({
|
|
|
227
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}',
|
|
228
252
|
'.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
|
|
229
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}',
|
|
230
273
|
'.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
|
|
231
274
|
'.dshet-note{color:var(--dshet-ink-dim)}',
|
|
232
275
|
'.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
|
|
@@ -301,6 +344,16 @@ window.__ModuleLoader__.load({
|
|
|
301
344
|
this.editorRow = null
|
|
302
345
|
this.editorFocus = false
|
|
303
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
|
|
304
357
|
this.clearTimer = null
|
|
305
358
|
this.pendingRerun = false
|
|
306
359
|
this.siblingRerun = 'unknown'
|
|
@@ -317,6 +370,7 @@ window.__ModuleLoader__.load({
|
|
|
317
370
|
repliesByMessage: new Map(),
|
|
318
371
|
editing: null,
|
|
319
372
|
draft: '',
|
|
373
|
+
parts: [],
|
|
320
374
|
confirming: false,
|
|
321
375
|
pending: false,
|
|
322
376
|
failure: null,
|
|
@@ -324,6 +378,12 @@ window.__ModuleLoader__.load({
|
|
|
324
378
|
surfaceReady: false,
|
|
325
379
|
loaded: false,
|
|
326
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,
|
|
327
387
|
// Matches the host's default: one click applies. Only an explicit
|
|
328
388
|
// `confirm: true` in the profile turns the second step back on.
|
|
329
389
|
confirmStep: false,
|
|
@@ -408,6 +468,10 @@ window.__ModuleLoader__.load({
|
|
|
408
468
|
loaded: true,
|
|
409
469
|
loadError: false,
|
|
410
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),
|
|
411
475
|
})
|
|
412
476
|
})
|
|
413
477
|
.catch(() => {
|
|
@@ -428,7 +492,10 @@ window.__ModuleLoader__.load({
|
|
|
428
492
|
const previous = this.editorElement
|
|
429
493
|
this.load()
|
|
430
494
|
this.editor = target
|
|
431
|
-
if (!same)
|
|
495
|
+
if (!same) {
|
|
496
|
+
this.editorDraft = target.text
|
|
497
|
+
this.editorParts = blocksToParts(target, this.sessionId)
|
|
498
|
+
}
|
|
432
499
|
this.confirming = false
|
|
433
500
|
this.editorElement = null
|
|
434
501
|
// The intent to keep the user's caret in this box. It is set when the
|
|
@@ -444,7 +511,7 @@ window.__ModuleLoader__.load({
|
|
|
444
511
|
/* focus is best-effort */
|
|
445
512
|
}
|
|
446
513
|
}
|
|
447
|
-
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 })
|
|
448
515
|
this.probeSiblingRerun()
|
|
449
516
|
}
|
|
450
517
|
|
|
@@ -481,15 +548,81 @@ window.__ModuleLoader__.load({
|
|
|
481
548
|
this.editorFocus = false
|
|
482
549
|
this.editor = null
|
|
483
550
|
this.editorDraft = ''
|
|
551
|
+
this.editorParts = []
|
|
484
552
|
this.confirming = false
|
|
485
553
|
this.editorElement = null
|
|
486
|
-
this.publish({ editing: null, draft: '', confirming: false, failure: null })
|
|
554
|
+
this.publish({ editing: null, draft: '', parts: [], confirming: false, failure: null })
|
|
487
555
|
}
|
|
488
556
|
|
|
489
557
|
setDraft(value) {
|
|
490
558
|
this.editorDraft = value
|
|
491
559
|
}
|
|
492
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
|
+
|
|
493
626
|
review() {
|
|
494
627
|
this.pendingRerun = false
|
|
495
628
|
this.runReview()
|
|
@@ -530,6 +663,13 @@ window.__ModuleLoader__.load({
|
|
|
530
663
|
this.publish({ failure: 'empty' })
|
|
531
664
|
return
|
|
532
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
|
|
533
673
|
this.publish({ pending: true, failure: null })
|
|
534
674
|
try {
|
|
535
675
|
const res = await fetch(`${ROUTE_PREFIX}/apply`, {
|
|
@@ -540,6 +680,7 @@ window.__ModuleLoader__.load({
|
|
|
540
680
|
seq: target.seq,
|
|
541
681
|
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
542
682
|
text,
|
|
683
|
+
...(parts === undefined ? {} : { parts }),
|
|
543
684
|
}),
|
|
544
685
|
})
|
|
545
686
|
const data = await res.json().catch(() => ({}))
|
|
@@ -570,6 +711,11 @@ window.__ModuleLoader__.load({
|
|
|
570
711
|
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
571
712
|
text,
|
|
572
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 : [],
|
|
573
719
|
})
|
|
574
720
|
}
|
|
575
721
|
// A reply edit shadows its own reply too, so it leaves the editable
|
|
@@ -582,6 +728,7 @@ window.__ModuleLoader__.load({
|
|
|
582
728
|
this.animateOnce = true
|
|
583
729
|
this.editor = null
|
|
584
730
|
this.editorDraft = ''
|
|
731
|
+
this.editorParts = []
|
|
585
732
|
this.editorElement = null
|
|
586
733
|
this.editorBox = null
|
|
587
734
|
this.editorRow = null
|
|
@@ -595,12 +742,34 @@ window.__ModuleLoader__.load({
|
|
|
595
742
|
revisions,
|
|
596
743
|
editable,
|
|
597
744
|
replies,
|
|
745
|
+
parts: [],
|
|
598
746
|
failure: null,
|
|
599
747
|
})
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
748
|
+
// `unchanged` means the host wrote NOTHING: the draft is (verbatim)
|
|
749
|
+
// the text the message already carries. There is no failure to report -
|
|
750
|
+
// the edit was simply not an edit - so it is noticed as such, and
|
|
751
|
+
// nothing else about this pass changes: no row was shadowed, so none is
|
|
752
|
+
// hidden, and the message stays on screen with the pencil it had.
|
|
753
|
+
//
|
|
754
|
+
// The re-run intent survives it. "Re-run" saves first only because the
|
|
755
|
+
// sibling regenerates the turn from the prompt the surface shows; a
|
|
756
|
+
// user who pressed it wants that turn regenerated whether or not the
|
|
757
|
+
// wording moved, so the chain below is NOT gated on a write having
|
|
758
|
+
// happened. (The decision itself is the host's - the text is parsed
|
|
759
|
+
// there, once - and this half only reads the flag it sends back.)
|
|
760
|
+
if (data.unchanged === true) {
|
|
761
|
+
if (!rerunIntent) this.notify('unchanged')
|
|
762
|
+
} else {
|
|
763
|
+
// The rollback has landed, so the editor is gone with its row: a
|
|
764
|
+
// failed second half has to be reported somewhere that survives that.
|
|
765
|
+
if (data.kind === 'reply' && data.applied === false) this.notify('reply')
|
|
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')
|
|
772
|
+
}
|
|
604
773
|
// Save first, re-run second - always: the sibling re-runs the turn
|
|
605
774
|
// from the prompt the surface NOW shows, so the revised wording has
|
|
606
775
|
// to be on the surface before it is asked to regenerate. Not
|
|
@@ -707,6 +876,10 @@ window.__ModuleLoader__.load({
|
|
|
707
876
|
messageId: entry.messageId,
|
|
708
877
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
709
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 : [],
|
|
710
883
|
}
|
|
711
884
|
}
|
|
712
885
|
return null
|
|
@@ -1668,6 +1841,94 @@ window.__ModuleLoader__.load({
|
|
|
1668
1841
|
// injecting a second controlled tree into it would fight its reconciler.
|
|
1669
1842
|
// The draft is mirrored into the controller on every input event, so a
|
|
1670
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
|
+
|
|
1671
1932
|
// The re-render marker for the in-place editor.
|
|
1672
1933
|
//
|
|
1673
1934
|
// Every field `renderEditor` reads belongs here. A marker that only tracks
|
|
@@ -1684,6 +1945,13 @@ window.__ModuleLoader__.load({
|
|
|
1684
1945
|
// The re-run button appears when the sibling probe lands; without it in
|
|
1685
1946
|
// the marker the editor would never be rebuilt to show the button.
|
|
1686
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('.'),
|
|
1687
1955
|
].join('|')
|
|
1688
1956
|
}
|
|
1689
1957
|
|
|
@@ -1702,6 +1970,100 @@ window.__ModuleLoader__.load({
|
|
|
1702
1970
|
}
|
|
1703
1971
|
}
|
|
1704
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
|
+
|
|
1705
2067
|
function renderEditor(row, controller, view, t) {
|
|
1706
2068
|
const target = view.editing
|
|
1707
2069
|
const texts = editorTexts(t, target)
|
|
@@ -1774,6 +2136,18 @@ window.__ModuleLoader__.load({
|
|
|
1774
2136
|
})
|
|
1775
2137
|
box.appendChild(area)
|
|
1776
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
|
+
|
|
1777
2151
|
// Only what the user has to know right now: what the replacement will
|
|
1778
2152
|
// discard, and what went wrong. The running explanation lives in the
|
|
1779
2153
|
// README and in the buttons' tooltips, the way a normal input box does it.
|
|
@@ -1783,7 +2157,9 @@ window.__ModuleLoader__.load({
|
|
|
1783
2157
|
note.textContent = t('confirm.body')
|
|
1784
2158
|
box.appendChild(note)
|
|
1785
2159
|
}
|
|
1786
|
-
|
|
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) {
|
|
1787
2163
|
const warn = document.createElement('p')
|
|
1788
2164
|
warn.className = 'dshet-warn'
|
|
1789
2165
|
warn.textContent = texts.warn
|
|
@@ -2009,6 +2385,10 @@ window.__ModuleLoader__.load({
|
|
|
2009
2385
|
messageId: entry.messageId,
|
|
2010
2386
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
2011
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: [],
|
|
2012
2392
|
})
|
|
2013
2393
|
}
|
|
2014
2394
|
// The config object is always an object: this runtime's `jsx` reads
|
|
@@ -5085,7 +5465,7 @@ window.__ModuleLoader__.load({
|
|
|
5085
5465
|
const NS = 'dsh-as-aistudio'
|
|
5086
5466
|
|
|
5087
5467
|
/** Keep in sync with package.json and src/components.js. */
|
|
5088
|
-
const PLUGIN_VERSION = '0.2.
|
|
5468
|
+
const PLUGIN_VERSION = '0.2.8'
|
|
5089
5469
|
|
|
5090
5470
|
/** Presence marker the live verifier reads. */
|
|
5091
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': '这个会话当前未激活,请先打开该会话再编辑。',
|
|
@@ -65,12 +75,18 @@ export default (require) => {
|
|
|
65
75
|
'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
|
|
66
76
|
'error.reply': '已回退,但替换没有落地。请重试。',
|
|
67
77
|
'error.empty': '改写后的内容不能为空。',
|
|
78
|
+
'error.unchanged': '内容没有变化,未做任何修改。',
|
|
68
79
|
'error.rerun-nothing': '已保存,但这一轮没有可重跑的作答。',
|
|
69
80
|
'error.rerun-unavailable': '已保存,但重跑没有开始,请稍后重试。',
|
|
70
81
|
'error.not-rerunnable': '这一轮没有可重跑的作答。',
|
|
71
82
|
'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
|
|
72
83
|
'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
|
|
73
84
|
'error.internal': '服务器内部错误,请稍后重试。',
|
|
85
|
+
'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
|
|
86
|
+
'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
|
|
87
|
+
'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
|
|
88
|
+
'error.attachment-too-large': '这个文件太大,无法作为附件。',
|
|
89
|
+
'error.too-large': '这次修改太大,服务器没有接收。',
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
const en = {
|
|
@@ -91,6 +107,8 @@ export default (require) => {
|
|
|
91
107
|
'editor.pending': 'Working...',
|
|
92
108
|
'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
|
|
93
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',
|
|
94
112
|
'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
|
|
95
113
|
'error.invalid': 'Invalid request; refresh and try again.',
|
|
96
114
|
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
@@ -105,12 +123,18 @@ export default (require) => {
|
|
|
105
123
|
'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
|
|
106
124
|
'error.reply': 'Rolled back, but the replacement did not land. Try again.',
|
|
107
125
|
'error.empty': 'The revised message cannot be empty.',
|
|
126
|
+
'error.unchanged': 'Nothing changed, so no edit was made.',
|
|
108
127
|
'error.rerun-nothing': 'Saved, but this turn has no reply that can be re-run.',
|
|
109
128
|
'error.rerun-unavailable': 'Saved, but the re-run did not start; try again.',
|
|
110
129
|
'error.not-rerunnable': 'This turn has no reply that can be re-run.',
|
|
111
130
|
'error.already-retired': 'This turn is no longer part of the current context.',
|
|
112
131
|
'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
|
|
113
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.',
|
|
114
138
|
}
|
|
115
139
|
|
|
116
140
|
// Every error code the host can return travels through here. A code is
|
|
@@ -219,6 +243,25 @@ export default (require) => {
|
|
|
219
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}',
|
|
220
244
|
'.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
|
|
221
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}',
|
|
222
265
|
'.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
|
|
223
266
|
'.dshet-note{color:var(--dshet-ink-dim)}',
|
|
224
267
|
'.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
|
|
@@ -293,6 +336,16 @@ export default (require) => {
|
|
|
293
336
|
this.editorRow = null
|
|
294
337
|
this.editorFocus = false
|
|
295
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
|
|
296
349
|
this.clearTimer = null
|
|
297
350
|
this.pendingRerun = false
|
|
298
351
|
this.siblingRerun = 'unknown'
|
|
@@ -309,6 +362,7 @@ export default (require) => {
|
|
|
309
362
|
repliesByMessage: new Map(),
|
|
310
363
|
editing: null,
|
|
311
364
|
draft: '',
|
|
365
|
+
parts: [],
|
|
312
366
|
confirming: false,
|
|
313
367
|
pending: false,
|
|
314
368
|
failure: null,
|
|
@@ -316,6 +370,12 @@ export default (require) => {
|
|
|
316
370
|
surfaceReady: false,
|
|
317
371
|
loaded: false,
|
|
318
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,
|
|
319
379
|
// Matches the host's default: one click applies. Only an explicit
|
|
320
380
|
// `confirm: true` in the profile turns the second step back on.
|
|
321
381
|
confirmStep: false,
|
|
@@ -400,6 +460,10 @@ export default (require) => {
|
|
|
400
460
|
loaded: true,
|
|
401
461
|
loadError: false,
|
|
402
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),
|
|
403
467
|
})
|
|
404
468
|
})
|
|
405
469
|
.catch(() => {
|
|
@@ -420,7 +484,10 @@ export default (require) => {
|
|
|
420
484
|
const previous = this.editorElement
|
|
421
485
|
this.load()
|
|
422
486
|
this.editor = target
|
|
423
|
-
if (!same)
|
|
487
|
+
if (!same) {
|
|
488
|
+
this.editorDraft = target.text
|
|
489
|
+
this.editorParts = blocksToParts(target, this.sessionId)
|
|
490
|
+
}
|
|
424
491
|
this.confirming = false
|
|
425
492
|
this.editorElement = null
|
|
426
493
|
// The intent to keep the user's caret in this box. It is set when the
|
|
@@ -436,7 +503,7 @@ export default (require) => {
|
|
|
436
503
|
/* focus is best-effort */
|
|
437
504
|
}
|
|
438
505
|
}
|
|
439
|
-
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 })
|
|
440
507
|
this.probeSiblingRerun()
|
|
441
508
|
}
|
|
442
509
|
|
|
@@ -473,15 +540,81 @@ export default (require) => {
|
|
|
473
540
|
this.editorFocus = false
|
|
474
541
|
this.editor = null
|
|
475
542
|
this.editorDraft = ''
|
|
543
|
+
this.editorParts = []
|
|
476
544
|
this.confirming = false
|
|
477
545
|
this.editorElement = null
|
|
478
|
-
this.publish({ editing: null, draft: '', confirming: false, failure: null })
|
|
546
|
+
this.publish({ editing: null, draft: '', parts: [], confirming: false, failure: null })
|
|
479
547
|
}
|
|
480
548
|
|
|
481
549
|
setDraft(value) {
|
|
482
550
|
this.editorDraft = value
|
|
483
551
|
}
|
|
484
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
|
+
|
|
485
618
|
review() {
|
|
486
619
|
this.pendingRerun = false
|
|
487
620
|
this.runReview()
|
|
@@ -522,6 +655,13 @@ export default (require) => {
|
|
|
522
655
|
this.publish({ failure: 'empty' })
|
|
523
656
|
return
|
|
524
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
|
|
525
665
|
this.publish({ pending: true, failure: null })
|
|
526
666
|
try {
|
|
527
667
|
const res = await fetch(`${ROUTE_PREFIX}/apply`, {
|
|
@@ -532,6 +672,7 @@ export default (require) => {
|
|
|
532
672
|
seq: target.seq,
|
|
533
673
|
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
534
674
|
text,
|
|
675
|
+
...(parts === undefined ? {} : { parts }),
|
|
535
676
|
}),
|
|
536
677
|
})
|
|
537
678
|
const data = await res.json().catch(() => ({}))
|
|
@@ -562,6 +703,11 @@ export default (require) => {
|
|
|
562
703
|
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
563
704
|
text,
|
|
564
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 : [],
|
|
565
711
|
})
|
|
566
712
|
}
|
|
567
713
|
// A reply edit shadows its own reply too, so it leaves the editable
|
|
@@ -574,6 +720,7 @@ export default (require) => {
|
|
|
574
720
|
this.animateOnce = true
|
|
575
721
|
this.editor = null
|
|
576
722
|
this.editorDraft = ''
|
|
723
|
+
this.editorParts = []
|
|
577
724
|
this.editorElement = null
|
|
578
725
|
this.editorBox = null
|
|
579
726
|
this.editorRow = null
|
|
@@ -587,12 +734,34 @@ export default (require) => {
|
|
|
587
734
|
revisions,
|
|
588
735
|
editable,
|
|
589
736
|
replies,
|
|
737
|
+
parts: [],
|
|
590
738
|
failure: null,
|
|
591
739
|
})
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
|
|
740
|
+
// `unchanged` means the host wrote NOTHING: the draft is (verbatim)
|
|
741
|
+
// the text the message already carries. There is no failure to report -
|
|
742
|
+
// the edit was simply not an edit - so it is noticed as such, and
|
|
743
|
+
// nothing else about this pass changes: no row was shadowed, so none is
|
|
744
|
+
// hidden, and the message stays on screen with the pencil it had.
|
|
745
|
+
//
|
|
746
|
+
// The re-run intent survives it. "Re-run" saves first only because the
|
|
747
|
+
// sibling regenerates the turn from the prompt the surface shows; a
|
|
748
|
+
// user who pressed it wants that turn regenerated whether or not the
|
|
749
|
+
// wording moved, so the chain below is NOT gated on a write having
|
|
750
|
+
// happened. (The decision itself is the host's - the text is parsed
|
|
751
|
+
// there, once - and this half only reads the flag it sends back.)
|
|
752
|
+
if (data.unchanged === true) {
|
|
753
|
+
if (!rerunIntent) this.notify('unchanged')
|
|
754
|
+
} else {
|
|
755
|
+
// The rollback has landed, so the editor is gone with its row: a
|
|
756
|
+
// failed second half has to be reported somewhere that survives that.
|
|
757
|
+
if (data.kind === 'reply' && data.applied === false) this.notify('reply')
|
|
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')
|
|
764
|
+
}
|
|
596
765
|
// Save first, re-run second - always: the sibling re-runs the turn
|
|
597
766
|
// from the prompt the surface NOW shows, so the revised wording has
|
|
598
767
|
// to be on the surface before it is asked to regenerate. Not
|
|
@@ -699,6 +868,10 @@ export default (require) => {
|
|
|
699
868
|
messageId: entry.messageId,
|
|
700
869
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
701
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 : [],
|
|
702
875
|
}
|
|
703
876
|
}
|
|
704
877
|
return null
|
|
@@ -1660,6 +1833,94 @@ export default (require) => {
|
|
|
1660
1833
|
// injecting a second controlled tree into it would fight its reconciler.
|
|
1661
1834
|
// The draft is mirrored into the controller on every input event, so a
|
|
1662
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
|
+
|
|
1663
1924
|
// The re-render marker for the in-place editor.
|
|
1664
1925
|
//
|
|
1665
1926
|
// Every field `renderEditor` reads belongs here. A marker that only tracks
|
|
@@ -1676,6 +1937,13 @@ export default (require) => {
|
|
|
1676
1937
|
// The re-run button appears when the sibling probe lands; without it in
|
|
1677
1938
|
// the marker the editor would never be rebuilt to show the button.
|
|
1678
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('.'),
|
|
1679
1947
|
].join('|')
|
|
1680
1948
|
}
|
|
1681
1949
|
|
|
@@ -1694,6 +1962,100 @@ export default (require) => {
|
|
|
1694
1962
|
}
|
|
1695
1963
|
}
|
|
1696
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
|
+
|
|
1697
2059
|
function renderEditor(row, controller, view, t) {
|
|
1698
2060
|
const target = view.editing
|
|
1699
2061
|
const texts = editorTexts(t, target)
|
|
@@ -1766,6 +2128,18 @@ export default (require) => {
|
|
|
1766
2128
|
})
|
|
1767
2129
|
box.appendChild(area)
|
|
1768
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
|
+
|
|
1769
2143
|
// Only what the user has to know right now: what the replacement will
|
|
1770
2144
|
// discard, and what went wrong. The running explanation lives in the
|
|
1771
2145
|
// README and in the buttons' tooltips, the way a normal input box does it.
|
|
@@ -1775,7 +2149,9 @@ export default (require) => {
|
|
|
1775
2149
|
note.textContent = t('confirm.body')
|
|
1776
2150
|
box.appendChild(note)
|
|
1777
2151
|
}
|
|
1778
|
-
|
|
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) {
|
|
1779
2155
|
const warn = document.createElement('p')
|
|
1780
2156
|
warn.className = 'dshet-warn'
|
|
1781
2157
|
warn.textContent = texts.warn
|
|
@@ -2001,6 +2377,10 @@ export default (require) => {
|
|
|
2001
2377
|
messageId: entry.messageId,
|
|
2002
2378
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
2003
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: [],
|
|
2004
2384
|
})
|
|
2005
2385
|
}
|
|
2006
2386
|
// The config object is always an object: this runtime's `jsx` reads
|