Archived
feat: support general chat file attachments
This commit is contained in:
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user