dsh-edit-turn 0.2.18 → 0.2.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -13
- package/lib/client.js +367 -5
- package/lib/index.js +631 -24
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -19,6 +19,17 @@
|
|
|
19
19
|
// pair can be left dangling.
|
|
20
20
|
//
|
|
21
21
|
//
|
|
22
|
+
// A revised PROMPT carries the message itself: the carrier's content is the
|
|
23
|
+
// user's block list after the edit, rebuilt block by block. A block the user
|
|
24
|
+
// did not touch is copied out of the original message verbatim - same object,
|
|
25
|
+
// same fields, same attachment reference - a block the user added is admitted
|
|
26
|
+
// through the platform's attachment store (`ctx.get('attachments')`, the seam
|
|
27
|
+
// dsh-acp uses) and a block the user removed is simply not written. The revised
|
|
28
|
+
// text lands where the original's first text block was, so the layout survives
|
|
29
|
+
// the edit. Nothing in that path names a block shape: the only test in the
|
|
30
|
+
// module is "does this block carry text", which is the editor's own field, so a
|
|
31
|
+
// block type the platform adds later needs no change here.
|
|
32
|
+
//
|
|
22
33
|
// The silent replacement carrier is an EMPTY `developer/message` (0.2.12;
|
|
23
34
|
// before that an empty `system/message`, which no reader accepts - see the
|
|
24
35
|
// README). Empty content projects to no model message, so the rollback adds
|
|
@@ -37,6 +48,7 @@
|
|
|
37
48
|
// degrades to "no config form, defaults apply" instead, which costs a settings
|
|
38
49
|
// surface rather than the feature. Every field is optional in `apply` anyway.
|
|
39
50
|
import { randomUUID } from 'node:crypto'
|
|
51
|
+
import { readFile } from 'node:fs/promises'
|
|
40
52
|
import { createRequire } from 'node:module'
|
|
41
53
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
42
54
|
|
|
@@ -57,7 +69,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
|
|
|
57
69
|
export const name = PLUGIN_ID
|
|
58
70
|
|
|
59
71
|
/** Keep in sync with package.json and lib/client.js. */
|
|
60
|
-
export const PLUGIN_VERSION = '0.2.
|
|
72
|
+
export const PLUGIN_VERSION = '0.2.19'
|
|
61
73
|
|
|
62
74
|
// A ring of the last few requests, answering "did the browser half even ask, and
|
|
63
75
|
// what did it get?" without needing its console. Read-only diagnostics: the
|
|
@@ -81,6 +93,30 @@ const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{
|
|
|
81
93
|
const MAX_TEXT = 200_000
|
|
82
94
|
const FLUSH_DEADLINE_MS = 5_000
|
|
83
95
|
|
|
96
|
+
// A revised block list travels as base64 inside the JSON body, so the body limit
|
|
97
|
+
// IS the upload limit. The platform's own per-image ceiling defaults to 20 MiB
|
|
98
|
+
// (dsh-attachment-local), which base64 carries in ~27 MiB of text; the store
|
|
99
|
+
// enforces its own limit independently, this one only bounds the socket read.
|
|
100
|
+
const MAX_BODY_BYTES = 64 * 1024 * 1024
|
|
101
|
+
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
|
102
|
+
// A preview is a courtesy: above this the chip keeps its label and asks for
|
|
103
|
+
// nothing. Never worth streaming a whole file to draw a thumbnail.
|
|
104
|
+
const MAX_PREVIEW_BYTES = 32 * 1024 * 1024
|
|
105
|
+
// One message cannot carry more blocks than this. The cap exists so a malformed
|
|
106
|
+
// or hostile body cannot make the host loop over an unbounded list.
|
|
107
|
+
const MAX_PARTS = 64
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Service names the durable attachment store is reachable under.
|
|
111
|
+
*
|
|
112
|
+
* `attachments` is the platform's own registration (the `AttachmentStore`
|
|
113
|
+
* base class in @deepseek-ai/dsh-attachment, and every `ctx.get('attachments')`
|
|
114
|
+
* consumer such as dsh-acp); the other two are the package names, accepted so a
|
|
115
|
+
* rename cannot silently cost the feature. Absence is a supported state, never
|
|
116
|
+
* an error: the edit degrades to the text-only rewrite it was before blocks.
|
|
117
|
+
*/
|
|
118
|
+
const ATTACHMENT_SERVICES = ['attachments', 'attachment', 'attachment-local']
|
|
119
|
+
|
|
84
120
|
/**
|
|
85
121
|
* Model-visible text of the replacement carrier.
|
|
86
122
|
*
|
|
@@ -295,30 +331,287 @@ export function isAssistantReply(event) {
|
|
|
295
331
|
return Boolean(event) && event.type === 'assistant/message'
|
|
296
332
|
}
|
|
297
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Whether one content block carries the editable text.
|
|
336
|
+
*
|
|
337
|
+
* This is the module's ONLY block-shape test, and it is deliberately about the
|
|
338
|
+
* field the editor owns rather than about a block class. Every other block - a
|
|
339
|
+
* picture, a file, and whatever type a later platform adds - is carried through
|
|
340
|
+
* an edit untouched unless the user removed it, so no code below ever has to
|
|
341
|
+
* learn a new name.
|
|
342
|
+
*
|
|
343
|
+
* @param block - one content block.
|
|
344
|
+
* @returns true when the block holds text the editor prefills.
|
|
345
|
+
*/
|
|
346
|
+
export function isTextBlock(block) {
|
|
347
|
+
return Boolean(block) && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string'
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The content blocks of a message payload, in message order.
|
|
352
|
+
*
|
|
353
|
+
* `user/message` carries its message fields on `data`; the other three surface
|
|
354
|
+
* types nest them under `data.message`, so both shapes read the same way now
|
|
355
|
+
* that the model's replies are editable too.
|
|
356
|
+
*
|
|
357
|
+
* @param event - raw session event.
|
|
358
|
+
* @returns the block array, or an empty one when the message carries none.
|
|
359
|
+
*/
|
|
360
|
+
export function readMessageBlocks(event) {
|
|
361
|
+
const data = (event && event.data) || {}
|
|
362
|
+
const message = data.message && typeof data.message === 'object' ? data.message : data
|
|
363
|
+
return Array.isArray(message.content) ? message.content : []
|
|
364
|
+
}
|
|
365
|
+
|
|
298
366
|
/**
|
|
299
367
|
* Read the plain text of a message payload, plus how many non-text parts it
|
|
300
|
-
* carries.
|
|
301
|
-
*
|
|
368
|
+
* carries. The count is what tells a reader that a text-only carrier would drop
|
|
369
|
+
* something, and it stays exactly what it always was.
|
|
302
370
|
* @param event - raw session event.
|
|
303
371
|
* @returns `{ text, attachments }`.
|
|
304
372
|
*/
|
|
305
373
|
export function readMessageText(event) {
|
|
306
|
-
const data = (event && event.data) || {}
|
|
307
|
-
// `user/message` carries its message fields on `data`; the other three surface
|
|
308
|
-
// types nest them under `data.message`. Both shapes have to read the same way
|
|
309
|
-
// now that the model's replies are editable too.
|
|
310
|
-
const message = data.message && typeof data.message === 'object' ? data.message : data
|
|
311
|
-
const blocks = Array.isArray(message.content) ? message.content : []
|
|
312
374
|
let text = ''
|
|
313
375
|
let attachments = 0
|
|
314
|
-
for (const block of
|
|
376
|
+
for (const block of readMessageBlocks(event)) {
|
|
315
377
|
if (!block || typeof block !== 'object') continue
|
|
316
|
-
if (block
|
|
378
|
+
if (isTextBlock(block)) text += (text === '' ? '' : '\n') + block.text
|
|
317
379
|
else attachments += 1
|
|
318
380
|
}
|
|
319
381
|
return { text, attachments }
|
|
320
382
|
}
|
|
321
383
|
|
|
384
|
+
/**
|
|
385
|
+
* The non-text blocks of a message, described for the editor's chips.
|
|
386
|
+
*
|
|
387
|
+
* Read by FIELD PRESENCE, never by type. A block that carries a durable
|
|
388
|
+
* `attachment` reference is described from that reference (name, media type,
|
|
389
|
+
* byte length, pixel size) whatever the platform calls the block; a block
|
|
390
|
+
* without one is described by its own type name alone, which is what a chip can
|
|
391
|
+
* honestly show. This list is for DRAWING and for offering a delete - it never
|
|
392
|
+
* decides what an edit writes, and a block the user keeps is copied verbatim
|
|
393
|
+
* whether or not this function could describe it.
|
|
394
|
+
*
|
|
395
|
+
* @param blocks - the message content, in message order.
|
|
396
|
+
* @param preview - whether the deployment can hand these bytes back for a thumbnail.
|
|
397
|
+
* @returns one entry per non-text block, in message order.
|
|
398
|
+
*/
|
|
399
|
+
export function blockSummaries(blocks, preview = false) {
|
|
400
|
+
const out = []
|
|
401
|
+
for (let index = 0; index < blocks.length; index += 1) {
|
|
402
|
+
const block = blocks[index]
|
|
403
|
+
if (!block || typeof block !== 'object' || isTextBlock(block)) continue
|
|
404
|
+
const raw = block.attachment
|
|
405
|
+
const attachment = raw && typeof raw === 'object' ? raw : null
|
|
406
|
+
const summary = {
|
|
407
|
+
index,
|
|
408
|
+
type: typeof block.type === 'string' && block.type !== '' ? block.type : 'block',
|
|
409
|
+
preview: false,
|
|
410
|
+
}
|
|
411
|
+
if (attachment !== null) {
|
|
412
|
+
if (typeof attachment.name === 'string' && attachment.name !== '') summary.name = attachment.name
|
|
413
|
+
if (typeof attachment.mediaType === 'string' && attachment.mediaType !== '') summary.mediaType = attachment.mediaType
|
|
414
|
+
if (typeof attachment.bytes === 'number') summary.bytes = attachment.bytes
|
|
415
|
+
if (typeof attachment.width === 'number') summary.width = attachment.width
|
|
416
|
+
if (typeof attachment.height === 'number') summary.height = attachment.height
|
|
417
|
+
summary.preview = preview && typeof attachment.attachmentId === 'string' && attachment.attachmentId !== ''
|
|
418
|
+
}
|
|
419
|
+
out.push(summary)
|
|
420
|
+
}
|
|
421
|
+
return out
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Whether two JSON values are the same value.
|
|
426
|
+
*
|
|
427
|
+
* Used to answer "did this edit change anything at all": the rebuilt block list
|
|
428
|
+
* is compared against the original one, field by field. Key ORDER is not part
|
|
429
|
+
* of a JSON value - a session reader is free to hand the same object back with
|
|
430
|
+
* its keys in another order - so this walks the keys instead of comparing
|
|
431
|
+
* stringified text.
|
|
432
|
+
*
|
|
433
|
+
* @param a - first JSON value.
|
|
434
|
+
* @param b - second JSON value.
|
|
435
|
+
* @returns true when the two are indistinguishable JSON.
|
|
436
|
+
*/
|
|
437
|
+
export function sameJson(a, b) {
|
|
438
|
+
if (a === b) return true
|
|
439
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
440
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
|
441
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
442
|
+
if (!sameJson(a[index], b[index])) return false
|
|
443
|
+
}
|
|
444
|
+
return true
|
|
445
|
+
}
|
|
446
|
+
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
|
|
447
|
+
const keys = Object.keys(a)
|
|
448
|
+
if (keys.length !== Object.keys(b).length) return false
|
|
449
|
+
for (const key of keys) {
|
|
450
|
+
if (!Object.prototype.hasOwnProperty.call(b, key)) return false
|
|
451
|
+
if (!sameJson(a[key], b[key])) return false
|
|
452
|
+
}
|
|
453
|
+
return true
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Normalise one submitted block list.
|
|
458
|
+
*
|
|
459
|
+
* The wire vocabulary is exactly two entries:
|
|
460
|
+
*
|
|
461
|
+
* `{ keep: <index> }` - the original block at that index, untouched;
|
|
462
|
+
* `{ add: { data, mediaType?, name? } }` - bytes the user just picked.
|
|
463
|
+
*
|
|
464
|
+
* Text is never listed: the revised text rides in its own field and the host
|
|
465
|
+
* puts it back where the original's first text block was. A `keep` that names
|
|
466
|
+
* a text block is therefore not an error - it is the text, and the text is
|
|
467
|
+
* already accounted for.
|
|
468
|
+
*
|
|
469
|
+
* @param raw - the submitted `parts` array.
|
|
470
|
+
* @param blockCount - how many blocks the original message carries.
|
|
471
|
+
* @returns `[{ keep } | { add }]` in submitted order.
|
|
472
|
+
* @throws {HttpError} 400 `invalid` for anything this vocabulary does not name.
|
|
473
|
+
*/
|
|
474
|
+
export function normalizeParts(raw, blockCount) {
|
|
475
|
+
if (!Array.isArray(raw)) throw new HttpError(400, 'invalid', 'parts must be an array')
|
|
476
|
+
if (raw.length > MAX_PARTS) throw new HttpError(400, 'invalid', `a message cannot carry more than ${MAX_PARTS} blocks`)
|
|
477
|
+
const parts = []
|
|
478
|
+
for (const entry of raw) {
|
|
479
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
480
|
+
throw new HttpError(400, 'invalid', 'every part must be an object')
|
|
481
|
+
}
|
|
482
|
+
if (entry.keep !== undefined) {
|
|
483
|
+
const index = entry.keep
|
|
484
|
+
if (!Number.isInteger(index) || index < 0 || index >= blockCount) {
|
|
485
|
+
throw new HttpError(400, 'invalid', 'a kept block index is out of range')
|
|
486
|
+
}
|
|
487
|
+
if (parts.some((part) => part.keep === index)) throw new HttpError(400, 'invalid', 'a block was kept twice')
|
|
488
|
+
parts.push({ keep: index })
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
const upload = entry.add
|
|
492
|
+
if (upload && typeof upload === 'object' && typeof upload.data === 'string') {
|
|
493
|
+
parts.push({
|
|
494
|
+
add: {
|
|
495
|
+
data: upload.data,
|
|
496
|
+
...(typeof upload.mediaType === 'string' && upload.mediaType !== '' ? { mediaType: upload.mediaType } : {}),
|
|
497
|
+
...(typeof upload.name === 'string' && upload.name !== '' ? { name: upload.name } : {}),
|
|
498
|
+
},
|
|
499
|
+
})
|
|
500
|
+
continue
|
|
501
|
+
}
|
|
502
|
+
throw new HttpError(400, 'invalid', 'every part is either a kept block or an upload')
|
|
503
|
+
}
|
|
504
|
+
return parts
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The ordered slots a submitted block list describes, plus where the text goes.
|
|
509
|
+
*
|
|
510
|
+
* Layout is the original message's, never a re-invention: the revised text is
|
|
511
|
+
* placed where the original's FIRST text block stood (the editor holds every
|
|
512
|
+
* text block at once, so the others merge into it), and the kept blocks keep
|
|
513
|
+
* their relative order around it. A message with no text block at all - a
|
|
514
|
+
* picture-only prompt - gets its text appended, which is the only position that
|
|
515
|
+
* does not move something the user did not touch.
|
|
516
|
+
*
|
|
517
|
+
* @param originalBlocks - the message content before the edit.
|
|
518
|
+
* @param parts - normalised parts (see {@link normalizeParts}).
|
|
519
|
+
* @returns `{ slots, textAt, added }`; `textAt` indexes `slots`.
|
|
520
|
+
*/
|
|
521
|
+
export function planBlockLayout(originalBlocks, parts) {
|
|
522
|
+
const slots = []
|
|
523
|
+
let added = 0
|
|
524
|
+
for (const part of parts) {
|
|
525
|
+
if (part.keep !== undefined) {
|
|
526
|
+
// The text blocks are the editor's own content, carried by `text`.
|
|
527
|
+
if (isTextBlock(originalBlocks[part.keep])) continue
|
|
528
|
+
slots.push({ kind: 'original', index: part.keep })
|
|
529
|
+
continue
|
|
530
|
+
}
|
|
531
|
+
slots.push({ kind: 'add', upload: part.add })
|
|
532
|
+
added += 1
|
|
533
|
+
}
|
|
534
|
+
let firstText = -1
|
|
535
|
+
for (let index = 0; index < originalBlocks.length; index += 1) {
|
|
536
|
+
if (isTextBlock(originalBlocks[index])) {
|
|
537
|
+
firstText = index
|
|
538
|
+
break
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
let textAt = slots.length
|
|
542
|
+
if (firstText >= 0) {
|
|
543
|
+
// The text stands before the first kept block that came after it in the
|
|
544
|
+
// original message. When no kept block did - everything after it was
|
|
545
|
+
// removed, or it was the last block of all - it keeps its own place at the
|
|
546
|
+
// head of what is left, ahead of the blocks that used to stand before it.
|
|
547
|
+
let anchored = false
|
|
548
|
+
for (let index = 0; index < slots.length; index += 1) {
|
|
549
|
+
const slot = slots[index]
|
|
550
|
+
if (slot.kind === 'original' && slot.index > firstText) {
|
|
551
|
+
textAt = index
|
|
552
|
+
anchored = true
|
|
553
|
+
break
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (!anchored) {
|
|
557
|
+
let before = 0
|
|
558
|
+
for (const slot of slots) {
|
|
559
|
+
if (slot.kind === 'original' && slot.index < firstText) before += 1
|
|
560
|
+
}
|
|
561
|
+
textAt = before
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return { slots, textAt, added }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Build the carrier content from a layout.
|
|
569
|
+
*
|
|
570
|
+
* A kept block is copied out of the original VERBATIM - a structured clone of
|
|
571
|
+
* the very object the message carries, so an attachment reference, a name, a
|
|
572
|
+
* dimension and every field a later platform adds cross the edit untouched and
|
|
573
|
+
* byte-identical. Nothing here inspects what the block is.
|
|
574
|
+
*
|
|
575
|
+
* @param originalBlocks - the message content before the edit.
|
|
576
|
+
* @param layout - the layout from {@link planBlockLayout}.
|
|
577
|
+
* @param admitted - blocks for the layout's `add` slots, in slot order.
|
|
578
|
+
* @param text - the revised text.
|
|
579
|
+
* @returns the block array the carrier is written with.
|
|
580
|
+
*/
|
|
581
|
+
export function assembleBlocks(originalBlocks, layout, admitted, text) {
|
|
582
|
+
const content = []
|
|
583
|
+
let next = 0
|
|
584
|
+
for (let index = 0; index <= layout.slots.length; index += 1) {
|
|
585
|
+
if (index === layout.textAt) content.push({ type: 'text', text })
|
|
586
|
+
const slot = layout.slots[index]
|
|
587
|
+
if (slot === undefined) continue
|
|
588
|
+
if (slot.kind === 'original') content.push(copyBlock(originalBlocks[slot.index]))
|
|
589
|
+
else content.push(admitted[next++])
|
|
590
|
+
}
|
|
591
|
+
return content
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* A verbatim copy of one block.
|
|
596
|
+
*
|
|
597
|
+
* `structuredClone` is what makes "untouched" literally true rather than
|
|
598
|
+
* nearly true: the carrier is serialized into the session log, so an identical
|
|
599
|
+
* copy and the same reference produce identical bytes - the clone only removes
|
|
600
|
+
* the chance that a later writer mutates the original through it.
|
|
601
|
+
*
|
|
602
|
+
* @param block - one content block.
|
|
603
|
+
* @returns an independent copy of the same JSON value.
|
|
604
|
+
*/
|
|
605
|
+
function copyBlock(block) {
|
|
606
|
+
try {
|
|
607
|
+
return structuredClone(block)
|
|
608
|
+
} catch {
|
|
609
|
+
// Not clonable (an exotic in-memory block): the block itself is still the
|
|
610
|
+
// verbatim value, which is what this function promises.
|
|
611
|
+
return block
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
322
615
|
/**
|
|
323
616
|
* Rebuild this plugin's rollback ledger from the log alone.
|
|
324
617
|
*
|
|
@@ -462,6 +755,9 @@ export function planRollback(events, surfaceNodes, request) {
|
|
|
462
755
|
throw new EditPlanError('not-editable', 'the target does not open a rollback window')
|
|
463
756
|
}
|
|
464
757
|
const { text, attachments } = readMessageText(target)
|
|
758
|
+
// The blocks themselves travel with the plan: the carrier is rebuilt from
|
|
759
|
+
// them, so no caller has to re-read the message to know what it carries.
|
|
760
|
+
const blocks = readMessageBlocks(target)
|
|
465
761
|
const turn = turnOf.get(target.seq)
|
|
466
762
|
const data = target.data && typeof target.data === 'object' ? target.data : {}
|
|
467
763
|
const message = data.message && typeof data.message === 'object' ? data.message : data
|
|
@@ -481,6 +777,7 @@ export function planRollback(events, surfaceNodes, request) {
|
|
|
481
777
|
model: source && typeof source.model === 'string' ? source.model : null,
|
|
482
778
|
original: text,
|
|
483
779
|
attachments,
|
|
780
|
+
blocks,
|
|
484
781
|
}
|
|
485
782
|
}
|
|
486
783
|
|
|
@@ -493,9 +790,11 @@ export function planRollback(events, surfaceNodes, request) {
|
|
|
493
790
|
*
|
|
494
791
|
* @param events - complete contiguous raw event log.
|
|
495
792
|
* @param surfaceNodes - current surface seqs in model order.
|
|
793
|
+
* @param options - `{ preview }`: whether the deployment can hand attachment
|
|
794
|
+
* bytes back, which decides if a chip may offer a thumbnail.
|
|
496
795
|
* @returns one entry per editable prompt, in conversation order.
|
|
497
796
|
*/
|
|
498
|
-
export function editableTurns(events, surfaceNodes) {
|
|
797
|
+
export function editableTurns(events, surfaceNodes, options = {}) {
|
|
499
798
|
const eventBySeq = new Map(events.map((event) => [event.seq, event]))
|
|
500
799
|
const turnOf = turnIndex(events)
|
|
501
800
|
const head = surfaceNodes[0]
|
|
@@ -516,6 +815,9 @@ export function editableTurns(events, surfaceNodes) {
|
|
|
516
815
|
messageId: messageIdOf(event) ?? null,
|
|
517
816
|
text,
|
|
518
817
|
attachments,
|
|
818
|
+
// What the editor draws beside the text: one chip per non-text block,
|
|
819
|
+
// described by field presence only (see blockSummaries).
|
|
820
|
+
blocks: blockSummaries(readMessageBlocks(event), options.preview === true),
|
|
519
821
|
})
|
|
520
822
|
}
|
|
521
823
|
return out
|
|
@@ -727,6 +1029,166 @@ async function flushSession(ctx, session) {
|
|
|
727
1029
|
return { flushed: false, flushError: errors.join(' | ') }
|
|
728
1030
|
}
|
|
729
1031
|
|
|
1032
|
+
// --- attachment admission ----------------------------------------------------
|
|
1033
|
+
//
|
|
1034
|
+
// The platform's durable store is the only path by which bytes become a
|
|
1035
|
+
// reference a session may cite (@deepseek-ai/dsh-attachment's `AttachmentStore`;
|
|
1036
|
+
// the local backend is @deepseek-ai/dsh-attachment-local). It is resolved at
|
|
1037
|
+
// REQUEST time, never assumed: a deployment that mounts none must keep working
|
|
1038
|
+
// exactly as this plugin did before blocks travelled at all - text only, with
|
|
1039
|
+
// the editor saying so - and one that mounts a store later starts working with
|
|
1040
|
+
// no restart.
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* The durable attachment store this deployment mounts, when it has one.
|
|
1044
|
+
*
|
|
1045
|
+
* Candidate names are probed the way the platform's own consumers reach it
|
|
1046
|
+
* (`ctx.get('attachments')`, e.g. dsh-acp). An object is only accepted when it
|
|
1047
|
+
* can actually admit something: a service that merely shares the name must not
|
|
1048
|
+
* make the editor promise to keep a block the host cannot store.
|
|
1049
|
+
*
|
|
1050
|
+
* @param ctx - plugin context.
|
|
1051
|
+
* @returns the store, or null when the deployment mounts none.
|
|
1052
|
+
*/
|
|
1053
|
+
export function resolveAttachmentStore(ctx) {
|
|
1054
|
+
if (!ctx || typeof ctx.get !== 'function') return null
|
|
1055
|
+
for (const name of ATTACHMENT_SERVICES) {
|
|
1056
|
+
let store
|
|
1057
|
+
try {
|
|
1058
|
+
store = ctx.get(name)
|
|
1059
|
+
} catch {
|
|
1060
|
+
store = null
|
|
1061
|
+
}
|
|
1062
|
+
if (!store || typeof store !== 'object') continue
|
|
1063
|
+
if (typeof store.saveImage === 'function' || typeof store.saveFile === 'function') return store
|
|
1064
|
+
}
|
|
1065
|
+
return null
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Whether this deployment can hand attachment bytes back for a chip thumbnail.
|
|
1070
|
+
* @param store - the resolved store, or null.
|
|
1071
|
+
* @returns true when a read path exists.
|
|
1072
|
+
*/
|
|
1073
|
+
export function canPreviewAttachments(store) {
|
|
1074
|
+
return Boolean(store) && (typeof store.readImage === 'function' || typeof store.fileHostPath === 'function')
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Decode one upload exactly the way the platform's wire admission does.
|
|
1079
|
+
*
|
|
1080
|
+
* Canonical base64 only (the platform's `admission.js` test): a payload whose
|
|
1081
|
+
* re-encoding differs is refused rather than silently decoded into different
|
|
1082
|
+
* bytes than the browser meant to send.
|
|
1083
|
+
*
|
|
1084
|
+
* @param data - base64 text.
|
|
1085
|
+
* @returns the decoded bytes.
|
|
1086
|
+
* @throws {HttpError} 400 `invalid` for anything but bounded canonical base64.
|
|
1087
|
+
*/
|
|
1088
|
+
function decodeUpload(data) {
|
|
1089
|
+
if (typeof data !== 'string' || data === '') throw new HttpError(400, 'invalid', 'an upload carried no data')
|
|
1090
|
+
if (data.length > MAX_UPLOAD_BYTES * 2) throw new HttpError(400, 'invalid', 'an upload is too large')
|
|
1091
|
+
const decoded = Buffer.from(data, 'base64')
|
|
1092
|
+
if (decoded.toString('base64') !== data) throw new HttpError(400, 'invalid', 'an upload is not canonical base64')
|
|
1093
|
+
if (decoded.byteLength > MAX_UPLOAD_BYTES) throw new HttpError(400, 'invalid', 'an upload is too large')
|
|
1094
|
+
return new Uint8Array(decoded)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Admit one uploaded payload and hand back the block that cites it.
|
|
1099
|
+
*
|
|
1100
|
+
* The STORE decides the shape, not this plugin: bytes it accepts as an image
|
|
1101
|
+
* become the platform's image block, anything else is stored verbatim as a file
|
|
1102
|
+
* block. That is the platform's own vocabulary (`AttachmentStore.saveImage` /
|
|
1103
|
+
* `saveFile`, and `admitPromptContent` builds the same two shapes), and it is
|
|
1104
|
+
* the only place a block type is named in the whole edit path - a block the
|
|
1105
|
+
* user did not touch never passes through here at all.
|
|
1106
|
+
*
|
|
1107
|
+
* @param store - the resolved attachment store.
|
|
1108
|
+
* @param upload - `{ data, mediaType?, name? }`.
|
|
1109
|
+
* @returns the content block citing the durable reference.
|
|
1110
|
+
* @throws {HttpError} 400 `attachment-refused` when the store refuses it.
|
|
1111
|
+
*/
|
|
1112
|
+
export async function admitUpload(store, upload) {
|
|
1113
|
+
const data = decodeUpload(upload.data)
|
|
1114
|
+
const name = typeof upload.name === 'string' && upload.name !== '' ? upload.name : undefined
|
|
1115
|
+
const mediaType = typeof upload.mediaType === 'string' && upload.mediaType !== '' ? upload.mediaType : undefined
|
|
1116
|
+
// The store's own advertised media types decide whether it can take this as
|
|
1117
|
+
// an image; without that list, the declared type family is the only signal.
|
|
1118
|
+
const imageTypes = store.imageLimits && Array.isArray(store.imageLimits.mediaTypes) ? store.imageLimits.mediaTypes : null
|
|
1119
|
+
const asImage = mediaType !== undefined && typeof store.saveImage === 'function' &&
|
|
1120
|
+
(imageTypes === null ? mediaType.startsWith('image/') : imageTypes.includes(mediaType))
|
|
1121
|
+
try {
|
|
1122
|
+
if (asImage) {
|
|
1123
|
+
const ref = await store.saveImage(name === undefined ? { data, mediaType } : { data, mediaType, name })
|
|
1124
|
+
return { type: 'image', attachment: ref }
|
|
1125
|
+
}
|
|
1126
|
+
if (typeof store.saveFile !== 'function') {
|
|
1127
|
+
throw new HttpError(400, 'attachment-refused', 'this deployment can only store images')
|
|
1128
|
+
}
|
|
1129
|
+
const ref = await store.saveFile(name === undefined ? { data } : { data, name })
|
|
1130
|
+
return { type: 'file', attachment: ref }
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
if (error instanceof HttpError) throw error
|
|
1133
|
+
// The store's own refusal (too many images, wrong bytes, an unwritable
|
|
1134
|
+
// root) is reported as-is and NOTHING is written: admission runs before the
|
|
1135
|
+
// first append, so a refused upload can never leave a half-built carrier.
|
|
1136
|
+
const detail = String((error && error.message) || error)
|
|
1137
|
+
throw new HttpError(400, 'attachment-refused', detail)
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* The bytes of one block's attachment, for a chip's thumbnail.
|
|
1143
|
+
*
|
|
1144
|
+
* A display-only read, deliberately kept out of the edit path. It answers from
|
|
1145
|
+
* whatever read seam the mounted store offers - an image reader, or the host
|
|
1146
|
+
* path of a verbatim file - and gives up quietly when neither exists, because a
|
|
1147
|
+
* chip without a picture is still a usable chip. The block is located by the
|
|
1148
|
+
* seq of the message and its index inside that message's content, and the only
|
|
1149
|
+
* authorization is that the session's own log really does cite it: the same bar
|
|
1150
|
+
* the platform's attachment RPC applies before handing bytes over.
|
|
1151
|
+
*
|
|
1152
|
+
* @param ctx - plugin context.
|
|
1153
|
+
* @param sessionId - the session whose log owns the block.
|
|
1154
|
+
* @param seq - seq of the message event.
|
|
1155
|
+
* @param index - the block's index in that message's content.
|
|
1156
|
+
* @returns `{ data, mediaType }`, or null when there is nothing to serve.
|
|
1157
|
+
*/
|
|
1158
|
+
export async function attachmentBytes(ctx, sessionId, seq, index) {
|
|
1159
|
+
const events = await readEvents(ctx, sessionId)
|
|
1160
|
+
if (!events) return null
|
|
1161
|
+
const event = events.find((item) => item && item.seq === seq)
|
|
1162
|
+
if (!event) return null
|
|
1163
|
+
const block = readMessageBlocks(event)[index]
|
|
1164
|
+
if (!block || typeof block !== 'object') return null
|
|
1165
|
+
const ref = block.attachment
|
|
1166
|
+
if (!ref || typeof ref !== 'object' || typeof ref.attachmentId !== 'string' || ref.attachmentId === '') return null
|
|
1167
|
+
if (typeof ref.bytes === 'number' && ref.bytes > MAX_PREVIEW_BYTES) return null
|
|
1168
|
+
const store = resolveAttachmentStore(ctx)
|
|
1169
|
+
if (store === null) return null
|
|
1170
|
+
if (typeof store.readImage === 'function') {
|
|
1171
|
+
try {
|
|
1172
|
+
const stored = await store.readImage(ref)
|
|
1173
|
+
if (stored && stored.data) {
|
|
1174
|
+
const mediaType = stored.ref && typeof stored.ref.mediaType === 'string' ? stored.ref.mediaType : ref.mediaType
|
|
1175
|
+
return { data: Buffer.from(stored.data), mediaType: typeof mediaType === 'string' ? mediaType : undefined }
|
|
1176
|
+
}
|
|
1177
|
+
} catch {
|
|
1178
|
+
// Not an image reference; a stored file is read below.
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
if (typeof store.fileHostPath === 'function') {
|
|
1182
|
+
try {
|
|
1183
|
+
const path = store.fileHostPath(ref)
|
|
1184
|
+
if (typeof path === 'string' && path !== '') return { data: await readFile(path), mediaType: undefined }
|
|
1185
|
+
} catch {
|
|
1186
|
+
// The reference resolves to nothing this deployment can serve.
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return null
|
|
1190
|
+
}
|
|
1191
|
+
|
|
730
1192
|
// --- operations --------------------------------------------------------------
|
|
731
1193
|
|
|
732
1194
|
async function stateOf(ctx, sessionId, config) {
|
|
@@ -738,7 +1200,11 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
738
1200
|
const folded = foldSurface(events)
|
|
739
1201
|
const ledger = rollbackLedger(events)
|
|
740
1202
|
const live = findLiveSession(ctx, sessionId)
|
|
741
|
-
|
|
1203
|
+
// Probed per request: the editor offers what THIS deployment can do right
|
|
1204
|
+
// now, so a store that is unmounted (or not yet mounted) cannot leave the
|
|
1205
|
+
// editor promising to keep blocks the host would have to drop.
|
|
1206
|
+
const store = resolveAttachmentStore(ctx)
|
|
1207
|
+
const turns = editableTurns(events, folded.nodes, { preview: canPreviewAttachments(store) })
|
|
742
1208
|
const replies = editableReplies(events, folded.nodes)
|
|
743
1209
|
noteRequest({
|
|
744
1210
|
kind: 'state',
|
|
@@ -764,6 +1230,11 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
764
1230
|
live: Boolean(live),
|
|
765
1231
|
busy: isBusy(events),
|
|
766
1232
|
lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
|
|
1233
|
+
// What this deployment can do with a block list. `attachments` is the
|
|
1234
|
+
// admission path (a chip the editor may keep) and `preview` is the read
|
|
1235
|
+
// path (a chip that may draw its own thumbnail); both are answered from a
|
|
1236
|
+
// live service probe, so the editor never promises what the host cannot do.
|
|
1237
|
+
capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
|
|
767
1238
|
config: { confirm: config.confirm },
|
|
768
1239
|
version: PLUGIN_VERSION,
|
|
769
1240
|
}
|
|
@@ -775,9 +1246,13 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
775
1246
|
* @param plan - the planned window.
|
|
776
1247
|
* @param lastTurn - highest turn number in the log, for a coherent bracket.
|
|
777
1248
|
* @param config - resolved deployment policy.
|
|
1249
|
+
* @param content - the block list the revised prompt carries. Omitted, the
|
|
1250
|
+
* carrier falls back to the marker text alone, which is what every caller
|
|
1251
|
+
* that predates block-preserving edits (and the silent developer carrier)
|
|
1252
|
+
* still asks for.
|
|
778
1253
|
* @returns `{ type, data }` for `session.append`.
|
|
779
1254
|
*/
|
|
780
|
-
export function buildCarrier(plan, lastTurn, config) {
|
|
1255
|
+
export function buildCarrier(plan, lastTurn, config, content) {
|
|
781
1256
|
const silent = config.carrier !== 'user/message'
|
|
782
1257
|
if (silent) {
|
|
783
1258
|
// The silent carrier is an empty message that projects to no model message.
|
|
@@ -807,7 +1282,7 @@ export function buildCarrier(plan, lastTurn, config) {
|
|
|
807
1282
|
data: {
|
|
808
1283
|
id: randomUUID(),
|
|
809
1284
|
role: 'user',
|
|
810
|
-
content: [{ type: 'text', text: config.markerText }],
|
|
1285
|
+
content: Array.isArray(content) && content.length > 0 ? content : [{ type: 'text', text: config.markerText }],
|
|
811
1286
|
// `kind: 'user'`, NOT a plugin-owned kind. This carrier stands in a human's
|
|
812
1287
|
// place - it IS the wording the user is asking with now - and the platform
|
|
813
1288
|
// owns that judgement: a human prompt is exactly a `user/message` whose
|
|
@@ -898,7 +1373,25 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
898
1373
|
if (!surfaceNodes.includes(seq)) throw new HttpError(409, 'stale', 'the session changed, retry')
|
|
899
1374
|
}
|
|
900
1375
|
|
|
901
|
-
//
|
|
1376
|
+
// The submitted block list, when the browser half sent one: which of the
|
|
1377
|
+
// message's blocks the user kept, in order, plus anything they just picked.
|
|
1378
|
+
// A caller that sends `text` only is a client from before blocks travelled -
|
|
1379
|
+
// a supported caller, not a broken one: it gets exactly the text-only carrier
|
|
1380
|
+
// this plugin has always written, and the answer says what that cost.
|
|
1381
|
+
let parts = plan.mode === 'prompt' && Array.isArray(body.parts) ? normalizeParts(body.parts, plan.blocks.length) : null
|
|
1382
|
+
let layout = parts === null ? null : planBlockLayout(plan.blocks, parts)
|
|
1383
|
+
// What this deployment can admit, probed NOW rather than remembered: with no
|
|
1384
|
+
// store mounted, nothing can be uploaded and no block may be promised, so the
|
|
1385
|
+
// edit degrades to the text-only carrier - the exact behaviour every release
|
|
1386
|
+
// before this one had - and the answer reports the blocks it had to leave
|
|
1387
|
+
// behind instead of writing a reference nothing can resolve.
|
|
1388
|
+
const store = plan.mode === 'prompt' ? resolveAttachmentStore(ctx) : null
|
|
1389
|
+
if (layout !== null && store === null) {
|
|
1390
|
+
parts = null
|
|
1391
|
+
layout = null
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Nothing to write when the revision changes nothing.
|
|
902
1395
|
//
|
|
903
1396
|
// The editor opens prefilled with exactly this text - it comes from
|
|
904
1397
|
// `readMessageText`, the same parse behind `/state`'s `turns[].text` and
|
|
@@ -908,6 +1401,14 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
908
1401
|
// unchanged save used to add a revision with no revision behind it and a whole
|
|
909
1402
|
// turn to the rail while the conversation had not moved at all.
|
|
910
1403
|
//
|
|
1404
|
+
// Since blocks travel, the same question is asked of the whole message: the
|
|
1405
|
+
// carrier is rebuilt from the submitted list and compared against the original
|
|
1406
|
+
// block for block (see sameJson), so a save that keeps every block where it was
|
|
1407
|
+
// writes nothing either. A caller that sends text only keeps the original,
|
|
1408
|
+
// narrower test - its carrier IS text-only by construction, and comparing that
|
|
1409
|
+
// against a message with a picture in it would turn "I changed nothing" into a
|
|
1410
|
+
// write that drops the picture.
|
|
1411
|
+
//
|
|
911
1412
|
// Compared verbatim, deliberately, which is the same caliber the editor was
|
|
912
1413
|
// filled with: a trailing space is a real difference and keeps the old
|
|
913
1414
|
// behaviour, and nothing here trims (the only trim in this function is the
|
|
@@ -917,7 +1418,9 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
917
1418
|
// first and re-runs regardless, and its whole point is that the user wants the
|
|
918
1419
|
// turn regenerated even when the wording is what it already was. So the caller
|
|
919
1420
|
// decides what happens next, and this returns before anything is appended.
|
|
920
|
-
|
|
1421
|
+
const keepsEveryBlock = parts !== null && layout !== null && layout.added === 0 &&
|
|
1422
|
+
sameJson(assembleBlocks(plan.blocks, layout, [], text), plan.blocks)
|
|
1423
|
+
if (text === plan.original && (parts === null || keepsEveryBlock)) {
|
|
921
1424
|
return {
|
|
922
1425
|
kind: plan.mode,
|
|
923
1426
|
applied: false,
|
|
@@ -926,9 +1429,30 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
926
1429
|
// hide a row on account of this answer.
|
|
927
1430
|
shadowed: [],
|
|
928
1431
|
original: plan.original,
|
|
1432
|
+
// Nothing was written, so nothing was left behind either - and the
|
|
1433
|
+
// message's blocks are the ones it already had.
|
|
1434
|
+
dropped: false,
|
|
1435
|
+
blocks: blockSummaries(plan.blocks, canPreviewAttachments(store)),
|
|
929
1436
|
}
|
|
930
1437
|
}
|
|
931
1438
|
|
|
1439
|
+
// Admission happens here, BEFORE the first append and outside the try blocks
|
|
1440
|
+
// below: a refused upload must not be able to leave a rollback (or a synthetic
|
|
1441
|
+
// turn) behind in the log. The blocks come back in slot order, which is the
|
|
1442
|
+
// order the layout put them in.
|
|
1443
|
+
const admitted = []
|
|
1444
|
+
if (layout !== null) {
|
|
1445
|
+
for (const slot of layout.slots) {
|
|
1446
|
+
if (slot.kind === 'add') admitted.push(await admitUpload(store, slot.upload))
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
const content = layout === null ? [{ type: 'text', text }] : assembleBlocks(plan.blocks, layout, admitted, text)
|
|
1450
|
+
// Whether this save left non-text blocks behind, told to the caller instead of
|
|
1451
|
+
// being discovered later: true for a reply (its tool calls and reasoning
|
|
1452
|
+
// cannot survive a replacement) and true for a message rewritten without its
|
|
1453
|
+
// blocks. False whenever the carrier really did carry them.
|
|
1454
|
+
const dropped = layout === null && plan.blocks.some((block) => !isTextBlock(block))
|
|
1455
|
+
|
|
932
1456
|
// A revised prompt has to (a) replace the old wording in the model context and
|
|
933
1457
|
// (b) NOT be answered. The platform answers any user message it finds on the
|
|
934
1458
|
// surface, so the revision cannot be *appended* - appending one made the loop
|
|
@@ -941,7 +1465,7 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
941
1465
|
// (it projects `lastTurn` from every turn/start it observes). A prompt edit
|
|
942
1466
|
// does not: `user/message` carries the revision and needs no turn.
|
|
943
1467
|
const editTurn = plan.mode === 'reply' ? nextTurnOf(events) : lastTurnOf(events)
|
|
944
|
-
const carrier = buildCarrier(plan, editTurn, plan.mode === 'prompt' ? { ...config, carrier: 'user/message', markerText: text } : config)
|
|
1468
|
+
const carrier = buildCarrier(plan, editTurn, plan.mode === 'prompt' ? { ...config, carrier: 'user/message', markerText: text } : config, content)
|
|
945
1469
|
if (plan.mode === 'reply') {
|
|
946
1470
|
try {
|
|
947
1471
|
session.append('turn/start', { turn: editTurn })
|
|
@@ -952,6 +1476,7 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
952
1476
|
applied: false,
|
|
953
1477
|
applyError: `could not open a turn for the correction: ${String((error && error.message) || error)}`,
|
|
954
1478
|
shadowed: plan.shadowed,
|
|
1479
|
+
dropped,
|
|
955
1480
|
}
|
|
956
1481
|
}
|
|
957
1482
|
}
|
|
@@ -998,6 +1523,7 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
998
1523
|
applied: false,
|
|
999
1524
|
applyError: `the corrected reply was refused: ${String((error && error.message) || error)}`,
|
|
1000
1525
|
shadowed: plan.shadowed,
|
|
1526
|
+
dropped,
|
|
1001
1527
|
...flush,
|
|
1002
1528
|
}
|
|
1003
1529
|
}
|
|
@@ -1022,6 +1548,7 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1022
1548
|
applied: true,
|
|
1023
1549
|
loopTurn,
|
|
1024
1550
|
shadowed: plan.shadowed,
|
|
1551
|
+
dropped,
|
|
1025
1552
|
...corrected,
|
|
1026
1553
|
}
|
|
1027
1554
|
}
|
|
@@ -1037,6 +1564,12 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1037
1564
|
replacementSeq: replacement.seq,
|
|
1038
1565
|
applied: true,
|
|
1039
1566
|
shadowed: plan.shadowed,
|
|
1567
|
+
dropped,
|
|
1568
|
+
// What the carrier now carries, described exactly the way /state describes
|
|
1569
|
+
// it. The browser half adopts this for the row it re-draws before its own
|
|
1570
|
+
// refresh lands - without it a second save could not know which blocks are
|
|
1571
|
+
// still there and would rewrite the message text-only.
|
|
1572
|
+
blocks: blockSummaries(content, canPreviewAttachments(store)),
|
|
1040
1573
|
...flush,
|
|
1041
1574
|
}
|
|
1042
1575
|
}
|
|
@@ -1143,16 +1676,32 @@ function sendJson(res, status, body) {
|
|
|
1143
1676
|
res.end(payload)
|
|
1144
1677
|
}
|
|
1145
1678
|
|
|
1146
|
-
|
|
1679
|
+
// The body limit is the upload limit: a revised message with blocks arrives as
|
|
1680
|
+
// base64 inside this JSON, so /apply asks for a larger ceiling than the tiny
|
|
1681
|
+
// default. The read is refused rather than truncated, and the socket keeps
|
|
1682
|
+
// draining so the refusal can still be answered.
|
|
1683
|
+
function readBody(req, limit = 1e6) {
|
|
1147
1684
|
return new Promise((resolve, reject) => {
|
|
1148
1685
|
let data = ''
|
|
1686
|
+
let refused = false
|
|
1149
1687
|
req.on('data', (chunk) => {
|
|
1688
|
+
if (refused) return
|
|
1150
1689
|
data += chunk
|
|
1151
|
-
if (data.length >
|
|
1690
|
+
if (data.length > limit) {
|
|
1691
|
+
refused = true
|
|
1692
|
+
data = ''
|
|
1693
|
+
reject(new HttpError(413, 'invalid', 'the request body is too large'))
|
|
1694
|
+
}
|
|
1695
|
+
})
|
|
1696
|
+
req.on('end', () => {
|
|
1697
|
+
if (!refused) resolve(data)
|
|
1698
|
+
})
|
|
1699
|
+
req.on('error', (error) => {
|
|
1700
|
+
if (!refused) reject(error)
|
|
1701
|
+
})
|
|
1702
|
+
req.on('aborted', () => {
|
|
1703
|
+
if (!refused) reject(new Error('aborted'))
|
|
1152
1704
|
})
|
|
1153
|
-
req.on('end', () => resolve(data))
|
|
1154
|
-
req.on('error', reject)
|
|
1155
|
-
req.on('aborted', () => reject(new Error('aborted')))
|
|
1156
1705
|
})
|
|
1157
1706
|
}
|
|
1158
1707
|
|
|
@@ -1238,6 +1787,49 @@ export function apply(ctx, config) {
|
|
|
1238
1787
|
}),
|
|
1239
1788
|
)
|
|
1240
1789
|
|
|
1790
|
+
// The bytes behind one block, for the editor's chips. Read-only, loopback
|
|
1791
|
+
// and same-origin like everything else here, and answered only for a block
|
|
1792
|
+
// the session's own log cites.
|
|
1793
|
+
fiber.effect(() =>
|
|
1794
|
+
webServer.register({
|
|
1795
|
+
kind: 'exact',
|
|
1796
|
+
path: `${ROUTE_PREFIX}/attachment`,
|
|
1797
|
+
handler: async (req, res) => {
|
|
1798
|
+
if (!guard(req, res)) return
|
|
1799
|
+
if (req.method !== 'GET') {
|
|
1800
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
|
|
1801
|
+
return
|
|
1802
|
+
}
|
|
1803
|
+
try {
|
|
1804
|
+
const url = new URL(req.url, 'http://localhost')
|
|
1805
|
+
const sessionId = requireSessionId((url.searchParams.get('sessionId') || '').trim())
|
|
1806
|
+
const seq = Number(url.searchParams.get('seq'))
|
|
1807
|
+
const index = Number(url.searchParams.get('index'))
|
|
1808
|
+
if (!Number.isInteger(seq) || seq < 0 || !Number.isInteger(index) || index < 0) {
|
|
1809
|
+
throw new HttpError(400, 'invalid', 'seq and index must be whole numbers')
|
|
1810
|
+
}
|
|
1811
|
+
const found = await attachmentBytes(ctx, sessionId, seq, index)
|
|
1812
|
+
if (found === null) {
|
|
1813
|
+
// Deliberately without a code: the editor translates codes into
|
|
1814
|
+
// sentences, and "this chip has no picture" is not one the user
|
|
1815
|
+
// needs to read - the chip simply draws its label.
|
|
1816
|
+
sendJson(res, 404, { ok: false, error: 'no attachment bytes for this block' })
|
|
1817
|
+
return
|
|
1818
|
+
}
|
|
1819
|
+
res.writeHead(200, {
|
|
1820
|
+
'content-type': typeof found.mediaType === 'string' && found.mediaType !== '' ? found.mediaType : 'application/octet-stream',
|
|
1821
|
+
'content-length': found.data.byteLength,
|
|
1822
|
+
// The stored object is immutable and content-addressed.
|
|
1823
|
+
'cache-control': 'private, max-age=600',
|
|
1824
|
+
})
|
|
1825
|
+
res.end(found.data)
|
|
1826
|
+
} catch (error) {
|
|
1827
|
+
failure(res, error)
|
|
1828
|
+
}
|
|
1829
|
+
},
|
|
1830
|
+
}),
|
|
1831
|
+
)
|
|
1832
|
+
|
|
1241
1833
|
// Read-only diagnostics. The browser half's own state cannot be observed
|
|
1242
1834
|
// from here, so the next best thing is an honest record of what its requests
|
|
1243
1835
|
// asked for and what they got back.
|
|
@@ -1266,9 +1858,20 @@ export function apply(ctx, config) {
|
|
|
1266
1858
|
sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
|
|
1267
1859
|
return
|
|
1268
1860
|
}
|
|
1861
|
+
let raw
|
|
1862
|
+
try {
|
|
1863
|
+
// A block list travels as base64 inside this body, so the ceiling is
|
|
1864
|
+
// the upload ceiling (see MAX_BODY_BYTES) - and an oversized body is
|
|
1865
|
+
// reported as such instead of being read as far as it goes.
|
|
1866
|
+
raw = await readBody(req, MAX_BODY_BYTES)
|
|
1867
|
+
} catch (error) {
|
|
1868
|
+
const status = error instanceof HttpError ? error.status : 400
|
|
1869
|
+
const detail = error instanceof HttpError ? error.message : 'malformed JSON body'
|
|
1870
|
+
sendJson(res, status, { ok: false, code: 'invalid', error: detail })
|
|
1871
|
+
return
|
|
1872
|
+
}
|
|
1269
1873
|
let body = {}
|
|
1270
1874
|
try {
|
|
1271
|
-
const raw = await readBody(req)
|
|
1272
1875
|
if (raw) body = JSON.parse(raw)
|
|
1273
1876
|
} catch {
|
|
1274
1877
|
sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
|
|
@@ -1292,6 +1895,10 @@ export function apply(ctx, config) {
|
|
|
1292
1895
|
// shows nothing for it, so this ring is the only place that records
|
|
1293
1896
|
// "the user pressed save and nothing needed saving".
|
|
1294
1897
|
unchanged: result.unchanged === true,
|
|
1898
|
+
// Whether non-text blocks were left behind by this save (see
|
|
1899
|
+
// applyEdit). The log cannot show this: the carrier is one event
|
|
1900
|
+
// either way.
|
|
1901
|
+
dropped: result.dropped === true,
|
|
1295
1902
|
})
|
|
1296
1903
|
sendJson(res, 200, { ok: true, ...result })
|
|
1297
1904
|
} catch (error) {
|