Archived
Attachments can be pasted, dropped, or uploaded, so 'paste' was too narrow. Rename the default folder and filename prefix accordingly and update the changeset reference.
85 lines
3.5 KiB
TypeScript
85 lines
3.5 KiB
TypeScript
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
import { formatDimensionNote, resizeImage } from "@earendil-works/pi-coding-agent";
|
|
import type { PromptAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js";
|
|
import { extensionForImageMimeType } from "../../shared/promptAttachments.js";
|
|
import { resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
|
|
|
|
/**
|
|
* Default workspace-relative folder used when saving pasted/dropped
|
|
* attachments for the agent to read with its own tools.
|
|
*/
|
|
export const DEFAULT_ATTACHMENT_FOLDER = ".pi-web/attachments";
|
|
|
|
export interface InlineImage {
|
|
image: ImageContent;
|
|
/** Optional human-readable dimension note produced by pi when resizing. */
|
|
dimensionNote?: string;
|
|
}
|
|
|
|
/**
|
|
* Convert validated attachments into pi-compatible inline image content.
|
|
*
|
|
* Mirrors pi's own CLI/TUI behaviour: each image is run through pi's
|
|
* `resizeImage` so it fits within pi's max dimensions and inline byte budget
|
|
* (2000x2000, ~4.5MB base64). Images that cannot be resized below the limit
|
|
* are dropped, matching pi's `[Image omitted]` behaviour.
|
|
*/
|
|
export async function attachmentsToInlineImages(attachments: PromptAttachment[]): Promise<InlineImage[]> {
|
|
const results: InlineImage[] = [];
|
|
for (const attachment of attachments) {
|
|
const bytes = Buffer.from(attachment.data, "base64");
|
|
const resized = await resizeImage(bytes, attachment.mimeType);
|
|
if (resized === null) continue;
|
|
const note = formatDimensionNote(resized);
|
|
results.push({
|
|
image: { type: "image", data: resized.data, mimeType: resized.mimeType },
|
|
...(note === undefined ? {} : { dimensionNote: note }),
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export interface SaveAttachmentsOptions {
|
|
/** Workspace-relative folder to write into. Defaults to `.pi-web/attachments`. */
|
|
folder?: string;
|
|
/** Clock injection for deterministic tests. */
|
|
now?: () => Date;
|
|
}
|
|
|
|
/**
|
|
* Write attachments into a workspace folder and return their relative paths.
|
|
* Filenames are collision-safe and stay inside the workspace root.
|
|
*/
|
|
export async function saveAttachmentsToWorkspace(
|
|
cwd: string,
|
|
attachments: PromptAttachment[],
|
|
options: SaveAttachmentsOptions = {},
|
|
): Promise<SavedPromptAttachment[]> {
|
|
const folder = normalizeFolder(options.folder ?? DEFAULT_ATTACHMENT_FOLDER);
|
|
const now = options.now ?? (() => new Date());
|
|
const { target: folderTarget } = await resolveParentInsideWorkspace(cwd, folder);
|
|
await mkdir(folderTarget, { recursive: true });
|
|
|
|
const stamp = timestamp(now());
|
|
const saved: SavedPromptAttachment[] = [];
|
|
for (const [index, attachment] of attachments.entries()) {
|
|
const bytes = Buffer.from(attachment.data, "base64");
|
|
const filename = `attachment-${stamp}-${String(index + 1)}.${extensionForImageMimeType(attachment.mimeType)}`;
|
|
const relativePath = `${folder}/${filename}`;
|
|
await writeFile(join(folderTarget, filename), bytes);
|
|
saved.push({ path: relativePath, mimeType: attachment.mimeType, size: bytes.byteLength });
|
|
}
|
|
return saved;
|
|
}
|
|
|
|
function normalizeFolder(folder: string): string {
|
|
return folder.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").join("/");
|
|
}
|
|
|
|
function timestamp(date: Date): string {
|
|
const pad = (value: number, length = 2) => String(value).padStart(length, "0");
|
|
return `${String(date.getFullYear())}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}-${pad(date.getMilliseconds(), 3)}`;
|
|
}
|