mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-07-09 17:28:45 +00:00
fix(coding-agent): use async operations in tools
This commit is contained in:
parent
9b62f1f87c
commit
e9146a5ff7
17 changed files with 706 additions and 398 deletions
|
|
@ -31,7 +31,7 @@
|
|||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets",
|
||||
"build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js --outfile dist/pi && npm run copy-binary-assets",
|
||||
"build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile dist/pi && npm run copy-binary-assets",
|
||||
"copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/",
|
||||
"copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/",
|
||||
"test": "vitest --run",
|
||||
|
|
|
|||
|
|
@ -50,13 +50,12 @@ export async function processFileArguments(fileArgs: string[], options?: Process
|
|||
if (mimeType) {
|
||||
// Handle image file
|
||||
const content = await readFile(absolutePath);
|
||||
const base64Content = content.toString("base64");
|
||||
|
||||
let attachment: ImageContent;
|
||||
let dimensionNote: string | undefined;
|
||||
|
||||
if (autoResizeImages) {
|
||||
const resized = await resizeImage({ type: "image", data: base64Content, mimeType });
|
||||
const resized = await resizeImage(content, mimeType);
|
||||
if (!resized) {
|
||||
text += `<file name="${absolutePath}">[Image omitted: could not be resized below the inline image size limit.]</file>\n`;
|
||||
continue;
|
||||
|
|
@ -71,7 +70,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process
|
|||
attachment = {
|
||||
type: "image",
|
||||
mimeType,
|
||||
data: base64Content,
|
||||
data: content.toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { constants } from "node:fs";
|
||||
import { access as fsAccess } from "node:fs/promises";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
||||
import { spawn } from "child_process";
|
||||
|
|
@ -66,62 +67,70 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas
|
|||
return {
|
||||
exec: (command, cwd, { onData, signal, timeout, env }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { shell, args } = getShellConfig(options?.shellPath);
|
||||
if (!existsSync(cwd)) {
|
||||
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
|
||||
return;
|
||||
}
|
||||
const child = spawn(shell, [...args, command], {
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
env: env ?? getShellEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (child.pid) trackDetachedChildPid(child.pid);
|
||||
let timedOut = false;
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
// Set timeout if provided.
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
}, timeout * 1000);
|
||||
}
|
||||
// Stream stdout and stderr.
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", onData);
|
||||
// Handle abort signal by killing the entire process tree.
|
||||
const onAbort = () => {
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
// Handle shell spawn errors and wait for the process to terminate without hanging
|
||||
// on inherited stdio handles held by detached descendants.
|
||||
waitForChildProcess(child)
|
||||
.then((code) => {
|
||||
if (child.pid) untrackDetachedChildPid(child.pid);
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
if (timedOut) {
|
||||
reject(new Error(`timeout:${timeout}`));
|
||||
return;
|
||||
}
|
||||
resolve({ exitCode: code });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (child.pid) untrackDetachedChildPid(child.pid);
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
reject(err);
|
||||
void (async () => {
|
||||
const { shell, args } = getShellConfig(options?.shellPath);
|
||||
try {
|
||||
await fsAccess(cwd, constants.F_OK);
|
||||
} catch {
|
||||
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
|
||||
return;
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
const child = spawn(shell, [...args, command], {
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
env: env ?? getShellEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (child.pid) trackDetachedChildPid(child.pid);
|
||||
let timedOut = false;
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
// Set timeout if provided.
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
}, timeout * 1000);
|
||||
}
|
||||
// Stream stdout and stderr.
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", onData);
|
||||
// Handle abort signal by killing the entire process tree.
|
||||
const onAbort = () => {
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
// Handle shell spawn errors and wait for the process to terminate without hanging
|
||||
// on inherited stdio handles held by detached descendants.
|
||||
waitForChildProcess(child)
|
||||
.then((code) => {
|
||||
if (child.pid) untrackDetachedChildPid(child.pid);
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
if (timedOut) {
|
||||
reject(new Error(`timeout:${timeout}`));
|
||||
return;
|
||||
}
|
||||
resolve({ exitCode: code });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (child.pid) untrackDetachedChildPid(child.pid);
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
reject(err);
|
||||
});
|
||||
})().catch((err: unknown) => reject(err instanceof Error ? err : new Error(String(err))));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -313,113 +313,63 @@ export function createEditToolDefinition(
|
|||
const { path, edits } = validateEditInput(input);
|
||||
const absolutePath = resolveToCwd(path, cwd);
|
||||
|
||||
return withFileMutationQueue(
|
||||
absolutePath,
|
||||
() =>
|
||||
new Promise<{
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: EditToolDetails | undefined;
|
||||
}>((resolve, reject) => {
|
||||
// Check if already aborted.
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
return withFileMutationQueue(absolutePath, async () => {
|
||||
let aborted = signal?.aborted ?? false;
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
};
|
||||
const throwIfAborted = (): void => {
|
||||
if (aborted || signal?.aborted) {
|
||||
throw new Error("Operation aborted");
|
||||
}
|
||||
};
|
||||
|
||||
let aborted = false;
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
throwIfAborted();
|
||||
|
||||
// Set up abort handler.
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
};
|
||||
// Check if file exists.
|
||||
try {
|
||||
await ops.access(absolutePath);
|
||||
} catch (error: unknown) {
|
||||
throwIfAborted();
|
||||
const errorMessage =
|
||||
error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
|
||||
throw new Error(`Could not edit file: ${path}. ${errorMessage}.`);
|
||||
}
|
||||
throwIfAborted();
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
// Read the file.
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
throwIfAborted();
|
||||
|
||||
// Perform the edit operation.
|
||||
void (async () => {
|
||||
try {
|
||||
// Check if file exists.
|
||||
try {
|
||||
await ops.access(absolutePath);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
reject(new Error(`Could not edit file: ${path}. ${errorMessage}.`));
|
||||
return;
|
||||
}
|
||||
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
|
||||
const rawContent = buffer.toString("utf-8");
|
||||
const { bom, text: content } = stripBom(rawContent);
|
||||
const originalEnding = detectLineEnding(content);
|
||||
const normalizedContent = normalizeToLF(content);
|
||||
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
|
||||
throwIfAborted();
|
||||
|
||||
// Check if aborted before reading.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
||||
await ops.writeFile(absolutePath, finalContent);
|
||||
throwIfAborted();
|
||||
|
||||
// Read the file.
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
const rawContent = buffer.toString("utf-8");
|
||||
|
||||
// Check if aborted after reading.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
|
||||
const { bom, text: content } = stripBom(rawContent);
|
||||
const originalEnding = detectLineEnding(content);
|
||||
const normalizedContent = normalizeToLF(content);
|
||||
const { baseContent, newContent } = applyEditsToNormalizedContent(
|
||||
normalizedContent,
|
||||
edits,
|
||||
path,
|
||||
);
|
||||
|
||||
// Check if aborted before writing.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
||||
await ops.writeFile(absolutePath, finalContent);
|
||||
|
||||
// Check if aborted after writing.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up abort handler.
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
const diffResult = generateDiffString(baseContent, newContent);
|
||||
const patch = generateUnifiedPatch(path, baseContent, newContent);
|
||||
resolve({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
|
||||
},
|
||||
],
|
||||
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// Clean up abort handler.
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!aborted) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}),
|
||||
);
|
||||
const diffResult = generateDiffString(baseContent, newContent);
|
||||
const patch = generateUnifiedPatch(path, baseContent, newContent);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
|
||||
},
|
||||
],
|
||||
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
||||
};
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
});
|
||||
},
|
||||
renderCall(args, theme, context) {
|
||||
const component = getEditCallRenderComponent(context.state, context.lastComponent);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
import { realpathSync } from "node:fs";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const fileMutationQueues = new Map<string, Promise<void>>();
|
||||
let registrationQueue = Promise.resolve();
|
||||
|
||||
function getMutationQueueKey(filePath: string): string {
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error.code === "ENOENT" || error.code === "ENOTDIR")
|
||||
);
|
||||
}
|
||||
|
||||
async function getMutationQueueKey(filePath: string): Promise<string> {
|
||||
const resolvedPath = resolve(filePath);
|
||||
try {
|
||||
return realpathSync.native(resolvedPath);
|
||||
} catch {
|
||||
return resolvedPath;
|
||||
return await realpath(resolvedPath);
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
return resolvedPath;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -17,16 +30,25 @@ function getMutationQueueKey(filePath: string): string {
|
|||
* Operations for different files still run in parallel.
|
||||
*/
|
||||
export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
||||
const key = getMutationQueueKey(filePath);
|
||||
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
|
||||
const registration = registrationQueue.then(async () => {
|
||||
const key = await getMutationQueueKey(filePath);
|
||||
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
|
||||
|
||||
let releaseNext!: () => void;
|
||||
const nextQueue = new Promise<void>((resolveQueue) => {
|
||||
releaseNext = resolveQueue;
|
||||
let releaseNext!: () => void;
|
||||
const nextQueue = new Promise<void>((resolveQueue) => {
|
||||
releaseNext = resolveQueue;
|
||||
});
|
||||
const chainedQueue = currentQueue.then(() => nextQueue);
|
||||
fileMutationQueues.set(key, chainedQueue);
|
||||
|
||||
return { key, currentQueue, chainedQueue, releaseNext };
|
||||
});
|
||||
const chainedQueue = currentQueue.then(() => nextQueue);
|
||||
fileMutationQueues.set(key, chainedQueue);
|
||||
registrationQueue = registration.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
const { key, currentQueue, chainedQueue, releaseNext } = await registration;
|
||||
await currentQueue;
|
||||
try {
|
||||
return await fn();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { constants } from "node:fs";
|
||||
import { access as fsAccess } from "node:fs/promises";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { spawn } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { type Static, Type } from "typebox";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
|
||||
|
|
@ -46,7 +47,14 @@ export interface FindOperations {
|
|||
}
|
||||
|
||||
const defaultFindOperations: FindOperations = {
|
||||
exists: existsSync,
|
||||
exists: async (absolutePath) => {
|
||||
try {
|
||||
await fsAccess(absolutePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
// This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided.
|
||||
glob: () => [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { spawn } from "child_process";
|
||||
import { readFileSync, statSync } from "fs";
|
||||
import path from "path";
|
||||
import { type Static, Type } from "typebox";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
|
||||
|
|
@ -55,8 +55,8 @@ export interface GrepOperations {
|
|||
}
|
||||
|
||||
const defaultGrepOperations: GrepOperations = {
|
||||
isDirectory: (p) => statSync(p).isDirectory(),
|
||||
readFile: (p) => readFileSync(p, "utf-8"),
|
||||
isDirectory: async (p) => (await fsStat(p)).isDirectory(),
|
||||
readFile: (p) => fsReadFile(p, "utf-8"),
|
||||
};
|
||||
|
||||
export interface GrepToolOptions {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { constants } from "node:fs";
|
||||
import { access as fsAccess, readdir as fsReaddir, stat as fsStat } from "node:fs/promises";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { existsSync, readdirSync, statSync } from "fs";
|
||||
import nodePath from "path";
|
||||
import { type Static, Type } from "typebox";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
|
||||
|
|
@ -38,9 +39,16 @@ export interface LsOperations {
|
|||
}
|
||||
|
||||
const defaultLsOperations: LsOperations = {
|
||||
exists: existsSync,
|
||||
stat: statSync,
|
||||
readdir: readdirSync,
|
||||
exists: async (absolutePath) => {
|
||||
try {
|
||||
await fsAccess(absolutePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
stat: fsStat,
|
||||
readdir: fsReaddir,
|
||||
};
|
||||
|
||||
export interface LsToolOptions {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { accessSync, constants } from "node:fs";
|
||||
import { access } from "node:fs/promises";
|
||||
import { normalizePath, resolvePath } from "../../utils/paths.ts";
|
||||
|
||||
const NARROW_NO_BREAK_SPACE = "\u202F";
|
||||
|
|
@ -27,6 +28,15 @@ function fileExists(filePath: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
async function fileExistsAsync(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function expandPath(filePath: string): string {
|
||||
return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
|
||||
}
|
||||
|
|
@ -72,3 +82,37 @@ export function resolveReadPath(filePath: string, cwd: string): string {
|
|||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function resolveReadPathAsync(filePath: string, cwd: string): Promise<string> {
|
||||
const resolved = resolveToCwd(filePath, cwd);
|
||||
|
||||
if (await fileExistsAsync(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Try macOS AM/PM variant (narrow no-break space before AM/PM)
|
||||
const amPmVariant = tryMacOSScreenshotPath(resolved);
|
||||
if (amPmVariant !== resolved && (await fileExistsAsync(amPmVariant))) {
|
||||
return amPmVariant;
|
||||
}
|
||||
|
||||
// Try NFD variant (macOS stores filenames in NFD form)
|
||||
const nfdVariant = tryNFDVariant(resolved);
|
||||
if (nfdVariant !== resolved && (await fileExistsAsync(nfdVariant))) {
|
||||
return nfdVariant;
|
||||
}
|
||||
|
||||
// Try curly quote variant (macOS uses U+2019 in screenshot names)
|
||||
const curlyVariant = tryCurlyQuoteVariant(resolved);
|
||||
if (curlyVariant !== resolved && (await fileExistsAsync(curlyVariant))) {
|
||||
return curlyVariant;
|
||||
}
|
||||
|
||||
// Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran")
|
||||
const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);
|
||||
if (nfdCurlyVariant !== resolved && (await fileExistsAsync(nfdCurlyVariant))) {
|
||||
return nfdCurlyVariant;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { formatDimensionNote, resizeImage } from "../../utils/image-resize.ts";
|
|||
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts";
|
||||
import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts";
|
||||
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
|
||||
import { resolveReadPath } from "./path-utils.ts";
|
||||
import { resolveReadPathAsync, resolveToCwd } from "./path-utils.ts";
|
||||
import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.ts";
|
||||
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
|
||||
|
|
@ -124,7 +124,7 @@ function getCompactReadClassification(
|
|||
const rawPath = str(args?.file_path ?? args?.path);
|
||||
if (!rawPath) return undefined;
|
||||
|
||||
const absolutePath = resolveReadPath(rawPath, cwd);
|
||||
const absolutePath = resolveToCwd(rawPath, cwd);
|
||||
const fileName = basename(absolutePath);
|
||||
if (fileName === "SKILL.md") {
|
||||
return { kind: "skill", label: basename(dirname(absolutePath)) || fileName };
|
||||
|
|
@ -223,7 +223,6 @@ export function createReadToolDefinition(
|
|||
_onUpdate?,
|
||||
ctx?,
|
||||
) {
|
||||
const absolutePath = resolveReadPath(path, cwd);
|
||||
return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>(
|
||||
(resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
|
|
@ -239,6 +238,8 @@ export function createReadToolDefinition(
|
|||
|
||||
(async () => {
|
||||
try {
|
||||
const absolutePath = await resolveReadPathAsync(path, cwd);
|
||||
if (aborted) return;
|
||||
// Check if file exists and is readable.
|
||||
await ops.access(absolutePath);
|
||||
if (aborted) return;
|
||||
|
|
@ -249,10 +250,9 @@ export function createReadToolDefinition(
|
|||
if (mimeType) {
|
||||
// Read image as binary.
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
const base64 = buffer.toString("base64");
|
||||
if (autoResizeImages) {
|
||||
// Resize image if needed before sending it back to the model.
|
||||
const resized = await resizeImage({ type: "image", data: base64, mimeType });
|
||||
const resized = await resizeImage(buffer, mimeType);
|
||||
if (!resized) {
|
||||
let textNote = `Read image file [${mimeType}]\n[Image omitted: could not be resized below the inline image size limit.]`;
|
||||
if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`;
|
||||
|
|
@ -272,7 +272,7 @@ export function createReadToolDefinition(
|
|||
if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`;
|
||||
content = [
|
||||
{ type: "text", text: textNote },
|
||||
{ type: "image", data: base64, mimeType },
|
||||
{ type: "image", data: buffer.toString("base64"), mimeType },
|
||||
];
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -200,44 +200,36 @@ export function createWriteToolDefinition(
|
|||
) {
|
||||
const absolutePath = resolveToCwd(path, cwd);
|
||||
const dir = dirname(absolutePath);
|
||||
return withFileMutationQueue(
|
||||
absolutePath,
|
||||
() =>
|
||||
new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>(
|
||||
(resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
let aborted = false;
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
(async () => {
|
||||
try {
|
||||
// Create parent directories if needed.
|
||||
await ops.mkdir(dir);
|
||||
if (aborted) return;
|
||||
// Write the file contents.
|
||||
await ops.writeFile(absolutePath, content);
|
||||
if (aborted) return;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve({
|
||||
content: [
|
||||
{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` },
|
||||
],
|
||||
details: undefined,
|
||||
});
|
||||
} catch (error: any) {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (!aborted) reject(error);
|
||||
}
|
||||
})();
|
||||
},
|
||||
),
|
||||
);
|
||||
return withFileMutationQueue(absolutePath, async () => {
|
||||
let aborted = signal?.aborted ?? false;
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
};
|
||||
const throwIfAborted = (): void => {
|
||||
if (aborted || signal?.aborted) {
|
||||
throw new Error("Operation aborted");
|
||||
}
|
||||
};
|
||||
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
throwIfAborted();
|
||||
// Create parent directories if needed.
|
||||
await ops.mkdir(dir);
|
||||
throwIfAborted();
|
||||
|
||||
// Write the file contents.
|
||||
await ops.writeFile(absolutePath, content);
|
||||
throwIfAborted();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
});
|
||||
},
|
||||
renderCall(args, theme, context) {
|
||||
const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;
|
||||
|
|
|
|||
164
packages/coding-agent/src/utils/image-resize-core.ts
Normal file
164
packages/coding-agent/src/utils/image-resize-core.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { applyExifOrientation } from "./exif-orientation.ts";
|
||||
import { loadPhoton } from "./photon.ts";
|
||||
|
||||
export interface ImageResizeOptions {
|
||||
maxWidth?: number; // Default: 2000
|
||||
maxHeight?: number; // Default: 2000
|
||||
maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit)
|
||||
jpegQuality?: number; // Default: 80
|
||||
}
|
||||
|
||||
export interface ResizedImage {
|
||||
data: string; // base64
|
||||
mimeType: string;
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
wasResized: boolean;
|
||||
}
|
||||
|
||||
// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
|
||||
const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
|
||||
|
||||
const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
|
||||
maxWidth: 2000,
|
||||
maxHeight: 2000,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
jpegQuality: 80,
|
||||
};
|
||||
|
||||
interface EncodedCandidate {
|
||||
data: string;
|
||||
encodedSize: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate {
|
||||
const data = Buffer.from(buffer).toString("base64");
|
||||
return {
|
||||
data,
|
||||
encodedSize: Buffer.byteLength(data, "utf-8"),
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image to fit within the specified max dimensions and encoded file size.
|
||||
* Returns null if the image cannot be resized below maxBytes.
|
||||
*
|
||||
* Uses Photon (Rust/WASM) for image processing. If Photon is not available,
|
||||
* returns null.
|
||||
*
|
||||
* Strategy for staying under maxBytes:
|
||||
* 1. First resize to maxWidth/maxHeight
|
||||
* 2. Try both PNG and JPEG formats, pick the smaller one
|
||||
* 3. If still too large, try JPEG with decreasing quality
|
||||
* 4. If still too large, progressively reduce dimensions until 1x1
|
||||
*/
|
||||
export async function resizeImageInProcess(
|
||||
inputBytes: Uint8Array,
|
||||
mimeType: string,
|
||||
options?: ImageResizeOptions,
|
||||
): Promise<ResizedImage | null> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4;
|
||||
|
||||
const photon = await loadPhoton();
|
||||
if (!photon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;
|
||||
try {
|
||||
const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);
|
||||
image = applyExifOrientation(photon, rawImage, inputBytes);
|
||||
if (image !== rawImage) rawImage.free();
|
||||
|
||||
const originalWidth = image.get_width();
|
||||
const originalHeight = image.get_height();
|
||||
const format = mimeType.split("/")[1] ?? "png";
|
||||
|
||||
// Check if already within all limits (dimensions AND encoded size)
|
||||
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {
|
||||
return {
|
||||
data: Buffer.from(inputBytes).toString("base64"),
|
||||
mimeType: mimeType || `image/${format}`,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
wasResized: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate initial dimensions respecting max limits
|
||||
let targetWidth = originalWidth;
|
||||
let targetHeight = originalHeight;
|
||||
|
||||
if (targetWidth > opts.maxWidth) {
|
||||
targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
|
||||
targetWidth = opts.maxWidth;
|
||||
}
|
||||
if (targetHeight > opts.maxHeight) {
|
||||
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
|
||||
targetHeight = opts.maxHeight;
|
||||
}
|
||||
|
||||
function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] {
|
||||
const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);
|
||||
|
||||
try {
|
||||
const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")];
|
||||
for (const quality of jpegQualities) {
|
||||
candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
|
||||
}
|
||||
return candidates;
|
||||
} finally {
|
||||
resized.free();
|
||||
}
|
||||
}
|
||||
|
||||
const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
|
||||
let currentWidth = targetWidth;
|
||||
let currentHeight = targetHeight;
|
||||
|
||||
while (true) {
|
||||
const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.encodedSize < opts.maxBytes) {
|
||||
return {
|
||||
data: candidate.data,
|
||||
mimeType: candidate.mimeType,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
width: currentWidth,
|
||||
height: currentHeight,
|
||||
wasResized: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (currentWidth === 1 && currentHeight === 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
|
||||
const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
|
||||
if (nextWidth === currentWidth && nextHeight === currentHeight) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentWidth = nextWidth;
|
||||
currentHeight = nextHeight;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (image) {
|
||||
image.free();
|
||||
}
|
||||
}
|
||||
}
|
||||
42
packages/coding-agent/src/utils/image-resize-worker.ts
Normal file
42
packages/coding-agent/src/utils/image-resize-worker.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { parentPort } from "node:worker_threads";
|
||||
import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts";
|
||||
|
||||
interface ResizeImageWorkerRequest {
|
||||
inputBytes: Uint8Array;
|
||||
mimeType: string;
|
||||
options?: ImageResizeOptions;
|
||||
}
|
||||
|
||||
interface ResizeImageWorkerResponse {
|
||||
result?: ResizedImage | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function isResizeImageWorkerRequest(value: unknown): value is ResizeImageWorkerRequest {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return record.inputBytes instanceof Uint8Array && typeof record.mimeType === "string";
|
||||
}
|
||||
|
||||
const port = parentPort;
|
||||
if (!port) {
|
||||
throw new Error("image resize worker requires parentPort");
|
||||
}
|
||||
|
||||
port.once("message", (message: unknown) => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (!isResizeImageWorkerRequest(message)) {
|
||||
throw new Error("Invalid image resize worker request");
|
||||
}
|
||||
const result = await resizeImageInProcess(message.inputBytes, message.mimeType, message.options);
|
||||
const response: ResizeImageWorkerResponse = { result };
|
||||
port.postMessage(response);
|
||||
} catch (error) {
|
||||
const response: ResizeImageWorkerResponse = {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
port.postMessage(response);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
@ -1,165 +1,96 @@
|
|||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
import { applyExifOrientation } from "./exif-orientation.ts";
|
||||
import { loadPhoton } from "./photon.ts";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts";
|
||||
|
||||
export interface ImageResizeOptions {
|
||||
maxWidth?: number; // Default: 2000
|
||||
maxHeight?: number; // Default: 2000
|
||||
maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit)
|
||||
jpegQuality?: number; // Default: 80
|
||||
export type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts";
|
||||
|
||||
interface ResizeImageWorkerResponse {
|
||||
result?: ResizedImage | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ResizedImage {
|
||||
data: string; // base64
|
||||
mimeType: string;
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
wasResized: boolean;
|
||||
function toTransferableBytes(input: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
// Transfer detaches the buffer, so transfer a worker-owned copy and leave the
|
||||
// caller's bytes intact.
|
||||
return new Uint8Array(input);
|
||||
}
|
||||
|
||||
// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
|
||||
const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
|
||||
|
||||
const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
|
||||
maxWidth: 2000,
|
||||
maxHeight: 2000,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
jpegQuality: 80,
|
||||
};
|
||||
|
||||
interface EncodedCandidate {
|
||||
data: string;
|
||||
encodedSize: number;
|
||||
mimeType: string;
|
||||
function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse {
|
||||
return value !== null && typeof value === "object";
|
||||
}
|
||||
|
||||
function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate {
|
||||
const data = Buffer.from(buffer).toString("base64");
|
||||
return {
|
||||
data,
|
||||
encodedSize: Buffer.byteLength(data, "utf-8"),
|
||||
mimeType,
|
||||
};
|
||||
function createResizeWorker(): Worker {
|
||||
const isTypeScriptRuntime = import.meta.url.endsWith(".ts");
|
||||
const workerUrl = new URL(
|
||||
isTypeScriptRuntime ? "./image-resize-worker.ts" : "./image-resize-worker.js",
|
||||
import.meta.url,
|
||||
);
|
||||
return new Worker(workerUrl);
|
||||
}
|
||||
|
||||
async function resizeImageInWorker(
|
||||
inputBytes: Uint8Array,
|
||||
mimeType: string,
|
||||
options?: ImageResizeOptions,
|
||||
): Promise<ResizedImage | null> {
|
||||
const worker = createResizeWorker();
|
||||
try {
|
||||
const inputBytesForWorker = toTransferableBytes(inputBytes);
|
||||
return await new Promise<ResizedImage | null>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (result: ResizedImage | null): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
const fail = (error: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
worker.once("message", (message: unknown) => {
|
||||
if (!isResizeImageWorkerResponse(message)) {
|
||||
fail(new Error("Invalid image resize worker response"));
|
||||
return;
|
||||
}
|
||||
if (message.error) {
|
||||
fail(new Error(message.error));
|
||||
return;
|
||||
}
|
||||
settle(message.result ?? null);
|
||||
});
|
||||
worker.once("error", fail);
|
||||
worker.once("exit", (code) => {
|
||||
if (!settled) {
|
||||
fail(new Error(`Image resize worker exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
worker.postMessage(
|
||||
{
|
||||
inputBytes: inputBytesForWorker,
|
||||
mimeType,
|
||||
options,
|
||||
},
|
||||
[inputBytesForWorker.buffer],
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
void worker.terminate().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image to fit within the specified max dimensions and encoded file size.
|
||||
* Returns null if the image cannot be resized below maxBytes.
|
||||
*
|
||||
* Uses Photon (Rust/WASM) for image processing. If Photon is not available,
|
||||
* returns null.
|
||||
*
|
||||
* Strategy for staying under maxBytes:
|
||||
* 1. First resize to maxWidth/maxHeight
|
||||
* 2. Try both PNG and JPEG formats, pick the smaller one
|
||||
* 3. If still too large, try JPEG with decreasing quality
|
||||
* 4. If still too large, progressively reduce dimensions until 1x1
|
||||
* Runs Photon in a worker thread so WASM decoding, resizing, and encoding do not
|
||||
* block the TUI event loop. Worker failures are propagated instead of retried on
|
||||
* the main thread.
|
||||
*/
|
||||
export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage | null> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const inputBuffer = Buffer.from(img.data, "base64");
|
||||
const inputBase64Size = Buffer.byteLength(img.data, "utf-8");
|
||||
|
||||
const photon = await loadPhoton();
|
||||
if (!photon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;
|
||||
try {
|
||||
const inputBytes = new Uint8Array(inputBuffer);
|
||||
const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);
|
||||
image = applyExifOrientation(photon, rawImage, inputBytes);
|
||||
if (image !== rawImage) rawImage.free();
|
||||
|
||||
const originalWidth = image.get_width();
|
||||
const originalHeight = image.get_height();
|
||||
const format = img.mimeType?.split("/")[1] ?? "png";
|
||||
|
||||
// Check if already within all limits (dimensions AND encoded size)
|
||||
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {
|
||||
return {
|
||||
data: img.data,
|
||||
mimeType: img.mimeType ?? `image/${format}`,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
wasResized: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate initial dimensions respecting max limits
|
||||
let targetWidth = originalWidth;
|
||||
let targetHeight = originalHeight;
|
||||
|
||||
if (targetWidth > opts.maxWidth) {
|
||||
targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
|
||||
targetWidth = opts.maxWidth;
|
||||
}
|
||||
if (targetHeight > opts.maxHeight) {
|
||||
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
|
||||
targetHeight = opts.maxHeight;
|
||||
}
|
||||
|
||||
function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] {
|
||||
const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);
|
||||
|
||||
try {
|
||||
const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")];
|
||||
for (const quality of jpegQualities) {
|
||||
candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
|
||||
}
|
||||
return candidates;
|
||||
} finally {
|
||||
resized.free();
|
||||
}
|
||||
}
|
||||
|
||||
const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
|
||||
let currentWidth = targetWidth;
|
||||
let currentHeight = targetHeight;
|
||||
|
||||
while (true) {
|
||||
const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.encodedSize < opts.maxBytes) {
|
||||
return {
|
||||
data: candidate.data,
|
||||
mimeType: candidate.mimeType,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
width: currentWidth,
|
||||
height: currentHeight,
|
||||
wasResized: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (currentWidth === 1 && currentHeight === 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
|
||||
const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
|
||||
if (nextWidth === currentWidth && nextHeight === currentHeight) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentWidth = nextWidth;
|
||||
currentHeight = nextHeight;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (image) {
|
||||
image.free();
|
||||
}
|
||||
}
|
||||
export async function resizeImage(
|
||||
inputBytes: Uint8Array,
|
||||
mimeType: string,
|
||||
options?: ImageResizeOptions,
|
||||
): Promise<ResizedImage | null> {
|
||||
return resizeImageInWorker(inputBytes, mimeType, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -10,6 +10,18 @@ function delay(ms: number): Promise<void> {
|
|||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function resolvesWithin(promise: Promise<unknown>, ms: number): Promise<boolean> {
|
||||
return Promise.race([promise.then(() => true), delay(ms).then(() => false)]);
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
|
|
@ -160,4 +172,103 @@ describe("built-in edit and write tools", () => {
|
|||
const content = await readFile(filePath, "utf8");
|
||||
expect(content).toBe("replacement\n");
|
||||
});
|
||||
|
||||
it("keeps write queue locked while an aborted write is still in flight", async () => {
|
||||
const dir = await createTempDir();
|
||||
const filePath = join(dir, "abort-write.txt");
|
||||
const firstWriteStarted = createDeferred();
|
||||
const finishFirstWrite = createDeferred();
|
||||
const secondWriteStarted = createDeferred();
|
||||
let firstWriteSettled = false;
|
||||
|
||||
const writeTool = createWriteTool(dir, {
|
||||
operations: {
|
||||
mkdir: async () => {},
|
||||
writeFile: async (path, content) => {
|
||||
if (content === "first\n") {
|
||||
firstWriteStarted.resolve();
|
||||
await finishFirstWrite.promise;
|
||||
await writeFile(path, content, "utf8");
|
||||
firstWriteSettled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (content === "second\n") {
|
||||
expect(firstWriteSettled).toBe(true);
|
||||
secondWriteStarted.resolve();
|
||||
}
|
||||
await writeFile(path, content, "utf8");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const firstWrite = writeTool.execute("call-1", { path: filePath, content: "first\n" }, controller.signal);
|
||||
await firstWriteStarted.promise;
|
||||
controller.abort();
|
||||
|
||||
const secondWrite = writeTool.execute("call-2", { path: filePath, content: "second\n" });
|
||||
expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false);
|
||||
|
||||
finishFirstWrite.resolve();
|
||||
await expect(firstWrite).rejects.toThrow("Operation aborted");
|
||||
await secondWrite;
|
||||
|
||||
const content = await readFile(filePath, "utf8");
|
||||
expect(content).toBe("second\n");
|
||||
});
|
||||
|
||||
it("keeps edit queue locked while an aborted edit write is still in flight", async () => {
|
||||
const dir = await createTempDir();
|
||||
const filePath = join(dir, "abort-edit.txt");
|
||||
await writeFile(filePath, "alpha\nbeta\n", "utf8");
|
||||
const firstWriteStarted = createDeferred();
|
||||
const finishFirstWrite = createDeferred();
|
||||
const secondWriteStarted = createDeferred();
|
||||
let firstWriteSettled = false;
|
||||
|
||||
const editTool = createEditTool(dir, {
|
||||
operations: {
|
||||
access,
|
||||
readFile,
|
||||
writeFile: async (path, content) => {
|
||||
if (content === "ALPHA\nbeta\n") {
|
||||
firstWriteStarted.resolve();
|
||||
await finishFirstWrite.promise;
|
||||
await writeFile(path, content, "utf8");
|
||||
firstWriteSettled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (content === "ALPHA\nBETA\n" || content === "alpha\nBETA\n") {
|
||||
expect(firstWriteSettled).toBe(true);
|
||||
secondWriteStarted.resolve();
|
||||
}
|
||||
await writeFile(path, content, "utf8");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const firstEdit = editTool.execute(
|
||||
"call-1",
|
||||
{ path: filePath, edits: [{ oldText: "alpha", newText: "ALPHA" }] },
|
||||
controller.signal,
|
||||
);
|
||||
await firstWriteStarted.promise;
|
||||
controller.abort();
|
||||
|
||||
const secondEdit = editTool.execute("call-2", {
|
||||
path: filePath,
|
||||
edits: [{ oldText: "beta", newText: "BETA" }],
|
||||
});
|
||||
expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false);
|
||||
|
||||
finishFirstWrite.resolve();
|
||||
await expect(firstEdit).rejects.toThrow("Operation aborted");
|
||||
await secondEdit;
|
||||
|
||||
const content = await readFile(filePath, "utf8");
|
||||
expect(content).toBe("ALPHA\nBETA\n");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ const MEDIUM_PNG_100x100 =
|
|||
const LARGE_PNG_200x200 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAMgAAADIEAIAAAByquWKAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGYktHRP///////wlY99wAAAAHdElNRQfqAQ4AMzqU03BFAAAEEElEQVR42u3dvWqUURSF4XXMRAyIhSAWQvwnWtjaWusFx9bW1kKD/wELESxEiBjDjJdwysOC57mCXW32+zGcGbtdACpsdi+T8Xz1GABzm91xMl6sHgNgblzcT/Y+rh4DYG6cv0/2H64eA2BOEgI1fHQHaoyzbXIwVo8BMCcJgRqSEKgxft9Lrn5aPQbA3Ph1klw7Wj0GwJwkBGpYWECN8fMiub63egyAORcWUMPCAmqMH3eTG59XjwEwN76/S24+Wj0GwJwkBGpYWECN8e1fcmuzegyAORcWUMPCAmqM09vJ4dfVYwDMjS9vkzuPV48BMCcJgRqeSAZqjA/nyYP91WMAzElCoIYkBGqMk8Pk6HT1GABzm+3L5JIkBApIQqCGj+5AjfHmb/Lk8uoxAOYkIVBDEgI1LCygxmZ7nFyShEABFxZQw8ICaozXf5KnV1aPATDnwgJqWFhADQsLqOFnDUANFxZQw8ICaoxXZ8mzg9VjAMy5sIAaFhZQw3tYQA1/QgHUkIRADUkI1JCEQA1JCNSQhEANFxZQwzcsoIYkBGpIQqCGJARqSEKghiQEalhYQA1/QgHUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEaFhZQw4ujQA0XFlDDwgJqSEKghgsLqGFhATUsLKCGb1hADRcWUMPCAmpIQqCGCwuoYWEBNbyHBdTw4ihQQxICNSQhUEMSAjUkIVBDEgI1XFhADd+wgBqSEKghCYEakhCoIQmBGpIQqGFhATW8OArUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEa49lB8ups9RgAc14cBWpIQqCGhQXUkIRADRcWUMPCAmqMp1eS139WjwEw5xsWUEMSAjUsLKCGJARquLCAGhYWUGM8uZy8+bt6DIA5L44CNSQhUMMTyUCNcXSanByuHgNgThICNSQhUGM82E8+nK8eA2DOzxqAGpIQqDHuPE6+vF09BsDcOPyanN5ePQbAnCQEavgdFlBj3Nok3/6tHgNgzoujQA1JCNQYNx8l39+tHgNgbtz4nPy4u3oMgDlJCNSwsIAa4/pe8vNi9RgAc37WANSQhECNce0o+XWyegyAuXH1U/L73uoxAOYkIVDDwgJqjIORnG1XjwEw508ogBqSEKgx9h8m5+9XjwEwN/Y+Jhf3V48BMCcJgRpjPE+2x6vHAJgbSbLbrR4DYO4/GqiSgXN+ksgAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjYtMDEtMTRUMDA6NTE6NTcrMDA6MDDpysx4AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDI2LTAxLTE0VDAwOjUxOjU3KzAwOjAwmJd0xAAAACh0RVh0ZGF0ZTp0aW1lc3RhbXAAMjAyNi0wMS0xNFQwMDo1MTo1NyswMDowMM+CVRsAAAAASUVORK5CYII=";
|
||||
|
||||
function imageBytes(base64Data: string): Uint8Array {
|
||||
return Buffer.from(base64Data, "base64");
|
||||
}
|
||||
|
||||
describe("convertToPng", () => {
|
||||
it("should return original data for PNG input", async () => {
|
||||
const result = await convertToPng(TINY_PNG, "image/png");
|
||||
|
|
@ -46,11 +50,28 @@ describe("convertToPng", () => {
|
|||
});
|
||||
|
||||
describe("resizeImage", () => {
|
||||
it("should keep caller input bytes intact", async () => {
|
||||
const input = new Uint8Array(imageBytes(TINY_PNG));
|
||||
const originalByteLength = input.byteLength;
|
||||
const originalFirstByte = input[0];
|
||||
|
||||
const result = await resizeImage(input, "image/png", {
|
||||
maxWidth: 100,
|
||||
maxHeight: 100,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(input.byteLength).toBe(originalByteLength);
|
||||
expect(input[0]).toBe(originalFirstByte);
|
||||
});
|
||||
|
||||
it("should return original image if within limits", async () => {
|
||||
const result = await resizeImage(
|
||||
{ type: "image", data: TINY_PNG, mimeType: "image/png" },
|
||||
{ maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 },
|
||||
);
|
||||
const result = await resizeImage(imageBytes(TINY_PNG), "image/png", {
|
||||
maxWidth: 100,
|
||||
maxHeight: 100,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.wasResized).toBe(false);
|
||||
|
|
@ -62,10 +83,11 @@ describe("resizeImage", () => {
|
|||
});
|
||||
|
||||
it("should resize image exceeding dimension limits", async () => {
|
||||
const result = await resizeImage(
|
||||
{ type: "image", data: MEDIUM_PNG_100x100, mimeType: "image/png" },
|
||||
{ maxWidth: 50, maxHeight: 50, maxBytes: 1024 * 1024 },
|
||||
);
|
||||
const result = await resizeImage(imageBytes(MEDIUM_PNG_100x100), "image/png", {
|
||||
maxWidth: 50,
|
||||
maxHeight: 50,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.wasResized).toBe(true);
|
||||
|
|
@ -80,10 +102,11 @@ describe("resizeImage", () => {
|
|||
const originalSize = originalBuffer.length;
|
||||
|
||||
// Set maxBytes to less than the original encoded image size
|
||||
const result = await resizeImage(
|
||||
{ type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" },
|
||||
{ maxWidth: 2000, maxHeight: 2000, maxBytes: Math.floor(LARGE_PNG_200x200.length * 0.9) },
|
||||
);
|
||||
const result = await resizeImage(imageBytes(LARGE_PNG_200x200), "image/png", {
|
||||
maxWidth: 2000,
|
||||
maxHeight: 2000,
|
||||
maxBytes: Math.floor(LARGE_PNG_200x200.length * 0.9),
|
||||
});
|
||||
|
||||
// Should have tried to reduce size
|
||||
expect(result).not.toBeNull();
|
||||
|
|
@ -93,19 +116,21 @@ describe("resizeImage", () => {
|
|||
});
|
||||
|
||||
it("should return null when image cannot be resized below maxBytes", async () => {
|
||||
const result = await resizeImage(
|
||||
{ type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" },
|
||||
{ maxWidth: 2000, maxHeight: 2000, maxBytes: 1 },
|
||||
);
|
||||
const result = await resizeImage(imageBytes(LARGE_PNG_200x200), "image/png", {
|
||||
maxWidth: 2000,
|
||||
maxHeight: 2000,
|
||||
maxBytes: 1,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle JPEG input", async () => {
|
||||
const result = await resizeImage(
|
||||
{ type: "image", data: TINY_JPEG, mimeType: "image/jpeg" },
|
||||
{ maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 },
|
||||
);
|
||||
const result = await resizeImage(imageBytes(TINY_JPEG), "image/jpeg", {
|
||||
maxWidth: 100,
|
||||
maxHeight: 100,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.wasResized).toBe(false);
|
||||
|
|
|
|||
|
|
@ -105,10 +105,13 @@ fi
|
|||
|
||||
for platform in "${PLATFORMS[@]}"; do
|
||||
echo "Building for $platform..."
|
||||
# Bun compiled executables only embed worker scripts when they are passed as
|
||||
# explicit build entrypoints. The runtime can still use new URL(...), but the
|
||||
# worker must be present in the compiled executable.
|
||||
if [[ "$platform" == windows-* ]]; then
|
||||
bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi.exe
|
||||
bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile binaries/$platform/pi.exe
|
||||
else
|
||||
bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi
|
||||
bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile binaries/$platform/pi
|
||||
fi
|
||||
done
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue