feat: support general chat file attachments

This commit is contained in:
Federico Jaramillo Martinez
2026-06-25 21:32:07 +02:00
parent 790b36f0ea
commit 7e812aa7f5
11 changed files with 397 additions and 96 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Allow chat composer attachments to save and mention general files while preserving native inline image delivery for supported image-only batches.
+59 -32
View File
@@ -7,7 +7,7 @@ import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
import { captureImageAttachments } from "../promptAttachmentCapture";
import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
import { inputModeForDraft } from "../inputModes";
import { machineSessionKey } from "../machineKeys";
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
@@ -18,14 +18,7 @@ import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, ren
import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels";
import "./AutocompleteMenu";
interface PendingAttachment {
id: string;
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
}
type PendingAttachment = CapturedAttachment & { id: string };
@customElement("prompt-editor")
export class PromptEditor extends LitElement {
@@ -96,8 +89,8 @@ export class PromptEditor extends LitElement {
<footer class=${shellMode ? "shell-mode" : ""} @paste=${(event: ClipboardEvent) => { void this.handlePaste(event); }} @dragover=${(event: DragEvent) => { this.handleDragOver(event); }} @drop=${(event: DragEvent) => { void this.handleDrop(event); }}>
<div class="editor-wrap">
<div class=${`markdown-editor${this.disabled ? " markdown-editor-disabled" : ""}`} aria-label="Message pi" aria-disabled=${this.disabled ? "true" : "false"}></div>
<input class="attachment-input" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} />
<button class="editor-attach icon-button" ?disabled=${busy} title="Attach images" aria-label="Attach images" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button>
<input class="attachment-input" type="file" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} />
<button class="editor-attach icon-button" ?disabled=${busy} title="Attach files" aria-label="Attach files" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button>
${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null}
${this.renderAttachments()}
@@ -137,18 +130,20 @@ export class PromptEditor extends LitElement {
private renderAttachments() {
if (this.attachments.length === 0 && this.attachmentError === undefined) return null;
const canUseInlineDelivery = promptAttachmentsCanUseInlineDelivery(this.attachments);
const delivery = this.effectiveAttachmentDelivery();
return html`
<div class="attachments" aria-label="Pending attachments">
${this.attachments.map((attachment) => html`
<div class="attachment-chip" title=${attachment.name}>
<img src=${`data:${attachment.mimeType};base64,${attachment.data}`} alt=${attachment.name} />
<div class=${`attachment-chip ${isInlinePromptAttachment(attachment) ? "attachment-chip-image" : "attachment-chip-file"}`} title=${attachment.name}>
${this.renderAttachmentPreview(attachment)}
<button type="button" class="attachment-remove" title="Remove attachment" aria-label=${`Remove ${attachment.name}`} @click=${() => { this.removeAttachment(attachment.id); }}>×</button>
</div>
`)}
${this.attachments.length > 0 ? html`
<label class="attachment-delivery" title="How attachments are delivered to the agent">
<select .value=${this.attachmentDelivery} @change=${(event: Event) => { this.changeDelivery(event); }}>
<option value="inline">Attach to message</option>
<label class="attachment-delivery" title=${canUseInlineDelivery ? "How attachments are delivered to the agent" : "General files are saved and mentioned from the workspace"}>
<select .value=${delivery} @change=${(event: Event) => { this.changeDelivery(event); }}>
<option value="inline" ?disabled=${!canUseInlineDelivery}>Attach to message${canUseInlineDelivery ? "" : " (images only)"}</option>
<option value="folder">Save to .pi-web/attachments</option>
</select>
</label>
@@ -158,9 +153,24 @@ export class PromptEditor extends LitElement {
`;
}
private renderAttachmentPreview(attachment: PendingAttachment) {
if (isInlinePromptAttachment(attachment)) {
return html`<img src=${`data:${attachment.mimeType};base64,${attachment.data}`} alt=${attachment.name} />`;
}
return html`
<div class="attachment-file-preview" aria-hidden="true">${fileExtensionLabel(attachment.name)}</div>
<span class="attachment-file-name">${attachment.name}</span>
`;
}
private changeDelivery(event: Event) {
if (!(event.target instanceof HTMLSelectElement)) return;
this.attachmentDelivery = event.target.value === "folder" ? "folder" : "inline";
const requested = event.target.value === "folder" ? "folder" : "inline";
if (requested === "inline" && !promptAttachmentsCanUseInlineDelivery(this.attachments)) {
event.target.value = "folder";
return;
}
this.attachmentDelivery = requested;
saveAttachmentDelivery(this.attachmentDelivery);
}
@@ -169,7 +179,7 @@ export class PromptEditor extends LitElement {
}
private async handlePaste(event: ClipboardEvent) {
const files = imageFilesFromDataTransfer(event.clipboardData);
const files = filesFromDataTransfer(event.clipboardData);
if (files.length === 0) return;
event.preventDefault();
await this.addAttachmentFiles(files);
@@ -177,13 +187,11 @@ export class PromptEditor extends LitElement {
private handleDragOver(event: DragEvent) {
if (event.dataTransfer === null) return;
if (Array.from(event.dataTransfer.items).some((item) => item.kind === "file" && item.type.startsWith("image/"))) {
event.preventDefault();
}
if (dataTransferHasFiles(event.dataTransfer)) event.preventDefault();
}
private async handleDrop(event: DragEvent) {
const files = imageFilesFromDataTransfer(event.dataTransfer);
const files = filesFromDataTransfer(event.dataTransfer);
if (files.length === 0) return;
event.preventDefault();
await this.addAttachmentFiles(files);
@@ -198,7 +206,7 @@ export class PromptEditor extends LitElement {
private async addAttachmentFiles(files: File[]) {
this.attachmentError = undefined;
const { attachments, error } = await captureImageAttachments(files, readFileAsBase64);
const { attachments, error } = await capturePromptAttachments(files, readFileAsBase64);
if (attachments.length > 0) {
this.attachments = [...this.attachments, ...attachments.map((attachment) => ({ id: `attachment-${String(++this.attachmentSeq)}`, ...attachment }))];
}
@@ -206,12 +214,11 @@ export class PromptEditor extends LitElement {
}
private currentAttachments(): PromptAttachment[] {
return this.attachments.map((attachment) => ({
kind: "image",
mimeType: attachment.mimeType,
data: attachment.data,
name: attachment.name,
}));
return this.attachments.map((attachment) => pendingToPromptAttachment(attachment));
}
private effectiveAttachmentDelivery(): PromptAttachmentDelivery {
return effectivePromptAttachmentDelivery(this.attachmentDelivery, this.attachments);
}
private createEditor() {
@@ -380,7 +387,7 @@ export class PromptEditor extends LitElement {
if (text === "" && pending.length === 0) return;
const behavior = this.canSteer || this.isCompacting ? streamingBehavior : undefined;
const attachments = pending.length > 0 ? this.currentAttachments() : undefined;
const delivery = this.attachmentDelivery;
const delivery = this.effectiveAttachmentDelivery();
this.resetComposer();
// Sending is owned by the controller (it drives the chat activity dock and,
// for folder mode, orchestrates the upload + reference rewrite), so this is
@@ -414,9 +421,29 @@ function emptyFileSuggestions(): FileSuggestion[] {
return [];
}
function imageFilesFromDataTransfer(data: DataTransfer | null): File[] {
function filesFromDataTransfer(data: DataTransfer | null): File[] {
if (data === null) return [];
return Array.from(data.files).filter((file) => file.type.startsWith("image/"));
return Array.from(data.files);
}
function dataTransferHasFiles(data: DataTransfer): boolean {
const items = Array.from(data.items);
if (items.length > 0) return items.some((item) => item.kind === "file");
return Array.from(data.types).includes("Files");
}
function pendingToPromptAttachment(attachment: PendingAttachment): PromptAttachment {
if (attachment.kind === "image") {
return { kind: "image", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name };
}
return { kind: "file", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name };
}
function fileExtensionLabel(name: string): string {
const trimmed = name.trim();
const dotIndex = trimmed.lastIndexOf(".");
if (dotIndex >= 0 && dotIndex < trimmed.length - 1) return trimmed.slice(dotIndex + 1, dotIndex + 5).toUpperCase();
return "FILE";
}
function readFileAsBase64(file: File): Promise<string> {
+3
View File
@@ -475,6 +475,9 @@ export const promptEditorStyles = css`
.attachments { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
.attachment-chip { position: relative; width: 56px; height: 56px; border: 1px solid var(--pi-border); border-radius: 8px; overflow: hidden; background: var(--pi-bg); }
.attachment-chip img { width: 100%; height: 100%; object-fit: cover; display: block; }
.attachment-chip-file { display: grid; place-items: center; }
.attachment-file-preview { display: grid; place-items: center; width: 34px; height: 26px; border: 1px solid var(--pi-border-muted); border-radius: 4px; background: var(--pi-surface); color: var(--pi-muted); font: 700 10px/1 system-ui, sans-serif; letter-spacing: .03em; }
.attachment-file-name { position: absolute; right: 4px; bottom: 3px; left: 4px; overflow: hidden; color: var(--pi-muted); font-size: 10px; line-height: 1.2; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.attachment-remove { position: absolute; top: 1px; right: 1px; width: 18px; height: 18px; padding: 0; line-height: 16px; border-radius: 50%; border: 1px solid var(--pi-border); background: var(--pi-surface); color: var(--pi-text); font-size: 13px; cursor: pointer; }
.attachment-delivery select { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; font: 12px system-ui, sans-serif; }
.attachment-error { flex-basis: 100%; color: var(--pi-danger); font-size: 12px; }
+48 -18
View File
@@ -1,51 +1,81 @@
import { describe, expect, it } from "vitest";
import { captureImageAttachments, READ_FAILURE_MESSAGE, UNSUPPORTED_IMAGE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture";
import { capturePromptAttachments, DEFAULT_FILE_MIME_TYPE, effectivePromptAttachmentDelivery, READ_FAILURE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture";
function file(name: string, type: string, size = 10): CapturableFile {
return { name, type, size };
}
describe("captureImageAttachments", () => {
it("reads supported images as base64 attachments", async () => {
const result = await captureImageAttachments(
describe("capturePromptAttachments", () => {
it("reads supported images as native inline image attachments", async () => {
const result = await capturePromptAttachments(
[file("shot.png", "image/png"), file("pic.webp", "image/webp")],
(f) => Promise.resolve(`data-for-${f.name}`),
);
expect(result.error).toBeUndefined();
expect(result.attachments).toEqual([
{ name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 },
{ name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 },
{ kind: "image", name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 },
{ kind: "image", name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 },
]);
});
it("derives a name from the mime type when the file is unnamed", async () => {
const result = await captureImageAttachments([file("", "image/jpeg")], () => Promise.resolve("x"));
expect(result.attachments[0]?.name).toBe("pasted-image.jpg");
it("captures generic files with their browser MIME type", async () => {
const result = await capturePromptAttachments(
[file("report.pdf", "application/pdf", 1234), file("vector.svg", "image/svg+xml")],
(f) => Promise.resolve(`data-for-${f.name}`),
);
expect(result.error).toBeUndefined();
expect(result.attachments).toEqual([
{ kind: "file", name: "report.pdf", mimeType: "application/pdf", data: "data-for-report.pdf", size: 1234 },
{ kind: "file", name: "vector.svg", mimeType: "image/svg+xml", data: "data-for-vector.svg", size: 10 },
]);
});
it("skips unsupported types and reports a single error while keeping valid ones", async () => {
const result = await captureImageAttachments(
[file("doc.pdf", "application/pdf"), file("ok.gif", "image/gif")],
it("uses application/octet-stream when the browser does not provide a MIME type", async () => {
const result = await capturePromptAttachments([file("archive", "")], () => Promise.resolve("x"));
expect(result.attachments[0]).toMatchObject({ kind: "file", name: "archive", mimeType: DEFAULT_FILE_MIME_TYPE });
});
it("derives fallback names for unnamed pasted attachments", async () => {
const result = await capturePromptAttachments(
[file("", "image/jpeg"), file("", "application/pdf")],
() => Promise.resolve("x"),
);
expect(result.error).toBe(UNSUPPORTED_IMAGE_MESSAGE);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["ok.gif"]);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["pasted-image.jpg", "pasted-file.bin"]);
});
it("reports a read failure without dropping other attachments", async () => {
const result = await captureImageAttachments(
[file("bad.png", "image/png"), file("good.png", "image/png")],
const result = await capturePromptAttachments(
[file("bad.png", "image/png"), file("good.txt", "text/plain")],
(f) => f.name === "bad.png" ? Promise.reject(new Error("boom")) : Promise.resolve("ok"),
);
expect(result.error).toBe(READ_FAILURE_MESSAGE);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.png"]);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.txt"]);
});
it("returns no attachments and no error for an empty batch", async () => {
const result = await captureImageAttachments([], () => Promise.resolve("x"));
const result = await capturePromptAttachments([], () => Promise.resolve("x"));
expect(result).toEqual({ attachments: [] });
});
});
describe("effectivePromptAttachmentDelivery", () => {
it("preserves inline delivery when all pending attachments are supported images", () => {
expect(effectivePromptAttachmentDelivery("inline", [{ kind: "image", mimeType: "image/png" }])).toBe("inline");
});
it("preserves an explicit folder preference for supported images", () => {
expect(effectivePromptAttachmentDelivery("folder", [{ kind: "image", mimeType: "image/png" }])).toBe("folder");
});
it("forces folder delivery when any attachment is a generic file", () => {
expect(effectivePromptAttachmentDelivery("inline", [
{ kind: "image", mimeType: "image/png" },
{ kind: "file", mimeType: "application/pdf" },
])).toBe("folder");
});
});
+55 -18
View File
@@ -1,3 +1,4 @@
import type { PromptAttachmentDelivery } from "../../shared/apiTypes";
import { extensionForImageMimeType, isSupportedImageMimeType } from "../../shared/promptAttachments";
/**
@@ -11,44 +12,51 @@ export interface CapturableFile {
size: number;
}
export interface CapturedAttachment {
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
}
export type CapturedAttachment =
| {
kind: "image";
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
}
| {
kind: "file";
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
};
export interface CaptureResult {
attachments: CapturedAttachment[];
error?: string;
}
export const UNSUPPORTED_IMAGE_MESSAGE = "Only PNG, JPEG, GIF, and WebP images are supported.";
export const DEFAULT_FILE_MIME_TYPE = "application/octet-stream";
export const READ_FAILURE_MESSAGE = "Failed to read an attachment.";
/**
* Validate a batch of files and read the supported images as base64.
* Read a batch of browser files as prompt attachments.
*
* Pure orchestration: the actual byte reading is injected so the side effect
* (FileReader/Blob access) stays at the component boundary and tests can supply
* a fake reader. Unsupported types and read failures are collected into a single
* user-facing error while still returning every attachment that did succeed.
* a fake reader. Supported image MIME types stay marked as native inline images;
* every other file is captured as a generic file attachment that must be saved
* into the workspace before being mentioned in the prompt.
*/
export async function captureImageAttachments<T extends CapturableFile>(
export async function capturePromptAttachments<T extends CapturableFile>(
files: readonly T[],
readBase64: (file: T) => Promise<string>,
): Promise<CaptureResult> {
const attachments: CapturedAttachment[] = [];
let error: string | undefined;
for (const file of files) {
if (!isSupportedImageMimeType(file.type)) {
error = UNSUPPORTED_IMAGE_MESSAGE;
continue;
}
try {
const data = await readBase64(file);
attachments.push({ name: attachmentName(file), mimeType: file.type, data, size: file.size });
attachments.push(capturedAttachment(file, data));
} catch {
error = READ_FAILURE_MESSAGE;
}
@@ -56,6 +64,35 @@ export async function captureImageAttachments<T extends CapturableFile>(
return { attachments, ...(error === undefined ? {} : { error }) };
}
export function isInlinePromptAttachment(attachment: Pick<CapturedAttachment, "kind" | "mimeType">): boolean {
return attachment.kind === "image" && isSupportedImageMimeType(attachment.mimeType);
}
export function promptAttachmentsCanUseInlineDelivery(attachments: readonly Pick<CapturedAttachment, "kind" | "mimeType">[]): boolean {
return attachments.every((attachment) => isInlinePromptAttachment(attachment));
}
export function effectivePromptAttachmentDelivery(
preferredDelivery: PromptAttachmentDelivery,
attachments: readonly Pick<CapturedAttachment, "kind" | "mimeType">[],
): PromptAttachmentDelivery {
return promptAttachmentsCanUseInlineDelivery(attachments) ? preferredDelivery : "folder";
}
function capturedAttachment(file: CapturableFile, data: string): CapturedAttachment {
if (isSupportedImageMimeType(file.type)) {
return { kind: "image", name: attachmentName(file), mimeType: file.type, data, size: file.size };
}
return { kind: "file", name: attachmentName(file), mimeType: fileMimeType(file), data, size: file.size };
}
function fileMimeType(file: CapturableFile): string {
const mimeType = file.type.trim();
return mimeType === "" ? DEFAULT_FILE_MIME_TYPE : mimeType;
}
function attachmentName(file: CapturableFile): string {
return file.name !== "" ? file.name : `pasted-image.${extensionForImageMimeType(file.type)}`;
if (file.name !== "") return file.name;
if (isSupportedImageMimeType(file.type)) return `pasted-image.${extensionForImageMimeType(file.type)}`;
return "pasted-file.bin";
}
+73 -2
View File
@@ -1,17 +1,22 @@
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_ATTACHMENT_FOLDER, saveAttachmentsToWorkspace } from "./attachmentService.js";
let workspace: string;
let externalDirectories: string[] = [];
beforeEach(async () => {
workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-"));
externalDirectories = [];
});
afterEach(async () => {
await rm(workspace, { recursive: true, force: true });
await Promise.all([
rm(workspace, { recursive: true, force: true }),
...externalDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
]);
});
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
@@ -43,6 +48,59 @@ describe("saveAttachmentsToWorkspace", () => {
expect(written.equals(pngBytes)).toBe(true);
});
it("saves generic files with sanitized original filenames", async () => {
const pdfBytes = Buffer.from("PDF bytes");
const saved = await saveAttachmentsToWorkspace(
workspace,
[
{ kind: "file", mimeType: "application/pdf", data: pdfBytes.toString("base64"), name: "../Quarterly Report (final).pdf" },
{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" },
],
{ now: () => new Date("2026-06-13T12:05:01.123Z") },
);
expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/attachment-`)).toBe(true);
expect(saved[0]?.path.endsWith("-1-Quarterly-Report-final.pdf")).toBe(true);
expect(saved[0]).toMatchObject({ mimeType: "application/pdf", size: pdfBytes.byteLength });
expect(saved[1]?.path.endsWith("-2-empty.txt")).toBe(true);
expect(saved[1]).toMatchObject({ mimeType: "text/plain", size: 0 });
expect((await readFile(join(workspace, saved[0]?.path ?? ""))).equals(pdfBytes)).toBe(true);
expect(await readFile(join(workspace, saved[1]?.path ?? ""))).toHaveLength(0);
});
it("does not overwrite an existing attachment name", async () => {
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
const first = await saveAttachmentsToWorkspace(
workspace,
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
{ now: fixedNow },
);
const second = await saveAttachmentsToWorkspace(
workspace,
[{ kind: "file", mimeType: "text/plain", data: "REVG", name: "note.txt" }],
{ now: fixedNow },
);
expect(second[0]?.path).not.toBe(first[0]?.path);
expect(second[0]?.path.endsWith("-1-note-2.txt")).toBe(true);
expect((await readFile(join(workspace, first[0]?.path ?? ""))).toString()).toBe("ABC");
expect((await readFile(join(workspace, second[0]?.path ?? ""))).toString()).toBe("DEF");
});
it("rejects unsafe custom folders", async () => {
await expect(saveAttachmentsToWorkspace(
workspace,
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
{ folder: "/tmp/uploads" },
)).rejects.toThrow(/Absolute paths/);
await expect(saveAttachmentsToWorkspace(
workspace,
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
{ folder: "../uploads" },
)).rejects.toThrow(/Path traversal/);
});
it("honors a custom folder", async () => {
const saved = await saveAttachmentsToWorkspace(
workspace,
@@ -52,6 +110,19 @@ describe("saveAttachmentsToWorkspace", () => {
expect(saved[0]?.path.startsWith("uploads/images/")).toBe(true);
});
it("rejects attachment folders that resolve outside the workspace", async () => {
const outside = await mkdtemp(join(tmpdir(), "pi-web-attachments-outside-"));
externalDirectories.push(outside);
await mkdir(join(workspace, ".pi-web"));
await symlink(outside, join(workspace, ".pi-web", "attachments"), "dir");
await expect(saveAttachmentsToWorkspace(
workspace,
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
)).rejects.toThrow(/Path escapes workspace/);
await expect(readdir(outside)).resolves.toEqual([]);
});
it("returns empty for no attachments", async () => {
expect(await saveAttachmentsToWorkspace(workspace, [])).toEqual([]);
});
+71 -13
View File
@@ -1,10 +1,10 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { mkdir, realpath, writeFile } from "node:fs/promises";
import { basename, extname, 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 type { PromptAttachment, PromptImageAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js";
import { extensionForImageMimeType } from "../../shared/promptAttachments.js";
import { resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
import { ensureInside, isNodeErrorWithCode, resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
/**
* Default workspace-relative folder used when saving pasted/dropped
@@ -26,7 +26,7 @@ export interface InlineImage {
* (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[]> {
export async function attachmentsToInlineImages(attachments: PromptImageAttachment[]): Promise<InlineImage[]> {
const results: InlineImage[] = [];
for (const attachment of attachments) {
const bytes = Buffer.from(attachment.data, "base64");
@@ -57,25 +57,83 @@ export async function saveAttachmentsToWorkspace(
attachments: PromptAttachment[],
options: SaveAttachmentsOptions = {},
): Promise<SavedPromptAttachment[]> {
const folder = normalizeFolder(options.folder ?? DEFAULT_ATTACHMENT_FOLDER);
const folder = options.folder ?? DEFAULT_ATTACHMENT_FOLDER;
const now = options.now ?? (() => new Date());
const { target: folderTarget } = await resolveParentInsideWorkspace(cwd, folder);
await mkdir(folderTarget, { recursive: true });
const { root, target: requestedFolderTarget, relativePath: normalizedFolder } = await resolveParentInsideWorkspace(cwd, folder);
await mkdir(requestedFolderTarget, { recursive: true });
const folderTarget = await realpath(requestedFolderTarget);
ensureInside(root, folderTarget);
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);
const filename = await writeUniqueAttachmentFile(folderTarget, attachmentFilename(attachment, stamp, index), bytes);
const relativePath = normalizedFolder === "" ? filename : `${normalizedFolder}/${filename}`;
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("/");
async function writeUniqueAttachmentFile(folderTarget: string, filename: string, bytes: Buffer): Promise<string> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const candidate = attempt === 0 ? filename : addCollisionSuffix(filename, attempt + 1);
try {
await writeFile(join(folderTarget, candidate), bytes, { flag: "wx" });
return candidate;
} catch (error: unknown) {
if (!isNodeErrorWithCode(error, "EEXIST")) throw error;
}
}
throw new Error("Unable to choose a unique attachment filename");
}
function addCollisionSuffix(filename: string, suffix: number): string {
const extension = extname(filename);
const stem = filename.slice(0, filename.length - extension.length);
return `${stem}-${String(suffix)}${extension}`;
}
function attachmentFilename(attachment: PromptAttachment, stamp: string, index: number): string {
const originalName = sanitizeOriginalFilename(attachment.name) ?? fallbackAttachmentFilename(attachment);
return `attachment-${stamp}-${String(index + 1)}-${originalName}`;
}
function fallbackAttachmentFilename(attachment: PromptAttachment): string {
if (attachment.kind === "image") return `image.${extensionForImageMimeType(attachment.mimeType)}`;
return "file.bin";
}
const MAX_ORIGINAL_FILENAME_LENGTH = 96;
function sanitizeOriginalFilename(name: string | undefined): string | undefined {
const trimmed = name?.trim();
if (trimmed === undefined || trimmed === "") return undefined;
const leaf = basename(trimmed.replace(/\\/g, "/"));
const sanitized = stripControlCharacters(leaf)
.normalize("NFKC")
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/-+\./g, ".")
.replace(/^\.+/, "")
.replace(/[.-]+$/, "");
if (sanitized === "") return undefined;
return truncateFilename(sanitized, MAX_ORIGINAL_FILENAME_LENGTH);
}
function stripControlCharacters(value: string): string {
return Array.from(value).filter((character) => {
const codePoint = character.codePointAt(0);
return codePoint !== undefined && codePoint > 0x1f && codePoint !== 0x7f;
}).join("");
}
function truncateFilename(filename: string, maxLength: number): string {
if (filename.length <= maxLength) return filename;
const extension = extname(filename);
if (extension.length >= maxLength) return filename.slice(0, maxLength);
const stem = filename.slice(0, filename.length - extension.length);
return `${stem.slice(0, maxLength - extension.length)}${extension}`;
}
function timestamp(date: Date): string {
+1 -1
View File
@@ -957,7 +957,7 @@ export class PiSessionService {
}
async saveAttachments(ref: PiSessionLookup, attachments: unknown, folder?: string): Promise<SavedPromptAttachment[]> {
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
if (parsed.length === 0) return [];
await this.assertWritable(ref);
const active = await this.getActive(ref);
+18 -6
View File
@@ -176,14 +176,13 @@ export interface QueuedSessionMessage {
}
/**
* A binary attachment carried with a prompt. The wire format mirrors pi's own
* `ImageContent` shape (`{ type: "image", data, mimeType }`) so attachments are
* fully compatible with the underlying pi coding agent.
* A pi-native image attachment carried with a prompt. The wire format mirrors
* pi's own `ImageContent` shape (`{ type: "image", data, mimeType }`) so these
* attachments are compatible with native multimodal delivery after validation.
*/
export interface PromptAttachment {
/** Kind of attachment. Only images are supported by pi today. */
export interface PromptImageAttachment {
kind: "image";
/** IANA mime type (for example "image/png"). */
/** Supported image MIME type (image/png, image/jpeg, image/gif, or image/webp). */
mimeType: string;
/** Base64-encoded binary payload (no data: URL prefix). */
data: string;
@@ -191,6 +190,19 @@ export interface PromptAttachment {
name?: string;
}
/** A general file attachment that must be saved into the workspace before use. */
export interface PromptFileAttachment {
kind: "file";
/** Non-empty IANA MIME type (for example "application/pdf"). */
mimeType: string;
/** Base64-encoded binary payload (no data: URL prefix). Empty for zero-byte files. */
data: string;
/** Optional original filename, used for previews and folder-mode filenames. */
name?: string;
}
export type PromptAttachment = PromptImageAttachment | PromptFileAttachment;
/**
* How prompt attachments should be delivered to the session.
* - "inline": send the binary to pi as native image content (multimodal input).
+25
View File
@@ -58,9 +58,34 @@ describe("parsePromptAttachments", () => {
it("rejects unsupported kinds and mime types", () => {
expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: tinyPngBase64 }])).toThrow(/unsupported kind/);
expect(() => parsePromptAttachments([{ kind: "file", mimeType: "application/pdf", data: tinyPngBase64 }])).toThrow(/unsupported kind/);
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }])).toThrow(/unsupported image type/);
});
it("accepts generic files only when file attachments are allowed", () => {
const result = parsePromptAttachments(
[{ kind: "file", mimeType: "application/pdf", data: "QUJD", name: "report.pdf" }],
{ allowFileAttachments: true },
);
expect(result).toEqual([{ kind: "file", mimeType: "application/pdf", data: "QUJD", name: "report.pdf" }]);
});
it("accepts zero-byte generic files", () => {
const result = parsePromptAttachments(
[{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" }],
{ allowFileAttachments: true },
);
expect(result).toEqual([{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" }]);
});
it("rejects generic files with empty mime types", () => {
expect(() => parsePromptAttachments([{ kind: "file", mimeType: "", data: "QUJD" }], { allowFileAttachments: true })).toThrow(/invalid file type/);
});
it("keeps image MIME validation when file attachments are allowed", () => {
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }], { allowFileAttachments: true })).toThrow(/unsupported image type/);
});
it("rejects invalid base64 data", () => {
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: "not base64!!!" }])).toThrow(/invalid base64/);
});
+39 -6
View File
@@ -1,4 +1,4 @@
import type { PromptAttachment } from "./apiTypes.js";
import type { PromptAttachment, PromptFileAttachment, PromptImageAttachment } from "./apiTypes.js";
/**
* Image mime types supported by the pi coding agent. Mirrors
@@ -44,13 +44,20 @@ export function base64ByteLength(data: string): number {
export interface AttachmentValidationOptions {
/** When true, enforce the per-image base64 size cap (inline delivery). */
enforceInlineSizeLimit?: boolean;
/** When true, accept general file attachments for save-to-folder delivery. */
allowFileAttachments?: boolean;
maxAttachments?: number;
}
type ImageOnlyAttachmentValidationOptions = AttachmentValidationOptions & { allowFileAttachments?: false | undefined };
type SaveAttachmentValidationOptions = AttachmentValidationOptions & { allowFileAttachments: true };
/**
* Validate and normalize untrusted prompt attachments. Throws on malformed,
* unsupported, or oversized input so routes can return a 400.
*/
export function parsePromptAttachments(value: unknown, options?: ImageOnlyAttachmentValidationOptions): PromptImageAttachment[];
export function parsePromptAttachments(value: unknown, options: SaveAttachmentValidationOptions): PromptAttachment[];
export function parsePromptAttachments(value: unknown, options: AttachmentValidationOptions = {}): PromptAttachment[] {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new Error("attachments must be an array");
@@ -67,19 +74,45 @@ function parsePromptAttachment(value: unknown, index: number, options: Attachmen
if (!isRecord(value)) throw new Error(`attachment ${String(index)} must be an object`);
const record = value;
const kind = record["kind"];
if (kind !== "image") throw new Error(`attachment ${String(index)} has unsupported kind`);
if (kind === "image") return parseImageAttachment(record, index, options);
if (kind === "file" && options.allowFileAttachments === true) return parseFileAttachment(record, index);
throw new Error(`attachment ${String(index)} has unsupported kind`);
}
function parseImageAttachment(record: Record<string, unknown>, index: number, options: AttachmentValidationOptions): PromptImageAttachment {
const mimeType = record["mimeType"];
if (!isSupportedImageMimeType(mimeType)) throw new Error(`attachment ${String(index)} has unsupported image type`);
const data = record["data"];
if (typeof data !== "string" || data === "" || !base64Pattern.test(data)) throw new Error(`attachment ${String(index)} has invalid base64 data`);
const data = requireBase64Data(record["data"], index, { allowEmpty: false });
if (options.enforceInlineSizeLimit === true && base64ByteLength(data) > MAX_INLINE_IMAGE_BASE64_BYTES) {
throw new Error(`attachment ${String(index)} exceeds the inline image size limit`);
}
const name = record["name"];
return {
kind: "image",
mimeType,
data,
...(typeof name === "string" && name !== "" ? { name } : {}),
...attachmentName(record),
};
}
function parseFileAttachment(record: Record<string, unknown>, index: number): PromptFileAttachment {
const mimeType = record["mimeType"];
if (typeof mimeType !== "string" || mimeType.trim() === "") throw new Error(`attachment ${String(index)} has invalid file type`);
return {
kind: "file",
mimeType: mimeType.trim(),
data: requireBase64Data(record["data"], index, { allowEmpty: true }),
...attachmentName(record),
};
}
function requireBase64Data(value: unknown, index: number, options: { allowEmpty: boolean }): string {
if (typeof value !== "string" || (!options.allowEmpty && value === "") || !base64Pattern.test(value)) {
throw new Error(`attachment ${String(index)} has invalid base64 data`);
}
return value;
}
function attachmentName(record: Record<string, unknown>): { name?: string } {
const name = record["name"];
return typeof name === "string" && name !== "" ? { name } : {};
}