refactor(ImageShowTool): download URLs to temp file, fix concurrency for kitty protocol
Browse filesURLs now downloaded to /tmp before passing to timg as file path instead of
piped stdin. Disable concurrency safety since kitty protocol sequences are
in-band and concurrent terminal writes corrupt each other.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
src/tools/ImageShowTool/ImageShowTool.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
import { execFileSync } from 'child_process'
|
|
|
|
| 2 |
import fs from 'node:fs'
|
| 3 |
import { z } from 'zod/v4'
|
| 4 |
import { Jimp } from "jimp";
|
| 5 |
import { buildTool, type ToolDef } from '../../Tool.js'
|
| 6 |
-
import { loadImageFromUrl } from "../../ink-picture/utils/jimpURL.ts";
|
| 7 |
import { lazySchema } from '../../utils/lazySchema.js'
|
| 8 |
import { whichSync } from '../../utils/which.js'
|
| 9 |
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
|
|
@@ -48,9 +48,34 @@ export function isUrl(path: string): boolean {
|
|
| 48 |
return path.startsWith("http://") || path.startsWith("https://");
|
| 49 |
}
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
export async function loadImage(path: string) {
|
| 52 |
if (isUrl(path)) {
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
} else {
|
| 55 |
// Read file to Buffer first, then pass to Jimp.read().
|
| 56 |
// Jimp v1.x has inconsistent path resolution in some runtimes
|
|
@@ -143,8 +168,9 @@ export const ImageShowTool = buildTool({
|
|
| 143 |
get outputSchema(): OutputSchema {
|
| 144 |
return outputSchema()
|
| 145 |
},
|
|
|
|
| 146 |
isConcurrencySafe() {
|
| 147 |
-
return
|
| 148 |
},
|
| 149 |
isReadOnly() {
|
| 150 |
return true
|
|
@@ -174,8 +200,17 @@ export const ImageShowTool = buildTool({
|
|
| 174 |
renderToolUseMessage,
|
| 175 |
renderToolResultMessage,
|
| 176 |
async call({ src }) {
|
|
|
|
| 177 |
try {
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
const rows = process.stdout.rows ?? 24
|
| 180 |
const dims = calculateDimensions(
|
| 181 |
image.bitmap.width,
|
|
@@ -188,13 +223,10 @@ export const ImageShowTool = buildTool({
|
|
| 188 |
try {
|
| 189 |
const timgPath = whichSync('timg')
|
| 190 |
if (timgPath) {
|
| 191 |
-
// Pipe image to timg via stdin, capture Kitty protocol output
|
| 192 |
-
const pngBuffer = await image.getBuffer("image/png")
|
| 193 |
kittySequence = execFileSync(
|
| 194 |
timgPath,
|
| 195 |
-
['-p', 'kitty', '-g', `${dims.width}x${dims.height}`,
|
| 196 |
{
|
| 197 |
-
input: pngBuffer,
|
| 198 |
encoding: 'utf8',
|
| 199 |
timeout: 30000,
|
| 200 |
maxBuffer: 50 * 1024 * 1024,
|
|
@@ -221,6 +253,10 @@ export const ImageShowTool = buildTool({
|
|
| 221 |
success: false,
|
| 222 |
} satisfies ImageShowOutput,
|
| 223 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
}
|
| 225 |
},
|
| 226 |
mapToolResultToToolResultBlockParam(output: ImageShowOutput, toolUseID: string) {
|
|
|
|
| 1 |
import { execFileSync } from 'child_process'
|
| 2 |
+
import crypto from 'node:crypto'
|
| 3 |
import fs from 'node:fs'
|
| 4 |
import { z } from 'zod/v4'
|
| 5 |
import { Jimp } from "jimp";
|
| 6 |
import { buildTool, type ToolDef } from '../../Tool.js'
|
|
|
|
| 7 |
import { lazySchema } from '../../utils/lazySchema.js'
|
| 8 |
import { whichSync } from '../../utils/which.js'
|
| 9 |
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
|
|
|
|
| 48 |
return path.startsWith("http://") || path.startsWith("https://");
|
| 49 |
}
|
| 50 |
|
| 51 |
+
/**
|
| 52 |
+
* Download a URL to a temporary file, return the path.
|
| 53 |
+
* Caller should delete the file after use.
|
| 54 |
+
*/
|
| 55 |
+
export async function downloadToTemp(url: string): Promise<string> {
|
| 56 |
+
const res = await fetch(url, {
|
| 57 |
+
headers: {
|
| 58 |
+
'User-Agent': 'Mozilla/5.0 (compatible; ImageShowTool/1.0)',
|
| 59 |
+
'Accept': 'image/*',
|
| 60 |
+
},
|
| 61 |
+
})
|
| 62 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
| 63 |
+
const buffer = Buffer.from(await res.arrayBuffer())
|
| 64 |
+
const hash = crypto.createHash('md5').update(url).digest('hex')
|
| 65 |
+
const tmp = `/tmp/img_${hash}`
|
| 66 |
+
fs.writeFileSync(tmp, buffer)
|
| 67 |
+
return tmp
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
export async function loadImage(path: string) {
|
| 71 |
if (isUrl(path)) {
|
| 72 |
+
// Download to temp file, then read with Jimp
|
| 73 |
+
const tmp = await downloadToTemp(path)
|
| 74 |
+
try {
|
| 75 |
+
return await Jimp.read(fs.readFileSync(tmp))
|
| 76 |
+
} finally {
|
| 77 |
+
fs.unlinkSync(tmp)
|
| 78 |
+
}
|
| 79 |
} else {
|
| 80 |
// Read file to Buffer first, then pass to Jimp.read().
|
| 81 |
// Jimp v1.x has inconsistent path resolution in some runtimes
|
|
|
|
| 168 |
get outputSchema(): OutputSchema {
|
| 169 |
return outputSchema()
|
| 170 |
},
|
| 171 |
+
// Kitty 协议序列是带内数据,并发写入终端会互相覆盖导致图片不显示
|
| 172 |
isConcurrencySafe() {
|
| 173 |
+
return false
|
| 174 |
},
|
| 175 |
isReadOnly() {
|
| 176 |
return true
|
|
|
|
| 200 |
renderToolUseMessage,
|
| 201 |
renderToolResultMessage,
|
| 202 |
async call({ src }) {
|
| 203 |
+
let tmpFile: string | undefined
|
| 204 |
try {
|
| 205 |
+
// For URLs: download to temp file so timg can read it directly
|
| 206 |
+
let imagePath = src
|
| 207 |
+
if (isUrl(src)) {
|
| 208 |
+
tmpFile = await downloadToTemp(src)
|
| 209 |
+
imagePath = tmpFile
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// Read with Jimp for dimension calculation (from buffer, no MIME restriction)
|
| 213 |
+
const image = await Jimp.read(fs.readFileSync(imagePath))
|
| 214 |
const rows = process.stdout.rows ?? 24
|
| 215 |
const dims = calculateDimensions(
|
| 216 |
image.bitmap.width,
|
|
|
|
| 223 |
try {
|
| 224 |
const timgPath = whichSync('timg')
|
| 225 |
if (timgPath) {
|
|
|
|
|
|
|
| 226 |
kittySequence = execFileSync(
|
| 227 |
timgPath,
|
| 228 |
+
['-p', 'kitty', '-g', `${dims.width}x${dims.height}`, imagePath],
|
| 229 |
{
|
|
|
|
| 230 |
encoding: 'utf8',
|
| 231 |
timeout: 30000,
|
| 232 |
maxBuffer: 50 * 1024 * 1024,
|
|
|
|
| 253 |
success: false,
|
| 254 |
} satisfies ImageShowOutput,
|
| 255 |
}
|
| 256 |
+
} finally {
|
| 257 |
+
if (tmpFile) {
|
| 258 |
+
try { fs.unlinkSync(tmpFile) } catch {}
|
| 259 |
+
}
|
| 260 |
}
|
| 261 |
},
|
| 262 |
mapToolResultToToolResultBlockParam(output: ImageShowOutput, toolUseID: string) {
|