Archived
feat: add image attachments to the chat composer
Support pasting (Ctrl/Cmd+V), drag-and-drop, and an Attach button to add PNG/JPEG/GIF/WebP images to a message, with thumbnail previews and multi-image support. Attachments are delivered to the session using pi's native ImageContent format and are run through pi's own resizeImage so they match pi's inline image limits exactly. Image content now renders inline in the transcript. A per-message delivery toggle also lets users save attachments into the workspace `.pi-web/paste` folder and reference them so the agent reads them with its own tools. The accepted HTTP upload size is configurable via PI_WEB_MAX_UPLOAD_BYTES or the maxUploadBytes config value (default 64 MB). Closes #13
This commit is contained in:
@@ -3,6 +3,7 @@ export type MachineStatus = "unknown" | "online" | "offline" | "error";
|
||||
|
||||
export const PI_WEB_CAPABILITIES = {
|
||||
sessionsDeleteArchived: "sessions.deleteArchived",
|
||||
promptAttachments: "prompt.attachments",
|
||||
} as const;
|
||||
|
||||
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
|
||||
@@ -55,6 +56,8 @@ export interface PiWebConfigValues {
|
||||
allowedHosts?: string[] | true;
|
||||
shortcuts?: PiWebShortcutConfig;
|
||||
plugins?: PiWebPluginConfigMap;
|
||||
/** Maximum accepted HTTP request body size in bytes (uploads/attachments). */
|
||||
maxUploadBytes?: number;
|
||||
}
|
||||
|
||||
export type PiWebPluginScope = "bundled" | "local" | "user" | "project";
|
||||
@@ -141,6 +144,41 @@ export interface QueuedSessionMessage {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export interface PromptAttachment {
|
||||
/** Kind of attachment. Only images are supported by pi today. */
|
||||
kind: "image";
|
||||
/** IANA mime type (for example "image/png"). */
|
||||
mimeType: string;
|
||||
/** Base64-encoded binary payload (no data: URL prefix). */
|
||||
data: string;
|
||||
/** Optional original filename, used for previews and folder-mode filenames. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How prompt attachments should be delivered to the session.
|
||||
* - "inline": send the binary to pi as native image content (multimodal input).
|
||||
* - "folder": save the file into the workspace and reference it from the prompt
|
||||
* text so the agent reads it with its own tools.
|
||||
*/
|
||||
export type PromptAttachmentDelivery = "inline" | "folder";
|
||||
|
||||
export interface SavedPromptAttachment {
|
||||
/** Workspace-relative path the attachment was written to. */
|
||||
path: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface SaveAttachmentsResponse {
|
||||
attachments: SavedPromptAttachment[];
|
||||
}
|
||||
|
||||
export interface SessionModel {
|
||||
provider?: string;
|
||||
id?: string;
|
||||
|
||||
@@ -6,11 +6,12 @@ export type { PiWebCapability };
|
||||
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
|
||||
const knownPiWebCapabilities: ReadonlySet<string> = new Set(KNOWN_PI_WEB_CAPABILITIES);
|
||||
|
||||
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived] as const satisfies readonly PiWebCapability[];
|
||||
export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived] as const satisfies readonly PiWebCapability[];
|
||||
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
|
||||
export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
|
||||
|
||||
const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
|
||||
[PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
|
||||
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
|
||||
|
||||
export function isPiWebCapability(value: unknown): value is PiWebCapability {
|
||||
|
||||
@@ -41,6 +41,7 @@ export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "POST", path: "/sessions/:sessionId/thinking-level/cycle" },
|
||||
{ method: "GET", path: "/sessions/:sessionId/commands" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/prompt" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/attachments" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/shell" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/commands/run" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/commands/respond" },
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { base64ByteLength, extensionForImageMimeType, isSupportedImageMimeType, MAX_INLINE_IMAGE_BASE64_BYTES, parsePromptAttachments } from "./promptAttachments.js";
|
||||
|
||||
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCA',".replace(/[^A-Za-z0-9+/=]/g, "");
|
||||
|
||||
describe("isSupportedImageMimeType", () => {
|
||||
it("accepts pi-supported image types", () => {
|
||||
expect(isSupportedImageMimeType("image/png")).toBe(true);
|
||||
expect(isSupportedImageMimeType("image/jpeg")).toBe(true);
|
||||
expect(isSupportedImageMimeType("image/gif")).toBe(true);
|
||||
expect(isSupportedImageMimeType("image/webp")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsupported types", () => {
|
||||
expect(isSupportedImageMimeType("image/svg+xml")).toBe(false);
|
||||
expect(isSupportedImageMimeType("application/pdf")).toBe(false);
|
||||
expect(isSupportedImageMimeType(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extensionForImageMimeType", () => {
|
||||
it("maps mime types to file extensions", () => {
|
||||
expect(extensionForImageMimeType("image/jpeg")).toBe("jpg");
|
||||
expect(extensionForImageMimeType("image/png")).toBe("png");
|
||||
expect(extensionForImageMimeType("image/gif")).toBe("gif");
|
||||
expect(extensionForImageMimeType("image/webp")).toBe("webp");
|
||||
expect(extensionForImageMimeType("image/unknown")).toBe("bin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("base64ByteLength", () => {
|
||||
it("computes decoded byte length", () => {
|
||||
expect(base64ByteLength("")).toBe(0);
|
||||
expect(base64ByteLength("QQ==")).toBe(1);
|
||||
expect(base64ByteLength("QUI=")).toBe(2);
|
||||
expect(base64ByteLength("QUJD")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePromptAttachments", () => {
|
||||
it("returns an empty array for undefined", () => {
|
||||
expect(parsePromptAttachments(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes valid attachments", () => {
|
||||
const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "shot.png" }]);
|
||||
expect(result).toEqual([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "shot.png" }]);
|
||||
});
|
||||
|
||||
it("drops empty names", () => {
|
||||
const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "" }]);
|
||||
expect(result[0]).not.toHaveProperty("name");
|
||||
});
|
||||
|
||||
it("rejects non-array input", () => {
|
||||
expect(() => parsePromptAttachments({})).toThrow(/must be an array/);
|
||||
});
|
||||
|
||||
it("rejects unsupported kinds and mime types", () => {
|
||||
expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: tinyPngBase64 }])).toThrow(/unsupported kind/);
|
||||
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }])).toThrow(/unsupported image type/);
|
||||
});
|
||||
|
||||
it("rejects invalid base64 data", () => {
|
||||
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: "not base64!!!" }])).toThrow(/invalid base64/);
|
||||
});
|
||||
|
||||
it("enforces the inline size limit when requested", () => {
|
||||
const oversized = "A".repeat(MAX_INLINE_IMAGE_BASE64_BYTES * 2);
|
||||
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: oversized }], { enforceInlineSizeLimit: true })).toThrow(/inline image size limit/);
|
||||
expect(parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: oversized }])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("enforces the attachment count limit", () => {
|
||||
const many = Array.from({ length: 3 }, () => ({ kind: "image", mimeType: "image/png", data: tinyPngBase64 }));
|
||||
expect(() => parsePromptAttachments(many, { maxAttachments: 2 })).toThrow(/too many attachments/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PromptAttachment } from "./apiTypes.js";
|
||||
|
||||
/**
|
||||
* Image mime types supported by the pi coding agent. Mirrors
|
||||
* `detectSupportedImageMimeType` in `@earendil-works/pi-coding-agent`.
|
||||
*/
|
||||
export const SUPPORTED_IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"] as const;
|
||||
|
||||
export type SupportedImageMimeType = typeof SUPPORTED_IMAGE_MIME_TYPES[number];
|
||||
|
||||
const supportedImageMimeTypes: ReadonlySet<string> = new Set(SUPPORTED_IMAGE_MIME_TYPES);
|
||||
|
||||
/**
|
||||
* Maximum base64 payload per image. Matches pi's `DEFAULT_MAX_BYTES`
|
||||
* (4.5MB, headroom below Anthropic's 5MB inline image limit). pi resizes
|
||||
* images down to this size; we validate against it as the hard upper bound.
|
||||
*/
|
||||
export const MAX_INLINE_IMAGE_BASE64_BYTES = Math.round(4.5 * 1024 * 1024);
|
||||
|
||||
/** Maximum number of attachments allowed on a single prompt. */
|
||||
export const MAX_PROMPT_ATTACHMENTS = 16;
|
||||
|
||||
export function isSupportedImageMimeType(value: unknown): value is SupportedImageMimeType {
|
||||
return typeof value === "string" && supportedImageMimeTypes.has(value);
|
||||
}
|
||||
|
||||
export function extensionForImageMimeType(mimeType: string): string {
|
||||
switch (mimeType) {
|
||||
case "image/jpeg": return "jpg";
|
||||
case "image/png": return "png";
|
||||
case "image/gif": return "gif";
|
||||
case "image/webp": return "webp";
|
||||
default: return "bin";
|
||||
}
|
||||
}
|
||||
|
||||
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
|
||||
export function base64ByteLength(data: string): number {
|
||||
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
||||
return Math.max(0, Math.floor((data.length * 3) / 4) - padding);
|
||||
}
|
||||
|
||||
export interface AttachmentValidationOptions {
|
||||
/** When true, enforce the per-image base64 size cap (inline delivery). */
|
||||
enforceInlineSizeLimit?: boolean;
|
||||
maxAttachments?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: AttachmentValidationOptions = {}): PromptAttachment[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) throw new Error("attachments must be an array");
|
||||
const maxAttachments = options.maxAttachments ?? MAX_PROMPT_ATTACHMENTS;
|
||||
if (value.length > maxAttachments) throw new Error(`too many attachments (max ${String(maxAttachments)})`);
|
||||
return value.map((entry, index) => parsePromptAttachment(entry, index, options));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parsePromptAttachment(value: unknown, index: number, options: AttachmentValidationOptions): PromptAttachment {
|
||||
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`);
|
||||
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`);
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user