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.
@@ -0,0 +1,9 @@
1
+ # dsh-plugin-read-image-preview 自 wiring(dsh.bundle.patch 机制):安装即注册,
2
+ # 无需在 profile 的 cordis.patch.yml 手动 insert。
3
+ #
4
+ # 两半同属一行:node half(index.js)在回环上注册取图路由,browser half
5
+ # (client.js,dsh.client 声明)把 read_image 的展示位接管为常驻预览卡。
6
+ - insert:
7
+ - id: read-image-preview
8
+ name: dsh-plugin-read-image-preview
9
+ inject: [webServer, fs]
package/index.js ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Host half of the read_image preview plugin.
3
+ *
4
+ * `read_image` hands the model a normalized copy of a local file, and a browser
5
+ * slot view has no filesystem privileges of its own, so the bytes have to come
6
+ * back over the page's own transport. This half reads the file through the
7
+ * composed `fs` service, validates the container by magic bytes, and publishes
8
+ * it on two plugin-private loopback routes:
9
+ *
10
+ * - `GET /read-image-preview/resolve?path=…&cwd=…` — resolve one tool-supplied
11
+ * path and answer with a JSON-safe description plus a capability URL;
12
+ * - `GET /read-image-preview/<token>` — the bytes themselves, for `<img src>`.
13
+ *
14
+ * Only the four bitmap containers are served, and the sniffed media type — not
15
+ * the file extension — is what goes out in `Content-Type`, so a file that
16
+ * happens to be SVG or HTML can never be negotiated as script. A URL carries an
17
+ * opaque token rather than a path, so the page never hands the host an arbitrary
18
+ * path to read back.
19
+ *
20
+ * Both routes are plain HTTP on the plugin's own prefix; nothing is published
21
+ * into the Cordis service namespace, so a sibling plugin cannot collide with it.
22
+ *
23
+ * @module dsh-plugin-read-image-preview
24
+ */
25
+
26
+ /** Route prefix owned by this plugin. */
27
+ const ROUTE = '/read-image-preview'
28
+ /** Refuse to buffer an image larger than this, mirroring the read tool's own bound. */
29
+ const MAX_BYTES = 268435456
30
+ /** Header window large enough for a JPEG's leading segments and every other header. */
31
+ const HEAD_BYTES = 65536
32
+ /** Tokens are cheap; a card's worth of history is all anyone re-requests. */
33
+ const MAX_TOKENS = 64
34
+
35
+ const tokens = new Map()
36
+ let issued = 0
37
+
38
+ /**
39
+ * Identify the container from its leading bytes.
40
+ * @param {Uint8Array} bytes - the file's first bytes.
41
+ * @returns {string | null} the exact media type, or null for anything else.
42
+ */
43
+ function sniff(bytes) {
44
+ if (bytes.length >= 4 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) return 'image/png'
45
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg'
46
+ if (bytes.length >= 4 && bytes[0] === 71 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 56) return 'image/gif'
47
+ if (bytes.length >= 12 && bytes[0] === 82 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70
48
+ && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) return 'image/webp'
49
+ return null
50
+ }
51
+
52
+ /**
53
+ * Read the pixel dimensions out of the container header alone.
54
+ *
55
+ * The card sizes its loading placeholder to the real aspect ratio and prints the
56
+ * true size, both before the image itself has loaded, so this runs on the same
57
+ * header window as the sniff and never touches the whole file.
58
+ * @param {Uint8Array} bytes - the header window.
59
+ * @param {string} mediaType - the container already identified by {@link sniff}.
60
+ * @returns {{ width: number, height: number } | null} dimensions, or null when the header does not carry them.
61
+ */
62
+ function dimensionsOf(bytes, mediaType) {
63
+ try {
64
+ if (bytes.length < 24) return null
65
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
66
+ if (mediaType === 'image/png') return { width: view.getUint32(16), height: view.getUint32(20) }
67
+ if (mediaType === 'image/gif') return { width: view.getUint16(6, true), height: view.getUint16(8, true) }
68
+ if (mediaType === 'image/webp') {
69
+ // Only the extended (VP8X) form states a canvas size in the header.
70
+ if (bytes.length >= 30 && bytes[15] === 86) {
71
+ return { width: view.getUint16(26, true) & 16383, height: view.getUint16(28, true) & 16383 }
72
+ }
73
+ return null
74
+ }
75
+ if (mediaType === 'image/jpeg') {
76
+ let at = 2
77
+ while (at + 9 < bytes.length) {
78
+ if (bytes[at] !== 255) {
79
+ at += 1
80
+ continue
81
+ }
82
+ const marker = bytes[at + 1]
83
+ // Fill bytes and the standalone markers carry no length word.
84
+ if (marker === 255) {
85
+ at += 1
86
+ continue
87
+ }
88
+ if (marker >= 208 && marker <= 215) {
89
+ at += 2
90
+ continue
91
+ }
92
+ if (marker === 217 || marker === 218) return null
93
+ const length = view.getUint16(at + 2)
94
+ if (length < 2) return null
95
+ // Any SOFn frame header states the size; the arithmetic-coded forms do not.
96
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
97
+ return { height: view.getUint16(at + 5), width: view.getUint16(at + 7) }
98
+ }
99
+ at += 2 + length
100
+ }
101
+ }
102
+ } catch {
103
+ return null
104
+ }
105
+ return null
106
+ }
107
+
108
+ /**
109
+ * Mint or reuse the capability token for one resolved image.
110
+ *
111
+ * The same path asked for twice yields one token, so a re-render cannot grow the
112
+ * registry; the oldest entry is evicted past the cap.
113
+ * @param {string} path - the path as the tool received it.
114
+ * @param {string} cwd - the session workspace root used to resolve it.
115
+ * @param {string} mediaType - the sniffed container.
116
+ * @param {number} size - the file's byte length.
117
+ * @returns {string} the capability token.
118
+ */
119
+ function issue(path, cwd, mediaType, size) {
120
+ for (const [token, entry] of tokens) {
121
+ if (entry.path === path && entry.cwd === cwd && entry.mediaType === mediaType) return token
122
+ }
123
+ const token = Math.floor(Math.random() * 4294967296).toString(36)
124
+ + Math.floor(Date.now()).toString(36)
125
+ + (issued++).toString(36)
126
+ tokens.set(token, { path, cwd, mediaType, size })
127
+ if (tokens.size > MAX_TOKENS) {
128
+ const oldest = tokens.keys().next()
129
+ if (oldest.done !== true) tokens.delete(oldest.value)
130
+ }
131
+ return token
132
+ }
133
+
134
+ /**
135
+ * Read one query value from a request URL.
136
+ * @param {string} url - the request URL.
137
+ * @param {string} name - the parameter to read.
138
+ * @returns {string} the decoded value, or the empty string when absent.
139
+ */
140
+ function queryOf(url, name) {
141
+ const queryAt = url.indexOf('?')
142
+ if (queryAt === -1) return ''
143
+ for (const pair of url.slice(queryAt + 1).split('&')) {
144
+ const at = pair.indexOf('=')
145
+ if (at === -1) continue
146
+ if (pair.slice(0, at) !== name) continue
147
+ try {
148
+ return decodeURIComponent(pair.slice(at + 1).replace(/\+/g, ' '))
149
+ } catch {
150
+ return ''
151
+ }
152
+ }
153
+ return ''
154
+ }
155
+
156
+ /**
157
+ * Write a JSON response.
158
+ * @param {import('node:http').ServerResponse} res - the response to own.
159
+ * @param {number} status - the HTTP status.
160
+ * @param {object} body - a JSON-safe body.
161
+ */
162
+ function sendJson(res, status, body) {
163
+ const text = JSON.stringify(body)
164
+ res.writeHead(status, {
165
+ 'content-type': 'application/json; charset=utf-8',
166
+ 'content-length': String(Buffer.byteLength(text)),
167
+ })
168
+ res.end(text)
169
+ }
170
+
171
+ /**
172
+ * Write a short plain-text response.
173
+ * @param {import('node:http').ServerResponse} res - the response to own.
174
+ * @param {number} status - the HTTP status.
175
+ * @param {string} message - the body.
176
+ */
177
+ function sendText(res, status, message) {
178
+ res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' })
179
+ res.end(message)
180
+ }
181
+
182
+ /**
183
+ * Host plugin: publish the resolve endpoint and the image route.
184
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
185
+ */
186
+ export function apply(ctx) {
187
+ /** Resolve one query-supplied path into a JSON-safe description. */
188
+ const resolve = async (req, res) => {
189
+ try {
190
+ const url = typeof req.url === 'string' ? req.url : ''
191
+ const raw = queryOf(url, 'path')
192
+ if (raw.trim() === '') {
193
+ sendJson(res, 400, { ok: false, error: 'no path supplied' })
194
+ return
195
+ }
196
+ const cwd = queryOf(url, 'cwd')
197
+ const target = await ctx.fs.resolve(raw, cwd === '' ? undefined : { cwd })
198
+ const info = await ctx.fs.stat(target)
199
+ if (info === undefined) {
200
+ sendJson(res, 200, { ok: false, error: 'file not found: ' + raw })
201
+ return
202
+ }
203
+ if (info.type !== 'file') {
204
+ sendJson(res, 200, { ok: false, error: 'not a regular file: ' + raw })
205
+ return
206
+ }
207
+ const head = await ctx.fs.readByteRange(target, { offset: 0, length: HEAD_BYTES })
208
+ const mediaType = sniff(head)
209
+ if (mediaType === null) {
210
+ sendJson(res, 200, { ok: false, error: 'not a PNG/JPEG/WebP/GIF image' })
211
+ return
212
+ }
213
+ const size = typeof info.size === 'number' ? info.size : 0
214
+ const dimensions = dimensionsOf(head, mediaType)
215
+ sendJson(res, 200, {
216
+ ok: true,
217
+ mediaType,
218
+ bytes: size,
219
+ url: ROUTE + '/' + issue(raw, cwd, mediaType, size),
220
+ width: dimensions === null ? null : dimensions.width,
221
+ height: dimensions === null ? null : dimensions.height,
222
+ displayPath: typeof target.displayPath === 'string' ? target.displayPath : raw,
223
+ })
224
+ } catch (error) {
225
+ const message = error instanceof Error ? error.message : String(error)
226
+ sendJson(res, 200, { ok: false, error: message })
227
+ }
228
+ }
229
+
230
+ /** Serve the bytes one issued token stands for. */
231
+ const serve = async (req, res) => {
232
+ try {
233
+ const url = typeof req.url === 'string' ? req.url : ''
234
+ const queryAt = url.indexOf('?')
235
+ const pathname = queryAt === -1 ? url : url.slice(0, queryAt)
236
+ const token = pathname.slice(ROUTE.length + 1)
237
+ const entry = token === '' ? undefined : tokens.get(token)
238
+ if (entry === undefined) {
239
+ sendText(res, 404, 'unknown image token')
240
+ return
241
+ }
242
+ const target = await ctx.fs.resolve(entry.path, entry.cwd === '' ? undefined : { cwd: entry.cwd })
243
+ const info = await ctx.fs.stat(target)
244
+ if (info === undefined || info.type !== 'file') {
245
+ sendText(res, 404, 'image is gone')
246
+ return
247
+ }
248
+ const size = typeof info.size === 'number' ? info.size : entry.size
249
+ res.writeHead(200, {
250
+ 'content-type': entry.mediaType,
251
+ 'content-length': String(size),
252
+ 'cache-control': 'private, max-age=3600',
253
+ 'x-content-type-options': 'nosniff',
254
+ })
255
+ if (req.method === 'HEAD') {
256
+ res.end()
257
+ return
258
+ }
259
+ const bytes = await ctx.fs.readBytes(target, undefined, MAX_BYTES)
260
+ res.end(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength))
261
+ } catch (error) {
262
+ const message = error instanceof Error ? error.message : String(error)
263
+ try {
264
+ sendText(res, 500, 'image read failed: ' + message)
265
+ } catch {
266
+ /* the response is already gone; nothing left to say */
267
+ }
268
+ }
269
+ }
270
+
271
+ ctx.effect(
272
+ () => ctx.webServer.register({ kind: 'exact', path: ROUTE + '/resolve', handler: resolve }),
273
+ 'read-image-preview: resolve endpoint',
274
+ )
275
+ ctx.effect(
276
+ () => ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler: serve }),
277
+ 'read-image-preview: image route',
278
+ )
279
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "dsh-plugin-read-image-preview",
3
+ "version": "1.4.1",
4
+ "license": "MIT",
5
+ "keywords": ["dsh-plugin", "deepseek-harness", "dsh", "image", "preview", "screenshot", "tool-card", "ui"],
6
+ "publishConfig": { "registry": "https://registry.npmjs.org" },
7
+ "repository": { "type": "git", "url": "git+https://github.com/tofu-zyc/dsh-plugin-tofu-box.git", "directory": "dsh-plugin-read-image-preview" },
8
+ "bugs": { "url": "https://github.com/tofu-zyc/dsh-plugin-tofu-box/issues" },
9
+ "homepage": "https://github.com/tofu-zyc/dsh-plugin-tofu-box/tree/main/dsh-plugin-read-image-preview",
10
+ "description": "通用工具图像卡片:read_image 走宿主文件预览;任何结果携带 image 附件的工具(如 computer-use 截图)自动渲染同款缩略图。常驻缩略图、像素尺寸与格式、Ctrl+滚轮缩放与拖动平移、复制路径。接入新图像工具只需加一行 key。",
11
+ "type": "module",
12
+ "main": "index.js",
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./client": "./client.js",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "dsh": {
19
+ "bundle": {
20
+ "patch": "./cordis.patch.yml"
21
+ },
22
+ "client": {
23
+ "platform": "web",
24
+ "inject": [
25
+ "@deepseek-ai/dsh-client-ui-tool",
26
+ "@deepseek-ai/dsh-client-locale"
27
+ ]
28
+ }
29
+ },
30
+ "files": [
31
+ "client.js",
32
+ "index.js",
33
+ "cordis.patch.yml",
34
+ "README.md"
35
+ ]
36
+ }