diff --git a/.changeset/prompt-image-attachments.md b/.changeset/prompt-image-attachments.md new file mode 100644 index 0000000..14010f0 --- /dev/null +++ b/.changeset/prompt-image-attachments.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": minor +--- + +Add image attachments to the chat composer. You can now paste (Ctrl/Cmd+V), drag-and-drop, or use the new Attach button to add PNG, JPEG, GIF, and WebP images to a message, with thumbnail previews and multi-image support. Attachments are delivered to the session using pi's native image format (images are auto-resized to pi's inline limits for full compatibility), and image content now renders inline in the transcript. A per-message delivery toggle also lets you instead 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 now configurable via `PI_WEB_MAX_UPLOAD_BYTES` or the `maxUploadBytes` config value. diff --git a/.gitignore b/.gitignore index f430324..052ea5e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ dist/ # Local plugin development sandboxes. Symlink these into ~/.pi-web/plugins/. /dev-plugins/ + +# Local runtime attachment uploads (created by the chat composer "save to folder" mode). +.pi-web/ diff --git a/README.md b/README.md index e224286..d1d4e32 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,7 @@ Environment variables: - `PI_WEB_SESSIOND_URL` — daemon URL used by the web process when connecting over TCP, for example `http://127.0.0.1:3001`. If you set `PI_WEB_SESSIOND_PORT`, set this for the web process too. - `PI_WEB_PROJECTS_FILE` — optional override for the projects storage JSON file. Defaults to `$PI_WEB_DATA_DIR/projects.json`. - `PI_WEB_MACHINES_FILE` — optional override for the remote machine registry JSON file. Defaults to `$PI_WEB_DATA_DIR/machines.json`. +- `PI_WEB_MAX_UPLOAD_BYTES` — maximum accepted HTTP request body size in bytes (covers pasted/attached images). Defaults to 64 MB. Also configurable as `maxUploadBytes` in `config.json`. - `PI_CODING_AGENT_SESSION_DIR` — Pi session storage directory. PI WEB follows the same session-location priority as Pi for web sessions: this environment variable, then `sessionDir` in Pi settings for the selected workspace, then Pi's default session directory. - `PI_CODING_AGENT_DIR` — Pi agent config directory. PI WEB uses this for Pi auth, settings, resources, and default session storage, matching Pi's own configuration layout. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 10e3e7e..63d4243 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,3 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; -export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; +export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, SavedPromptAttachment, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index f21f1e9..3118678 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -1,4 +1,4 @@ -import type { FileSuggestion, PiWebConfigValues, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes"; +import type { FileSuggestion, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes"; import { request } from "./http"; import { arrayOf, @@ -28,6 +28,7 @@ import { parsePiWebStatusResponse, parseProject, parseRestored, + parseSavedAttachments, parseSessionInfo, parseSessionStatus, parseSlashCommand, @@ -130,7 +131,8 @@ export const sessionsApi = { setThinkingLevel: (session: SessionLookup, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", machineId = "local") => request(sessionUrl(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { level }) }), cycleThinkingLevel: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }), commands: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "commands", machineId), arrayOf(parseSlashCommand)), - prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local") => request(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }), + prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local", attachments?: PromptAttachment[]) => request(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text, ...(streamingBehavior === undefined ? {} : { streamingBehavior }), ...(attachments !== undefined && attachments.length > 0 ? { attachments } : {}) }) }), + saveAttachments: (session: SessionLookup, attachments: PromptAttachment[], machineId = "local", folder?: string) => request(sessionUrl(session, "attachments", machineId), parseSavedAttachments, { method: "POST", body: sessionBody(session, { attachments, ...(folder === undefined ? {} : { folder }) }) }), shell: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "shell", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text }) }), runCommand: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { text }) }), respondToCommand: (session: SessionLookup, requestId: string, value: string, machineId = "local") => request(sessionUrl(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { requestId, value }) }), diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 6245a5d..2c5dfc7 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; +import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; import { isPiWebCapability } from "../../../shared/capabilities"; function isRecord(value: unknown): value is Record { @@ -655,6 +655,16 @@ export function parseAccepted(value: unknown): { accepted: true } { return { accepted: true }; } +export function parseSavedAttachments(value: unknown): SavedPromptAttachment[] { + const record = requireRecord(value); + return arrayOf(parseSavedAttachment)(record["attachments"]); +} + +function parseSavedAttachment(value: unknown): SavedPromptAttachment { + const record = requireRecord(value); + return { path: requireString(record, "path"), mimeType: requireString(record, "mimeType"), size: requireNumber(record, "size") }; +} + export function parseClosed(value: unknown): { closed: true } { const record = requireRecord(value); if (record["closed"] !== true) throw new Error("Expected closed response"); diff --git a/src/client/src/attachmentPreferences.ts b/src/client/src/attachmentPreferences.ts new file mode 100644 index 0000000..c68cb79 --- /dev/null +++ b/src/client/src/attachmentPreferences.ts @@ -0,0 +1,27 @@ +import type { PromptAttachmentDelivery } from "../../shared/apiTypes"; + +const storageKey = "pi-web:attachment-delivery"; + +function browserStorage(): Storage | undefined { + try { + return typeof localStorage === "undefined" ? undefined : localStorage; + } catch { + return undefined; + } +} + +export function loadAttachmentDelivery(storage = browserStorage()): PromptAttachmentDelivery { + try { + return storage?.getItem(storageKey) === "folder" ? "folder" : "inline"; + } catch { + return "inline"; + } +} + +export function saveAttachmentDelivery(mode: PromptAttachmentDelivery, storage = browserStorage()): void { + try { + storage?.setItem(storageKey, mode); + } catch { + // Ignore localStorage quota/privacy errors. + } +} diff --git a/src/client/src/chatMessages.test.ts b/src/client/src/chatMessages.test.ts index a53e6a8..7d71f6f 100644 --- a/src/client/src/chatMessages.test.ts +++ b/src/client/src/chatMessages.test.ts @@ -29,6 +29,18 @@ describe("chat message normalization", () => { ]); }); + it("normalizes image content into image parts", () => { + expect(normalizeMessage({ role: "user", content: [{ type: "text", text: "see this" }, { type: "image", mimeType: "image/png", data: "QUJD" }] })).toEqual([ + { role: "user", parts: [{ type: "text", text: "see this" }, { type: "image", mimeType: "image/png", data: "QUJD" }] }, + ]); + }); + + it("falls back to a placeholder for image content without data", () => { + expect(normalizeMessage({ role: "user", content: [{ type: "image", mimeType: "image/png" }] })).toEqual([ + { role: "user", parts: [{ type: "text", text: "[image]" }] }, + ]); + }); + it("shows assistant model errors as system chat messages", () => { expect(normalizeMessage({ role: "assistant", content: [], stopReason: "error", errorMessage: "429 rate limit", timestamp: "2026-05-09T12:00:00.000Z", provider: "openai", model: "gpt-4.1" })).toEqual([ { role: "system", parts: [{ type: "text", text: "Model response failed: 429 rate limit" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "openai", id: "gpt-4.1" } } }, diff --git a/src/client/src/chatMessages.ts b/src/client/src/chatMessages.ts index 736ae28..ceec115 100644 --- a/src/client/src/chatMessages.ts +++ b/src/client/src/chatMessages.ts @@ -167,7 +167,12 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] { const toolCallId = getString(part, "id"); return [{ type: "toolCall", ...(toolCallId === undefined ? {} : { toolCallId }), toolName, summary: summarizeArgs(args), ...(args === undefined ? {} : { args }) }]; } - if (type === "image") return [{ type: "text", text: "[image]" }]; + if (type === "image") { + const data = getString(part, "data"); + const mimeType = getString(part, "mimeType"); + if (data !== undefined && data !== "" && mimeType !== undefined && mimeType !== "") return [{ type: "image", mimeType, data }]; + return [{ type: "text", text: "[image]" }]; + } return objectFallback(part); }).map((part) => part.type === "text" && getString(message, "role") === "toolResult" ? toolResultPartFromText(part.text, message) diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 869e5a3..89cb3bc 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -477,6 +477,7 @@ export class ChatView extends LitElement { read ${part.path} `; + if (part.type === "image") return html`attached image`; if (part.type === "toolCall") return html`
▶ ${part.toolName}${part.summary}
`; if (part.type === "toolExecution") return html``; if (part.type === "toolResult") return html` diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index af68508..713b672 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1664,9 +1664,10 @@ export class PiWebApp extends LitElement { if (isThinkingLevel(value)) await this.sessions.setThinkingLevel(value); } - private sendPrompt(text: string, streamingBehavior?: "steer" | "followUp"): void { - if (streamingBehavior === undefined && this.auth.handleSlashCommand(text)) return; - void this.sessions.send(text, streamingBehavior); + private sendPrompt(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[]): void { + const hasAttachments = attachments !== undefined && attachments.length > 0; + if (!hasAttachments && streamingBehavior === undefined && this.auth.handleSlashCommand(text)) return; + void this.sessions.send(text, streamingBehavior, attachments); } private renderContextBar() { @@ -1728,7 +1729,7 @@ export class PiWebApp extends LitElement {
${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}
${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> - 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> + 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[]) => { this.sendPrompt(text, streamingBehavior, attachments); }} .onSaveAttachments=${(attachments: import("../api").PromptAttachment[]) => this.sessions.saveAttachments(attachments)} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 569c18e..6083598 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -5,14 +5,26 @@ import { EditorView, keymap, placeholder } from "@codemirror/view"; import { defaultHighlightStyle, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language"; import { LitElement, html, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; -import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api"; +import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api"; +import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; +import { isSupportedImageMimeType } from "../../../shared/promptAttachments"; import { inputModeForDraft } from "../inputModes"; import { machineSessionKey } from "../machineKeys"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; +import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences"; import { promptEditorStyles, type CompletionItem } from "./shared"; import "./AutocompleteMenu"; +interface PendingAttachment { + id: string; + name: string; + mimeType: string; + /** Base64 payload without the data: URL prefix. */ + data: string; + size: number; +} + @customElement("prompt-editor") export class PromptEditor extends LitElement { @property({ type: Boolean }) disabled = false; @@ -23,14 +35,21 @@ export class PromptEditor extends LitElement { @property({ type: Boolean }) isCompacting = false; @property({ type: Boolean }) canStop = false; @property({ attribute: false }) status?: SessionStatus; - @property({ attribute: false }) onSend?: (text: string, streamingBehavior?: "steer" | "followUp") => void; + @property({ attribute: false }) onSend?: (text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[]) => void; + @property({ attribute: false }) onSaveAttachments?: (attachments: PromptAttachment[]) => Promise<{ path: string }[]>; @property({ attribute: false }) onStop?: () => void; @property({ attribute: false }) onSelectModel?: () => void; @property({ attribute: false }) onSelectThinking?: () => void; @query(".markdown-editor") private editorHost?: HTMLDivElement; + @query(".attachment-input") private attachmentInput?: HTMLInputElement; @state() private draft = ""; @state() private completions: CompletionItem[] = []; @state() private selectedIndex = 0; + @state() private attachments: PendingAttachment[] = []; + @state() private attachmentDelivery: PromptAttachmentDelivery = loadAttachmentDelivery(); + @state() private attachmentError: string | undefined = undefined; + @state() private isSavingAttachments = false; + private attachmentSeq = 0; private requestVersion = 0; private editor: EditorView | undefined; private readonly editableCompartment = new Compartment(); @@ -67,18 +86,22 @@ export class PromptEditor extends LitElement { const inputMode = inputModeForDraft(this.draft); const shellMode = inputMode.kind === "shell"; const queuesInput = this.canSteer || this.isCompacting; + const busy = this.disabled || this.isSavingAttachments; return html` -