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
+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 } : {};
}