Archived
feat: support general chat file attachments
This commit is contained in:
@@ -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.
|
||||
@@ -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> {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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