Archived
feat: support general chat file attachments
This commit is contained in:
+18
-6
@@ -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).
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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 } : {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user