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/LICENSE +21 -0
- package/README.md +64 -0
- package/client.js +1162 -0
- package/cordis.patch.yml +9 -0
- package/index.js +279 -0
- package/package.json +36 -0
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
|
+
})
|