dsh-plugin-read-image-preview 1.4.1

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/client.js ADDED
@@ -0,0 +1,1162 @@
1
+ /**
2
+ * Browser half of the generic image tool-preview plugin.
3
+ *
4
+ * One card claims the keyed presentation cell of every tool that puts a picture
5
+ * in front of the model, at priority -1 (shadowing shipped rows and any dynamic
6
+ * fallback cards). Two data sources feed the same interaction suite (thumbnail,
7
+ * pixel/byte/format line, click-to-open zoom with Ctrl+wheel, drag pan,
8
+ * double-click fit):
9
+ *
10
+ * - `file` mode — the call takes a `file_path` (the read family). Bytes come
11
+ * from the host half over `GET /read-image-preview/…`, and the card previews
12
+ * before the call settles. This is the original read_image behaviour.
13
+ * - `attachment` mode — the settled result content carries `{type:'image',
14
+ * attachment}` blocks (computer-use screenshots today; any future image
15
+ * tool). Bytes come from the session-authorized `props.loadImage`, every
16
+ * stat rides the attachment reference, and no extra request is made.
17
+ *
18
+ * Adding a new image-bearing tool is one key in `TOOL_LABELS` — nothing else.
19
+ *
20
+ * v1.4.1: both modes carry the picture's pixel size before the bytes arrive
21
+ * (resolve reply / attachment ref), so the img element gets width+height and
22
+ * the metadata bar its width up front. A late-loading picture then lands with
23
+ * zero layout delta — the fix for the chat's stick-to-bottom logic reading an
24
+ * image-driven reflow as a reader scroll and dropping auto-follow.
25
+ *
26
+ * @module dsh-plugin-read-image-preview/client
27
+ */
28
+
29
+ window.__ModuleLoader__.load({
30
+ id: 'dsh-plugin-read-image-preview',
31
+ factory: (require) => {
32
+ const module = { exports: {} }
33
+ const exports = module.exports
34
+ const React = require('react')
35
+
36
+ const HOST_ROUTE = '/read-image-preview/resolve'
37
+
38
+ const CSS = `
39
+ .dsh-imgvw-root {
40
+ display: flex;
41
+ flex-direction: column;
42
+ align-items: center;
43
+ gap: 6px;
44
+ min-width: 0;
45
+ }
46
+ .dsh-imgvw-root ~ [class*="_root"] {
47
+ display: none !important;
48
+ }
49
+ .dsh-imgvw-frame {
50
+ position: relative;
51
+ display: flex;
52
+ align-items: center;
53
+ justify-content: center;
54
+ width: fit-content;
55
+ max-width: 100%;
56
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.22));
57
+ border-radius: 10px;
58
+ overflow: hidden;
59
+ background: var(--dsw-alias-bg-base, rgba(128, 128, 128, 0.05));
60
+ line-height: 0;
61
+ cursor: zoom-in;
62
+ }
63
+ .dsh-imgvw-img {
64
+ display: block;
65
+ width: auto;
66
+ height: auto;
67
+ max-width: min(420px, 100%);
68
+ max-height: 300px;
69
+ }
70
+ .dsh-imgvw-img[data-checker='true'] {
71
+ background-image:
72
+ linear-gradient(45deg, rgba(128, 128, 128, 0.18) 25%, transparent 25%),
73
+ linear-gradient(-45deg, rgba(128, 128, 128, 0.18) 25%, transparent 25%),
74
+ linear-gradient(45deg, transparent 75%, rgba(128, 128, 128, 0.18) 75%),
75
+ linear-gradient(-45deg, transparent 75%, rgba(128, 128, 128, 0.18) 75%);
76
+ background-size: 16px 16px;
77
+ background-position: 0 0, 0 8px, 8px -8px, -8px 0;
78
+ }
79
+ .dsh-imgvw-img:hover {
80
+ filter: brightness(1.04);
81
+ }
82
+ .dsh-imgvw-zoom {
83
+ position: absolute;
84
+ top: 6px;
85
+ right: 6px;
86
+ width: 20px;
87
+ height: 20px;
88
+ display: flex;
89
+ align-items: center;
90
+ justify-content: center;
91
+ border-radius: 6px;
92
+ background: rgba(20, 20, 20, 0.55);
93
+ color: #fff;
94
+ font-size: 12px;
95
+ line-height: 1;
96
+ opacity: 0;
97
+ pointer-events: none;
98
+ transition: opacity 0.15s ease;
99
+ }
100
+ .dsh-imgvw-frame:hover .dsh-imgvw-zoom {
101
+ opacity: 1;
102
+ }
103
+ .dsh-imgvw-skel {
104
+ position: relative;
105
+ width: 100%;
106
+ min-width: 260px;
107
+ max-width: 420px;
108
+ min-height: 140px;
109
+ overflow: hidden;
110
+ background: var(--dsw-alias-bg-base, rgba(128, 128, 128, 0.07));
111
+ }
112
+ .dsh-imgvw-skel::after {
113
+ content: '';
114
+ position: absolute;
115
+ inset: 0;
116
+ background: linear-gradient(
117
+ 100deg,
118
+ transparent 20%,
119
+ var(--dsw-alias-bg-hover, rgba(128, 128, 128, 0.16)) 50%,
120
+ transparent 80%
121
+ );
122
+ transform: translateX(-100%);
123
+ animation: dsh-imgvw-sweep 1.15s ease-in-out infinite;
124
+ }
125
+ .dsh-imgvw-skel-label {
126
+ position: absolute;
127
+ left: 12px;
128
+ bottom: 10px;
129
+ z-index: 1;
130
+ font-size: 11px;
131
+ line-height: 1.4;
132
+ color: var(--dsw-alias-label-tertiary, rgba(128, 128, 128, 0.9));
133
+ }
134
+ .dsh-imgvw-skel-label[data-bad='true'] {
135
+ color: var(--dsw-alias-label-error, #d1495b);
136
+ }
137
+ @keyframes dsh-imgvw-sweep {
138
+ to { transform: translateX(100%); }
139
+ }
140
+ @media (prefers-reduced-motion: reduce) {
141
+ .dsh-imgvw-skel::after { animation: none; }
142
+ }
143
+ .dsh-imgvw-bar {
144
+ display: flex;
145
+ align-items: center;
146
+ justify-content: center;
147
+ flex-wrap: wrap;
148
+ gap: 6px;
149
+ max-width: 100%;
150
+ min-width: 0;
151
+ font-size: 11px;
152
+ line-height: 1.5;
153
+ color: var(--dsw-alias-label-tertiary, rgba(128, 128, 128, 0.85));
154
+ }
155
+ .dsh-imgvw-name {
156
+ max-width: 240px;
157
+ overflow: hidden;
158
+ text-overflow: ellipsis;
159
+ white-space: nowrap;
160
+ font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
161
+ color: var(--dsw-alias-label-secondary, rgba(128, 128, 128, 0.95));
162
+ }
163
+ .dsh-imgvw-sep {
164
+ opacity: 0.45;
165
+ }
166
+ .dsh-imgvw-stat {
167
+ font-variant-numeric: tabular-nums;
168
+ }
169
+ .dsh-imgvw-link {
170
+ background: none;
171
+ border: 0;
172
+ padding: 0;
173
+ font: inherit;
174
+ color: inherit;
175
+ text-decoration: underline;
176
+ text-decoration-color: transparent;
177
+ text-underline-offset: 2px;
178
+ cursor: pointer;
179
+ }
180
+ .dsh-imgvw-link:hover {
181
+ text-decoration-color: currentColor;
182
+ color: var(--dsw-static-deepseek-500, #4d6bfe);
183
+ }
184
+ .dsh-imgvw-dot {
185
+ flex: none;
186
+ width: 6px;
187
+ height: 6px;
188
+ border-radius: 50%;
189
+ background: var(--dsw-alias-label-tertiary, #8a8f98);
190
+ }
191
+ .dsh-imgvw-dot[data-live='true'] {
192
+ background: var(--dsw-static-deepseek-500, #4d6bfe);
193
+ animation: dsh-imgvw-pulse 1.2s ease-in-out infinite;
194
+ }
195
+ .dsh-imgvw-dot[data-bad='true'] {
196
+ background: var(--dsw-alias-label-error, #d1495b);
197
+ }
198
+ .dsh-imgvw-dot[data-ok='true'] {
199
+ background: var(--dsw-alias-label-success, #2f9e63);
200
+ }
201
+ @keyframes dsh-imgvw-pulse {
202
+ 50% { opacity: 0.25; }
203
+ }
204
+ .dsh-imgvw-backdrop {
205
+ position: fixed;
206
+ inset: 0;
207
+ z-index: 2147483000;
208
+ display: flex;
209
+ flex-direction: column;
210
+ background: rgba(8, 8, 10, 0.86);
211
+ backdrop-filter: blur(2px);
212
+ }
213
+ .dsh-imgvw-stage {
214
+ position: relative;
215
+ flex: 1 1 auto;
216
+ min-height: 0;
217
+ overflow: hidden;
218
+ display: flex;
219
+ align-items: center;
220
+ justify-content: center;
221
+ touch-action: none;
222
+ user-select: none;
223
+ cursor: grab;
224
+ }
225
+ .dsh-imgvw-stage[data-drag='true'] {
226
+ cursor: grabbing;
227
+ }
228
+ .dsh-imgvw-big {
229
+ max-width: none;
230
+ max-height: none;
231
+ will-change: transform;
232
+ }
233
+ .dsh-imgvw-x {
234
+ position: absolute;
235
+ top: 14px;
236
+ right: 18px;
237
+ z-index: 1;
238
+ width: 32px;
239
+ height: 32px;
240
+ display: flex;
241
+ align-items: center;
242
+ justify-content: center;
243
+ border: 0;
244
+ border-radius: 8px;
245
+ background: rgba(255, 255, 255, 0.12);
246
+ color: #fff;
247
+ font-size: 18px;
248
+ line-height: 1;
249
+ cursor: pointer;
250
+ }
251
+ .dsh-imgvw-x:hover {
252
+ background: rgba(255, 255, 255, 0.22);
253
+ }
254
+ .dsh-imgvw-legend {
255
+ position: absolute;
256
+ left: 50%;
257
+ top: 20px;
258
+ transform: translateX(-50%);
259
+ z-index: 1;
260
+ padding: 5px 12px;
261
+ border-radius: 999px;
262
+ background: rgba(0, 0, 0, 0.45);
263
+ color: rgba(255, 255, 255, 0.82);
264
+ font-size: 11px;
265
+ line-height: 1.6;
266
+ white-space: nowrap;
267
+ pointer-events: none;
268
+ }
269
+ .dsh-imgvw-caption {
270
+ flex: none;
271
+ display: flex;
272
+ align-items: center;
273
+ justify-content: center;
274
+ flex-wrap: wrap;
275
+ gap: 10px;
276
+ padding: 10px 16px;
277
+ color: rgba(255, 255, 255, 0.8);
278
+ font-size: 11px;
279
+ line-height: 1.6;
280
+ }
281
+ .dsh-imgvw-caption b {
282
+ font-weight: 600;
283
+ color: #fff;
284
+ font-variant-numeric: tabular-nums;
285
+ }
286
+ .dsh-imgvw-caption button {
287
+ background: none;
288
+ border: 1px solid rgba(255, 255, 255, 0.28);
289
+ border-radius: 6px;
290
+ color: inherit;
291
+ font: inherit;
292
+ padding: 1px 8px;
293
+ cursor: pointer;
294
+ }
295
+ .dsh-imgvw-caption button:hover {
296
+ background: rgba(255, 255, 255, 0.14);
297
+ }
298
+ `
299
+
300
+ /** Narrow an unknown wire value to a plain record. */
301
+ function isRecord(value) {
302
+ return typeof value === 'object' && value !== null && Array.isArray(value) === false
303
+ }
304
+
305
+ /**
306
+ * Read the `file_path` argument out of a Tool call block.
307
+ *
308
+ * The block is either a running call or a settled result; both carry the call
309
+ * head, whose `argsRaw` is the JSON the model produced. A settled call with a
310
+ * malformed head simply renders nothing rather than a broken card.
311
+ */
312
+ function imagePathOf(block) {
313
+ const call = isRecord(block) && isRecord(block.call) ? block.call : block
314
+ if (isRecord(call) === false) return null
315
+ const raw = call.argsRaw
316
+ if (typeof raw !== 'string' || raw === '') return null
317
+ let args
318
+ try {
319
+ args = JSON.parse(raw)
320
+ } catch {
321
+ return null
322
+ }
323
+ if (isRecord(args) === false) return null
324
+ const path = args.file_path
325
+ if (typeof path !== 'string' || path.trim() === '') return null
326
+ return path
327
+ }
328
+
329
+ /** Strip the workspace root from a workspace-rooted path, for display only. */
330
+ function displayPathOf(path, cwd) {
331
+ if (typeof cwd !== 'string' || cwd === '') return path
332
+ const root = cwd.replace(/[/\\]+$/, '')
333
+ if (root === '') return path
334
+ if (path.startsWith(root + '/') || path.startsWith(root + '\\')) return path.slice(root.length + 1)
335
+ return path
336
+ }
337
+
338
+ /** Render a byte count at reading size. */
339
+ function formatBytes(bytes) {
340
+ if (typeof bytes !== 'number' || bytes <= 0) return null
341
+ if (bytes < 1024) return bytes + ' B'
342
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
343
+ return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
344
+ }
345
+
346
+ /**
347
+ * The claim list: every tool whose card this plugin owns, with the Chinese
348
+ * label shown while the call runs. `read_image` needs no label (the file
349
+ * path speaks for itself). Adding an image-bearing tool = adding one line.
350
+ */
351
+ const TOOL_LABELS = {
352
+ generate_image: '绘图',
353
+ computer_screenshot: '屏幕截图',
354
+ computer_zoom: '区域放大',
355
+ computer_wait: '等待',
356
+ computer_move: '移动鼠标',
357
+ computer_click: '点击',
358
+ computer_drag: '拖拽',
359
+ computer_scroll: '滚动',
360
+ computer_type: '输入文本',
361
+ computer_key: '按键',
362
+ computer_open: '启动应用',
363
+ computer_sequence: '连续动作',
364
+ }
365
+
366
+ const IMAGE_TOOL_KEYS = ['read_image'].concat(Object.keys(TOOL_LABELS))
367
+
368
+ /**
369
+ * Decide what the card should render for one call.
370
+ *
371
+ * `file` when the call itself names a path (previewable before settle);
372
+ * otherwise `attachment` when the settled content carries image refs —
373
+ * collected together with the first text line, which becomes the note row.
374
+ */
375
+ function imageSpecOf(block) {
376
+ const path = imagePathOf(block)
377
+ if (path !== null) return { mode: 'file', path, atts: [], text: '' }
378
+ const content = isRecord(block) && Array.isArray(block.content) ? block.content : null
379
+ if (content === null) return null
380
+ const atts = []
381
+ let text = ''
382
+ for (const item of content) {
383
+ if (!isRecord(item)) continue
384
+ if (item.type === 'image' && isRecord(item.attachment)
385
+ && typeof item.attachment.attachmentId === 'string') {
386
+ atts.push(item.attachment)
387
+ } else if (item.type === 'text' && typeof item.text === 'string' && text === '') {
388
+ text = String(item.text).split('\n')[0]
389
+ }
390
+ }
391
+ if (atts.length === 0) return null
392
+ return { mode: 'attachment', path: null, atts, text }
393
+ }
394
+
395
+ /**
396
+ * Full-screen zoom and pan stage.
397
+ *
398
+ * The picture renders at natural size and is moved entirely by one transform,
399
+ * so zooming is continuous rather than a set of steps. The wheel is consumed
400
+ * only while Ctrl is held and only inside this stage, which is what keeps a
401
+ * zoom gesture from fighting the chat's own scrolling. Zoom is anchored on the
402
+ * cursor: the point under the pointer stays put, so zooming into a detail does
403
+ * not make the user chase it with a drag afterwards.
404
+ */
405
+ function ImageZoomOverlay(props) {
406
+ const [view, setView] = React.useState({ scale: 1, x: 0, y: 0 })
407
+ const [dragging, setDragging] = React.useState(false)
408
+ const stageRef = React.useRef(null)
409
+ const imgRef = React.useRef(null)
410
+ const dragRef = React.useRef(null)
411
+ const tapRef = React.useRef(null)
412
+ const [natural, setNatural] = React.useState({ w: 0, h: 0 })
413
+ const [vp, setVp] = React.useState({ w: 0, h: 0 })
414
+ const close = props.onClose
415
+
416
+ // Open at a fit-to-viewport scale: 1:1 is the wrong entry point for a
417
+ // screenshot, which is routinely larger than the window it must fit in.
418
+ React.useEffect(() => {
419
+ const measure = () => {
420
+ const stage = stageRef.current
421
+ if (stage === null) return
422
+ const rect = stage.getBoundingClientRect()
423
+ if (rect.width > 0 && rect.height > 0) setVp({ w: rect.width, h: rect.height })
424
+ }
425
+ measure()
426
+ window.addEventListener('resize', measure)
427
+ return () => window.removeEventListener('resize', measure)
428
+ }, [])
429
+
430
+ const fit = (natural.w > 0 && natural.h > 0 && vp.w > 0 && vp.h > 0)
431
+ ? Math.min(1, (vp.w * 0.97) / natural.w, (vp.h * 0.97) / natural.h)
432
+ : 1
433
+
434
+ /** Land on the fit scale once, as soon as both the stage and the picture are measurable. */
435
+ const settled = React.useRef(null)
436
+ React.useEffect(() => {
437
+ if (natural.w <= 0 || vp.w <= 0 || vp.h <= 0) return
438
+ // Follow the window only while the view is still the one this overlay opened with;
439
+ // a view the user has zoomed or panned is theirs to keep.
440
+ if (settled.current === 'user' || settled.current === 'done') return
441
+ settled.current = 'done'
442
+ setView({ scale: fit, x: 0, y: 0 })
443
+ }, [natural.w, natural.h, vp.w, vp.h, fit])
444
+
445
+ React.useEffect(() => {
446
+ const onKey = (event) => {
447
+ if (event.key === 'Escape') close()
448
+ }
449
+ window.addEventListener('keydown', onKey)
450
+ return () => window.removeEventListener('keydown', onKey)
451
+ }, [close])
452
+
453
+ // Registered non-passively: a Ctrl+wheel gesture must not also scroll the page.
454
+ React.useEffect(() => {
455
+ const stage = stageRef.current
456
+ if (stage === null) return undefined
457
+ const onWheel = (event) => {
458
+ if (event.ctrlKey !== true) return
459
+ event.preventDefault()
460
+ settled.current = 'user'
461
+ const rect = stage.getBoundingClientRect()
462
+ const cx = event.clientX - rect.left - rect.width / 2
463
+ const cy = event.clientY - rect.top - rect.height / 2
464
+ const factor = Math.exp(-event.deltaY / 420)
465
+ setView((prev) => {
466
+ const scale = Math.min(8, Math.max(0.05, prev.scale * factor))
467
+ const ratio = scale / prev.scale
468
+ return { scale, x: cx - (cx - prev.x) * ratio, y: cy - (cy - prev.y) * ratio }
469
+ })
470
+ }
471
+ stage.addEventListener('wheel', onWheel, { passive: false })
472
+ return () => stage.removeEventListener('wheel', onWheel)
473
+ }, [])
474
+
475
+ const onPointerDown = (event) => {
476
+ dragRef.current = {
477
+ id: event.pointerId,
478
+ fromX: event.clientX,
479
+ fromY: event.clientY,
480
+ baseX: view.x,
481
+ baseY: view.y,
482
+ moved: false,
483
+ }
484
+ try {
485
+ event.currentTarget.setPointerCapture(event.pointerId)
486
+ } catch {
487
+ /* capture is a nicety; dragging still works without it */
488
+ }
489
+ }
490
+
491
+ const onPointerMove = (event) => {
492
+ const drag = dragRef.current
493
+ if (drag === null || drag.id !== event.pointerId) return
494
+ const dx = event.clientX - drag.fromX
495
+ const dy = event.clientY - drag.fromY
496
+ if (drag.moved === false && Math.abs(dx) + Math.abs(dy) > 3) {
497
+ drag.moved = true
498
+ setDragging(true)
499
+ }
500
+ if (drag.moved === true) {
501
+ settled.current = 'user'
502
+ setView((prev) => ({ scale: prev.scale, x: drag.baseX + dx, y: drag.baseY + dy }))
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Whether a pointer position lands on the picture itself.
508
+ *
509
+ * Testing the live rect rather than the event target is the point: a
510
+ * letterboxed picture leaves stage area above and below (and sometimes
511
+ * beside) itself, and a hit test there reports the stage, so target-based
512
+ * emptiness would treat a click on the picture's own margin as a click on
513
+ * the backdrop.
514
+ */
515
+ const landsOnPicture = (event) => {
516
+ const node = imgRef.current
517
+ if (node === null) return false
518
+ const rect = node.getBoundingClientRect()
519
+ return event.clientX >= rect.left && event.clientX <= rect.right
520
+ && event.clientY >= rect.top && event.clientY <= rect.bottom
521
+ }
522
+
523
+ /**
524
+ * A gesture that never moved: either the first half of a double tap or a
525
+ * click outside the picture, which closes.
526
+ *
527
+ * Double tap is detected here rather than left to the native `dblclick`
528
+ * because the pointer capture this stage takes for dragging is exactly the
529
+ * kind of retargeting that stops the browser synthesising that event.
530
+ */
531
+ const onPointerUp = (event) => {
532
+ const drag = dragRef.current
533
+ dragRef.current = null
534
+ setDragging(false)
535
+ if (drag === null) return
536
+ if (drag.moved === true) return
537
+ const now = Date.now()
538
+ const last = tapRef.current
539
+ if (last !== null
540
+ && now - last.at < 400
541
+ && Math.abs(event.clientX - last.x) < 20
542
+ && Math.abs(event.clientY - last.y) < 20) {
543
+ tapRef.current = null
544
+ toFit()
545
+ return
546
+ }
547
+ tapRef.current = { at: now, x: event.clientX, y: event.clientY }
548
+ if (landsOnPicture(event) === false) close()
549
+ }
550
+
551
+ // Double-click and the fit button land on the same view the card opened with.
552
+ // Declared before the pointer handlers, which call it from a tap they own.
553
+ function toFit() {
554
+ settled.current = 'user'
555
+ setView({ scale: fit, x: 0, y: 0 })
556
+ }
557
+ const toActual = () => {
558
+ settled.current = 'user'
559
+ setView({ scale: 1, x: 0, y: 0 })
560
+ }
561
+ // Before the first fit is known the picture is hidden rather than shown at a
562
+ // scale it is about to leave, which is what a flash of an oversized image is.
563
+ const focused = settled.current !== null
564
+
565
+ const step = (factor) => setView((prev) => {
566
+ settled.current = 'user'
567
+ const scale = Math.min(8, Math.max(0.05, prev.scale * factor))
568
+ const ratio = scale / prev.scale
569
+ return { scale, x: prev.x * ratio, y: prev.y * ratio }
570
+ })
571
+
572
+ return React.createElement('div', {
573
+ className: 'dsh-imgvw-backdrop',
574
+ onDoubleClick: toFit,
575
+ }, [
576
+ React.createElement('button', {
577
+ key: 'x',
578
+ type: 'button',
579
+ className: 'dsh-imgvw-x',
580
+ title: '关闭 (Esc)',
581
+ onClick: close,
582
+ }, '×'),
583
+ React.createElement('div', { key: 'legend', className: 'dsh-imgvw-legend' },
584
+ 'Ctrl + 滚轮缩放 · 按住拖动 · 双击适应窗口 · 点图外关闭'),
585
+ React.createElement('div', {
586
+ key: 'stage',
587
+ ref: stageRef,
588
+ className: 'dsh-imgvw-stage',
589
+ 'data-drag': dragging ? 'true' : 'false',
590
+ onPointerDown: onPointerDown,
591
+ onPointerMove: onPointerMove,
592
+ onPointerUp: onPointerUp,
593
+ onPointerCancel: onPointerUp,
594
+ }, [
595
+ React.createElement('img', {
596
+ key: 'img',
597
+ ref: imgRef,
598
+ className: 'dsh-imgvw-big',
599
+ src: props.src,
600
+ alt: props.shown,
601
+ draggable: false,
602
+ style: {
603
+ transform: 'translate(' + view.x + 'px, ' + view.y + 'px) scale(' + view.scale + ')',
604
+ opacity: focused ? undefined : 0,
605
+ },
606
+ onLoad: (event) => {
607
+ const w = event.target.naturalWidth
608
+ const h = event.target.naturalHeight
609
+ if (typeof w !== 'number' || typeof h !== 'number' || w <= 0 || h <= 0) return
610
+ setNatural({ w, h })
611
+ },
612
+ }),
613
+ ]),
614
+ React.createElement('div', { key: 'caption', className: 'dsh-imgvw-caption' }, [
615
+ React.createElement('span', { key: 'name' }, props.shown),
616
+ props.pixels === null ? null : React.createElement('span', { key: 'dims' }, props.pixels),
617
+ React.createElement('span', { key: 'scale' }, [
618
+ '缩放 ',
619
+ React.createElement('b', { key: 'v' }, Math.round(view.scale * 100) + '%'),
620
+ ]),
621
+ React.createElement('button', { key: 'out', type: 'button', onClick: () => step(1 / 1.25) }, '−'),
622
+ React.createElement('button', { key: 'in', type: 'button', onClick: () => step(1.25) }, '+'),
623
+ React.createElement('button', { key: 'fit', type: 'button', onClick: toFit, title: '双击图片同样回到适应窗口' }, '适应窗口'),
624
+ React.createElement('button', { key: 'actual', type: 'button', onClick: toActual }, '100%'),
625
+ ]),
626
+ ])
627
+ }
628
+
629
+ /**
630
+ * The one metadata line under the picture: identity, size, format, actions.
631
+ *
632
+ * It is a separate component so the image's own re-render never rebuilds the
633
+ * action buttons.
634
+ */
635
+ function MediaBar(props) {
636
+ const parts = []
637
+ parts.push(React.createElement('span', {
638
+ key: 'dot',
639
+ className: 'dsh-imgvw-dot',
640
+ 'data-live': props.live ? 'true' : 'false',
641
+ 'data-bad': props.bad ? 'true' : 'false',
642
+ 'data-ok': props.ok && props.live === false ? 'true' : 'false',
643
+ }))
644
+ parts.push(React.createElement('span', {
645
+ key: 'name',
646
+ className: 'dsh-imgvw-name',
647
+ title: props.shown,
648
+ }, props.shown))
649
+ const add = (key, value) => {
650
+ if (value === null) return
651
+ parts.push(React.createElement(React.Fragment, { key: key + '-sep' },
652
+ React.createElement('span', { className: 'dsh-imgvw-sep' }, '·')))
653
+ parts.push(React.createElement('span', { key: key, className: 'dsh-imgvw-stat' }, value))
654
+ }
655
+ add('pixels', props.pixels)
656
+ add('bytes', props.bytes)
657
+ add('format', props.format)
658
+ if (props.onZoomIn !== null) {
659
+ parts.push(React.createElement(React.Fragment, { key: 'zoom-sep' },
660
+ React.createElement('span', { className: 'dsh-imgvw-sep' }, '·')))
661
+ parts.push(React.createElement('button', {
662
+ key: 'zoom',
663
+ type: 'button',
664
+ className: 'dsh-imgvw-link',
665
+ onClick: props.onZoomIn,
666
+ }, '缩放查看'))
667
+ }
668
+ if (props.onCopy !== null) {
669
+ parts.push(React.createElement(React.Fragment, { key: 'copy-sep' },
670
+ React.createElement('span', { className: 'dsh-imgvw-sep' }, '·')))
671
+ parts.push(React.createElement('button', {
672
+ key: 'copy',
673
+ type: 'button',
674
+ className: 'dsh-imgvw-link',
675
+ onClick: props.onCopy,
676
+ }, props.copied ? '已复制路径' : '复制路径'))
677
+ }
678
+ if (props.onOpenFile !== null) {
679
+ parts.push(React.createElement(React.Fragment, { key: 'open-sep' },
680
+ React.createElement('span', { className: 'dsh-imgvw-sep' }, '·')))
681
+ parts.push(React.createElement('button', {
682
+ key: 'open',
683
+ type: 'button',
684
+ className: 'dsh-imgvw-link',
685
+ onClick: props.onOpenFile,
686
+ }, '在编辑器中打开'))
687
+ }
688
+ return React.createElement('div', {
689
+ className: 'dsh-imgvw-bar',
690
+ style: props.width === null ? undefined : { width: props.width + 'px' },
691
+ }, parts)
692
+ }
693
+
694
+ /**
695
+ * Running vs settled comes from the block's shape, not from a flag.
696
+ *
697
+ * `ToolCallBlock` is a union of `RunningToolCall` and `ToolResultNode`
698
+ * (content, isError). There is no `isRunning` field, so reading one always
699
+ * yielded false and an in-flight call fell through to the settled path —
700
+ * which returns null when the block carries no content, leaving the row
701
+ * blank for the whole call.
702
+ *
703
+ * dsh 0.1.7-rc.1 split `RunningToolCall` into `phase: 'preparing'` (the
704
+ * model is still streaming arguments, so there is no `argsRaw` yet) and
705
+ * `phase: 'start'`, and the slot now renders during preparation as well. The
706
+ * preparing head must count as running too, or a claimed row renders nothing
707
+ * until the call is dispatched.
708
+ */
709
+ function isRunningBlock(block) {
710
+ if (isRecord(block) === false) return false
711
+ if (block.phase === 'preparing' || block.phase === 'start') return true
712
+ return block.isRunning !== false && typeof block.argsRaw === 'string'
713
+ }
714
+ function isFailedBlock(block) {
715
+ return isRecord(block) && block.isError === true
716
+ }
717
+
718
+ /**
719
+ * The preview card in `file` mode: frame, metadata line, one transient note.
720
+ *
721
+ * The host resolves the path once per call and answers with everything the
722
+ * card needs, so there is exactly one request per rendered call and no state
723
+ * to keep beyond this component's lifetime.
724
+ */
725
+ function FilePreview(props) {
726
+ const block = props.block
727
+ const path = imagePathOf(block)
728
+ const live = isRunningBlock(block)
729
+ const [info, setInfo] = React.useState(null)
730
+ const [open, setOpen] = React.useState(false)
731
+ const [geometry, setGeometry] = React.useState(null)
732
+ const [checker, setChecker] = React.useState(false)
733
+ const [note, setNote] = React.useState(null)
734
+ const [copied, setCopied] = React.useState(false)
735
+
736
+ React.useEffect(() => {
737
+ if (path === null) return undefined
738
+ let alive = true
739
+ setInfo(null)
740
+ const params = new URLSearchParams()
741
+ params.set('path', path)
742
+ if (typeof props.cwd === 'string' && props.cwd !== '') params.set('cwd', props.cwd)
743
+ fetch(HOST_ROUTE + '?' + params.toString()).then(
744
+ (response) => response.json(),
745
+ (error) => ({ ok: false, error: String(error) }),
746
+ ).then((value) => {
747
+ if (alive !== true) return
748
+ setInfo(isRecord(value) ? value : { ok: false, error: 'unexpected reply' })
749
+ }, (error) => {
750
+ if (alive === true) setInfo({ ok: false, error: String(error) })
751
+ })
752
+ return () => { alive = false }
753
+ }, [path, props.cwd])
754
+
755
+ if (path === null) return null
756
+
757
+ const cwd = typeof props.cwd === 'string' ? props.cwd : ''
758
+ const shown = displayPathOf(path, cwd)
759
+ const ok = info !== null && info.ok === true
760
+ const bad = info !== null && info.ok === false
761
+ const failure = bad ? String(info.error) : ''
762
+ const src = ok && typeof info.url === 'string' ? info.url : null
763
+ const bytes = ok ? formatBytes(info.bytes) : null
764
+ const pw = ok && typeof info.width === 'number' ? info.width : null
765
+ const ph = ok && typeof info.height === 'number' ? info.height : null
766
+ const pixels = pw !== null && ph !== null ? pw + '×' + ph : null
767
+ const mediaType = ok && typeof info.mediaType === 'string' ? info.mediaType : ''
768
+ const format = mediaType === '' ? null : mediaType.replace('image/', '').toUpperCase()
769
+ const aspect = pw !== null && ph !== null && ph > 0 ? pw / ph : null
770
+ // v1.4.1:图片未加载就按已知尺寸定死一切布局。晚到的图片在已滚走的区域
771
+ // 加载时不再撑高内容——那会让聊天流的吸底逻辑把浏览器自发的布局位移
772
+ // 误判成用户上滚,从而停止自动跟随最新记录。
773
+ const known = geometry !== null ? geometry : pw !== null && ph !== null && ph > 0 ? { w: pw, h: ph } : null
774
+ const barWidth = known === null
775
+ ? null
776
+ : Math.max(320, Math.min(720, Math.round(160 * (known.w / known.h))))
777
+
778
+ /** Size the metadata line to the picture and detect a transparent container. */
779
+ const onImageLoad = (event) => {
780
+ const node = event.target
781
+ const w = node.naturalWidth
782
+ const h = node.naturalHeight
783
+ if (typeof w !== 'number' || typeof h !== 'number' || w <= 0 || h <= 0) return
784
+ setGeometry({ w, h })
785
+ if (format !== 'PNG' && format !== 'WEBP' && format !== 'GIF') return
786
+ try {
787
+ const size = 24
788
+ const canvas = document.createElement('canvas')
789
+ canvas.width = size
790
+ canvas.height = size
791
+ const context = canvas.getContext('2d')
792
+ if (context === null) return
793
+ context.drawImage(node, 0, 0, size, size)
794
+ const data = context.getImageData(0, 0, size, size).data
795
+ for (let i = 3; i < data.length; i += 4) {
796
+ if (data[i] < 250) {
797
+ setChecker(true)
798
+ return
799
+ }
800
+ }
801
+ } catch {
802
+ /* an unreadable canvas just means no checkerboard */
803
+ }
804
+ }
805
+
806
+ const copyPath = () => {
807
+ console.log('read_image preview path', shown)
808
+ try {
809
+ const clip = navigator.clipboard
810
+ if (clip && typeof clip.writeText === 'function') {
811
+ clip.writeText(shown).then(
812
+ () => setCopied(true),
813
+ () => setNote('无法自动复制,路径已打到控制台'),
814
+ )
815
+ return
816
+ }
817
+ } catch {
818
+ /* fall through to the console note */
819
+ }
820
+ setNote('无法自动复制,路径已打到控制台')
821
+ }
822
+
823
+ const frame = src !== null
824
+ ? React.createElement('div', {
825
+ key: 'frame',
826
+ className: 'dsh-imgvw-frame',
827
+ title: '点击打开缩放视图',
828
+ onClick: () => setOpen(true),
829
+ }, [
830
+ React.createElement('img', {
831
+ key: 'img',
832
+ className: 'dsh-imgvw-img',
833
+ src,
834
+ alt: shown,
835
+ width: pw === null ? undefined : pw,
836
+ height: ph === null ? undefined : ph,
837
+ 'data-checker': checker ? 'true' : 'false',
838
+ onLoad: onImageLoad,
839
+ }),
840
+ React.createElement('span', {
841
+ key: 'zoom',
842
+ className: 'dsh-imgvw-zoom',
843
+ 'aria-hidden': 'true',
844
+ }, '⤢'),
845
+ ])
846
+ : React.createElement('div', { key: 'frame', className: 'dsh-imgvw-frame' }, [
847
+ React.createElement('div', {
848
+ key: 'skel',
849
+ className: 'dsh-imgvw-skel',
850
+ style: aspect === null ? undefined : { aspectRatio: String(aspect) },
851
+ }, [
852
+ React.createElement('span', {
853
+ key: 'label',
854
+ className: 'dsh-imgvw-skel-label',
855
+ 'data-bad': bad ? 'true' : 'false',
856
+ }, bad
857
+ ? '无法预览:' + failure
858
+ : live
859
+ ? (TOOL_LABELS[props.toolName] === undefined
860
+ ? 'AI 正在查看这张图片…'
861
+ : TOOL_LABELS[props.toolName] + ' 执行中,画面稍后呈现…')
862
+ : '正在载入预览…'),
863
+ ]),
864
+ ])
865
+
866
+ return React.createElement('div', { className: 'dsh-imgvw-root' }, [
867
+ frame,
868
+ React.createElement(MediaBar, {
869
+ key: 'bar',
870
+ shown,
871
+ live,
872
+ bad,
873
+ ok,
874
+ pixels,
875
+ bytes,
876
+ format,
877
+ copied,
878
+ width: barWidth,
879
+ onZoomIn: src === null ? null : () => setOpen(true),
880
+ onCopy: copyPath,
881
+ onOpenFile: typeof props.openFile === 'function' ? () => props.openFile(path) : null,
882
+ }),
883
+ note === null
884
+ ? null
885
+ : React.createElement('div', {
886
+ key: 'note',
887
+ className: 'dsh-imgvw-bar',
888
+ style: { opacity: 0.75 },
889
+ }, note),
890
+ open && src !== null
891
+ ? React.createElement(ImageZoomOverlay, {
892
+ key: 'overlay',
893
+ src,
894
+ shown,
895
+ pixels,
896
+ onClose: () => setOpen(false),
897
+ })
898
+ : null,
899
+ ])
900
+ }
901
+
902
+ /**
903
+ * The preview card in `attachment` mode: the settled result owns the picture.
904
+ *
905
+ * Every stat renders straight off the attachment reference, and the URL
906
+ * comes from the session-authorized loader (`peek` first — chat keeps a warm
907
+ * cache of images it already resolved). There is no path semantics, so the
908
+ * copy-path and open-in-editor actions fall away.
909
+ */
910
+ function AttachmentPreview(props) {
911
+ const att = props.spec.atts[0]
912
+ const extra = props.spec.atts.length - 1
913
+ const load = props.loadImage
914
+ const label = TOOL_LABELS[props.toolName] === undefined ? props.toolName : TOOL_LABELS[props.toolName]
915
+ const [url, setUrl] = React.useState(() => (
916
+ typeof load === 'function' && typeof load.peek === 'function' ? load.peek(att) ?? null : null
917
+ ))
918
+ const [failed, setFailed] = React.useState(false)
919
+ const [open, setOpen] = React.useState(false)
920
+ const [geometry, setGeometry] = React.useState(null)
921
+ const [checker, setChecker] = React.useState(false)
922
+
923
+ React.useEffect(() => {
924
+ if (typeof load !== 'function') {
925
+ setFailed(true)
926
+ return undefined
927
+ }
928
+ let alive = true
929
+ const peeked = typeof load.peek === 'function' ? load.peek(att) : null
930
+ if (peeked !== null && peeked !== undefined) {
931
+ setUrl(peeked)
932
+ return undefined
933
+ }
934
+ setUrl(null)
935
+ setFailed(false)
936
+ Promise.resolve(load(att)).then(
937
+ (value) => { if (alive === true) setUrl(typeof value === 'string' ? value : null) },
938
+ () => { if (alive === true) setFailed(true) },
939
+ )
940
+ return () => { alive = false }
941
+ }, [att.attachmentId])
942
+
943
+ if (props.spec.atts.length === 0) return null
944
+
945
+ const shown = typeof att.name === 'string' && att.name !== '' ? att.name : label
946
+ const hasDims = typeof att.width === 'number' && typeof att.height === 'number'
947
+ && att.width > 0 && att.height > 0
948
+ const pixels = hasDims ? att.width + '×' + att.height : null
949
+ const bytes = formatBytes(att.bytes)
950
+ const format = typeof att.mediaType === 'string' && att.mediaType.startsWith('image/')
951
+ ? att.mediaType.slice('image/'.length).toUpperCase()
952
+ : null
953
+ const aspect = hasDims ? att.width / att.height : null
954
+ // v1.4.1:附件的像素尺寸在图片加载前就在引用上,布局提前定死,加载零位移。
955
+ const known = geometry !== null ? geometry : hasDims ? { w: att.width, h: att.height } : null
956
+ const barWidth = known === null
957
+ ? null
958
+ : Math.max(320, Math.min(720, Math.round(160 * (known.w / known.h))))
959
+ const noteParts = []
960
+ if (props.spec.text !== '') noteParts.push(props.spec.text)
961
+ if (extra > 0) noteParts.push('另有 ' + extra + ' 张未展开')
962
+ const note = noteParts.length === 0 ? null : noteParts.join(' · ')
963
+
964
+ /** Size the metadata line to the picture and detect a transparent container. */
965
+ const onImageLoad = (event) => {
966
+ const node = event.target
967
+ const w = node.naturalWidth
968
+ const h = node.naturalHeight
969
+ if (typeof w !== 'number' || typeof h !== 'number' || w <= 0 || h <= 0) return
970
+ setGeometry({ w, h })
971
+ if (format !== 'PNG' && format !== 'WEBP' && format !== 'GIF') return
972
+ try {
973
+ const size = 24
974
+ const canvas = document.createElement('canvas')
975
+ canvas.width = size
976
+ canvas.height = size
977
+ const context = canvas.getContext('2d')
978
+ if (context === null) return
979
+ context.drawImage(node, 0, 0, size, size)
980
+ const data = context.getImageData(0, 0, size, size).data
981
+ for (let i = 3; i < data.length; i += 4) {
982
+ if (data[i] < 250) {
983
+ setChecker(true)
984
+ return
985
+ }
986
+ }
987
+ } catch {
988
+ /* an unreadable canvas just means no checkerboard */
989
+ }
990
+ }
991
+
992
+ const src = failed === false && typeof url === 'string' ? url : null
993
+ const frame = src !== null
994
+ ? React.createElement('div', {
995
+ key: 'frame',
996
+ className: 'dsh-imgvw-frame',
997
+ title: '点击打开缩放视图',
998
+ onClick: () => setOpen(true),
999
+ }, [
1000
+ React.createElement('img', {
1001
+ key: 'img',
1002
+ className: 'dsh-imgvw-img',
1003
+ src,
1004
+ alt: shown,
1005
+ width: hasDims ? att.width : undefined,
1006
+ height: hasDims ? att.height : undefined,
1007
+ 'data-checker': checker ? 'true' : 'false',
1008
+ onLoad: onImageLoad,
1009
+ }),
1010
+ React.createElement('span', {
1011
+ key: 'zoom',
1012
+ className: 'dsh-imgvw-zoom',
1013
+ 'aria-hidden': 'true',
1014
+ }, '⤢'),
1015
+ ])
1016
+ : React.createElement('div', { key: 'frame', className: 'dsh-imgvw-frame' }, [
1017
+ React.createElement('div', {
1018
+ key: 'skel',
1019
+ className: 'dsh-imgvw-skel',
1020
+ style: aspect === null ? undefined : { aspectRatio: String(aspect) },
1021
+ }, [
1022
+ React.createElement('span', {
1023
+ key: 'label',
1024
+ className: 'dsh-imgvw-skel-label',
1025
+ 'data-bad': failed ? 'true' : 'false',
1026
+ }, failed ? '图片加载失败:附件不可读' : '正在载入画面…'),
1027
+ ]),
1028
+ ])
1029
+
1030
+ return React.createElement('div', { className: 'dsh-imgvw-root' }, [
1031
+ frame,
1032
+ React.createElement(MediaBar, {
1033
+ key: 'bar',
1034
+ shown,
1035
+ live: false,
1036
+ bad: failed,
1037
+ ok: src !== null,
1038
+ pixels,
1039
+ bytes,
1040
+ format,
1041
+ copied: false,
1042
+ width: barWidth,
1043
+ onZoomIn: src === null ? null : () => setOpen(true),
1044
+ onCopy: null,
1045
+ onOpenFile: null,
1046
+ }),
1047
+ note === null
1048
+ ? null
1049
+ : React.createElement('div', {
1050
+ key: 'note',
1051
+ className: 'dsh-imgvw-bar',
1052
+ style: { opacity: 0.75 },
1053
+ }, note),
1054
+ open && src !== null
1055
+ ? React.createElement(ImageZoomOverlay, {
1056
+ key: 'overlay',
1057
+ src,
1058
+ shown,
1059
+ pixels,
1060
+ onClose: () => setOpen(false),
1061
+ })
1062
+ : null,
1063
+ ])
1064
+ }
1065
+
1066
+ /**
1067
+ * The one card for every claimed tool.
1068
+ *
1069
+ * Dispatch order: file preview (call names a path) → attachment preview
1070
+ * (settled content carries image refs) → a live skeleton while the call
1071
+ * runs → a plain one-line outcome for imageless/failed settled calls, so a
1072
+ * claimed row never renders an empty shell.
1073
+ */
1074
+ function ImageToolCard(props) {
1075
+ const block = props.block
1076
+ const spec = imageSpecOf(block)
1077
+ if (spec !== null && spec.mode === 'file') {
1078
+ return React.createElement(FilePreview, {
1079
+ block,
1080
+ cwd: props.cwd,
1081
+ home: props.home,
1082
+ openFile: props.openFile,
1083
+ toolName: props.toolName,
1084
+ })
1085
+ }
1086
+ if (spec !== null) {
1087
+ return React.createElement(AttachmentPreview, {
1088
+ spec,
1089
+ loadImage: props.loadImage,
1090
+ toolName: props.toolName,
1091
+ })
1092
+ }
1093
+ const live = isRunningBlock(block)
1094
+ const failed = isFailedBlock(block)
1095
+ if (live) {
1096
+ const liveLabel = TOOL_LABELS[props.toolName] === undefined ? props.toolName : TOOL_LABELS[props.toolName]
1097
+ return React.createElement('div', { className: 'dsh-imgvw-root' }, [
1098
+ React.createElement('div', { key: 'frame', className: 'dsh-imgvw-frame' }, [
1099
+ React.createElement('div', { key: 'skel', className: 'dsh-imgvw-skel' }, [
1100
+ React.createElement('span', {
1101
+ key: 'label',
1102
+ className: 'dsh-imgvw-skel-label',
1103
+ }, liveLabel + ' 执行中,画面稍后呈现…'),
1104
+ ]),
1105
+ ]),
1106
+ ])
1107
+ }
1108
+ let line = ''
1109
+ if (isRecord(block) && Array.isArray(block.content)) {
1110
+ for (const item of block.content) {
1111
+ if (isRecord(item) && item.type === 'text' && typeof item.text === 'string') {
1112
+ line = String(item.text).split('\n')[0]
1113
+ break
1114
+ }
1115
+ }
1116
+ }
1117
+ if (line === '' && failed === false) return null
1118
+ return React.createElement('div', { className: 'dsh-imgvw-root' }, [
1119
+ React.createElement('div', {
1120
+ key: 'line',
1121
+ className: 'dsh-imgvw-bar',
1122
+ style: failed ? { color: 'var(--dsw-danger, #c53030)' } : { opacity: 0.85 },
1123
+ }, line === '' ? '(无输出)' : line),
1124
+ ])
1125
+ }
1126
+
1127
+ const inject = ['slots']
1128
+
1129
+ /**
1130
+ * Claim every image-bearing tool's keyed cell at priority -1.
1131
+ *
1132
+ * Each cell is keyed by the wire Tool name and these keys are either
1133
+ * covered by the shipped composition (read_image) or by dynamic fallback
1134
+ * cards; the lower priority shadows them, and if one of our entries ever
1135
+ * throws, the slot's error boundary abdicates it and the previous card
1136
+ * returns.
1137
+ */
1138
+ function apply(ctx) {
1139
+ const style = document.createElement('style')
1140
+ style.dataset.plugin = 'dsh-plugin-read-image-preview'
1141
+ style.textContent = CSS
1142
+ document.head.appendChild(style)
1143
+ ctx.effect(() => () => {
1144
+ style.remove()
1145
+ })
1146
+
1147
+ ctx.slots.inject('tool.call.toolview', () => {
1148
+ IMAGE_TOOL_KEYS.forEach((key) => {
1149
+ ctx.slots.register({
1150
+ name: 'tool.call.toolview',
1151
+ key,
1152
+ priority: -1,
1153
+ }, ImageToolCard)
1154
+ })
1155
+ })
1156
+ }
1157
+
1158
+ exports.apply = apply
1159
+ exports.inject = inject
1160
+ return module.exports
1161
+ },
1162
+ })