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/cordis.patch.yml
ADDED
|
@@ -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
|
+
}
|