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:
@@ -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.
|
||||
@@ -7,3 +7,6 @@ dist/
|
||||
|
||||
# Local plugin development sandboxes. Symlink these into ~/.pi-web/plugins/<plugin-id>.
|
||||
/dev-plugins/
|
||||
|
||||
# Local runtime attachment uploads (created by the chat composer "save to folder" mode).
|
||||
.pi-web/
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }) }),
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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" } } },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -477,6 +477,7 @@ export class ChatView extends LitElement {
|
||||
<small>read ${part.path}</small>
|
||||
</div>
|
||||
`;
|
||||
if (part.type === "image") return html`<img class="part chat-image" src=${`data:${part.mimeType};base64,${part.data}`} alt="attached image" loading="lazy" />`;
|
||||
if (part.type === "toolCall") return html`<div class="part tool-line">▶ ${part.toolName}<span class="summary">${part.summary}</span></div>`;
|
||||
if (part.type === "toolExecution") return html`<tool-execution-view class="part" .execution=${part}></tool-execution-view>`;
|
||||
if (part.type === "toolResult") return html`
|
||||
|
||||
@@ -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 {
|
||||
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
|
||||
${state.selectedSession ? html`
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 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())}></chat-view>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 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(); }}></prompt-editor>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 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(); }}></prompt-editor>
|
||||
<status-bar .status=${state.status}></status-bar>
|
||||
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
||||
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
|
||||
|
||||
@@ -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`
|
||||
<footer class=${shellMode ? "shell-mode" : ""}>
|
||||
<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>
|
||||
${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()}
|
||||
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu>
|
||||
</div>
|
||||
<div class="actions">
|
||||
${this.renderCompactStatus()}
|
||||
<button ?disabled=${this.disabled} title=${queuesInput ? "Queue until the current activity finishes" : "Send message"} @click=${() => { this.send("followUp"); }}>${queuesInput ? "Queue" : "Send"}</button>
|
||||
${this.canSteer && !this.isCompacting ? html`<button ?disabled=${this.disabled} title="Steer the current response before the next model call" @click=${() => { this.send("steer"); }}>Steer</button>` : null}
|
||||
<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="attach-button" ?disabled=${busy} title="Attach images" @click=${() => { this.attachmentInput?.click(); }}>Attach</button>
|
||||
<button ?disabled=${busy} title=${queuesInput ? "Queue until the current activity finishes" : "Send message"} @click=${() => { void this.send("followUp"); }}>${queuesInput ? "Queue" : "Send"}</button>
|
||||
${this.canSteer && !this.isCompacting ? html`<button ?disabled=${busy} title="Steer the current response before the next model call" @click=${() => { void this.send("steer"); }}>Steer</button>` : null}
|
||||
<button ?disabled=${this.disabled || !this.canStop} title=${this.canStop ? "Stop current work and clear queued messages" : "Nothing running"} @click=${() => this.onStop?.()}>Stop</button>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -102,6 +125,98 @@ export class PromptEditor extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAttachments() {
|
||||
if (this.attachments.length === 0 && this.attachmentError === undefined) return null;
|
||||
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} />
|
||||
<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>
|
||||
<option value="folder">Save to .pi-web/paste</option>
|
||||
</select>
|
||||
</label>
|
||||
` : null}
|
||||
${this.attachmentError !== undefined ? html`<div class="attachment-error">${this.attachmentError}</div>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private changeDelivery(event: Event) {
|
||||
if (!(event.target instanceof HTMLSelectElement)) return;
|
||||
this.attachmentDelivery = event.target.value === "folder" ? "folder" : "inline";
|
||||
saveAttachmentDelivery(this.attachmentDelivery);
|
||||
}
|
||||
|
||||
private removeAttachment(id: string) {
|
||||
this.attachments = this.attachments.filter((attachment) => attachment.id !== id);
|
||||
}
|
||||
|
||||
private async handlePaste(event: ClipboardEvent) {
|
||||
const files = imageFilesFromDataTransfer(event.clipboardData);
|
||||
if (files.length === 0) return;
|
||||
event.preventDefault();
|
||||
await this.addAttachmentFiles(files);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDrop(event: DragEvent) {
|
||||
const files = imageFilesFromDataTransfer(event.dataTransfer);
|
||||
if (files.length === 0) return;
|
||||
event.preventDefault();
|
||||
await this.addAttachmentFiles(files);
|
||||
}
|
||||
|
||||
private async handleFileInput(event: Event) {
|
||||
if (!(event.target instanceof HTMLInputElement) || event.target.files === null) return;
|
||||
const files = Array.from(event.target.files);
|
||||
event.target.value = "";
|
||||
await this.addAttachmentFiles(files);
|
||||
}
|
||||
|
||||
private async addAttachmentFiles(files: File[]) {
|
||||
this.attachmentError = undefined;
|
||||
for (const file of files) {
|
||||
if (!isSupportedImageMimeType(file.type)) {
|
||||
this.attachmentError = "Only PNG, JPEG, GIF, and WebP images are supported.";
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const data = await readFileAsBase64(file);
|
||||
this.attachments = [...this.attachments, {
|
||||
id: `attachment-${String(++this.attachmentSeq)}`,
|
||||
name: file.name !== "" ? file.name : `pasted-image.${file.type.split("/")[1] ?? "png"}`,
|
||||
mimeType: file.type,
|
||||
data,
|
||||
size: file.size,
|
||||
}];
|
||||
} catch {
|
||||
this.attachmentError = "Failed to read an attachment.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private currentAttachments(): PromptAttachment[] {
|
||||
return this.attachments.map((attachment) => ({
|
||||
kind: "image",
|
||||
mimeType: attachment.mimeType,
|
||||
data: attachment.data,
|
||||
name: attachment.name,
|
||||
}));
|
||||
}
|
||||
|
||||
private createEditor() {
|
||||
if (!this.editorHost || this.editor !== undefined) return;
|
||||
this.editor = new EditorView({
|
||||
@@ -229,7 +344,7 @@ export class PromptEditor extends LitElement {
|
||||
if (completion !== undefined) this.pick(completion);
|
||||
return true;
|
||||
}
|
||||
this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
|
||||
void this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -261,14 +376,47 @@ export class PromptEditor extends LitElement {
|
||||
this.completions = [];
|
||||
}
|
||||
|
||||
private send(streamingBehavior?: "steer" | "followUp") {
|
||||
private async send(streamingBehavior?: "steer" | "followUp") {
|
||||
if (this.disabled || this.isSavingAttachments) return;
|
||||
const text = this.draft.trim();
|
||||
if (text === "" || this.disabled) return;
|
||||
const pending = this.attachments;
|
||||
if (text === "" && pending.length === 0) return;
|
||||
const behavior = this.canSteer || this.isCompacting ? streamingBehavior : undefined;
|
||||
|
||||
if (pending.length > 0 && this.attachmentDelivery === "folder") {
|
||||
await this.sendWithFolderAttachments(text, behavior);
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments = pending.length > 0 ? this.currentAttachments() : undefined;
|
||||
this.resetComposer();
|
||||
this.onSend?.(text, behavior, attachments);
|
||||
}
|
||||
|
||||
private async sendWithFolderAttachments(text: string, behavior?: "steer" | "followUp") {
|
||||
if (this.onSaveAttachments === undefined) return;
|
||||
this.isSavingAttachments = true;
|
||||
this.attachmentError = undefined;
|
||||
try {
|
||||
const saved = await this.onSaveAttachments(this.currentAttachments());
|
||||
const references = saved.map((file) => fileCompletionInsertText(file.path, false)).join(" ");
|
||||
const body = text === "" ? references : `${text}\n\n${references}`;
|
||||
this.resetComposer();
|
||||
this.onSend?.(body, behavior);
|
||||
} catch (error) {
|
||||
this.attachmentError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.isSavingAttachments = false;
|
||||
}
|
||||
}
|
||||
|
||||
private resetComposer() {
|
||||
this.draft = "";
|
||||
const key = draftStorageKey(this.machineId, this.sessionId);
|
||||
if (key !== undefined) clearDraft(key);
|
||||
this.completions = [];
|
||||
this.onSend?.(text, this.canSteer || this.isCompacting ? streamingBehavior : undefined);
|
||||
this.attachments = [];
|
||||
this.attachmentError = undefined;
|
||||
}
|
||||
|
||||
static override styles = promptEditorStyles;
|
||||
@@ -288,6 +436,25 @@ function emptyFileSuggestions(): FileSuggestion[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function imageFilesFromDataTransfer(data: DataTransfer | null): File[] {
|
||||
if (data === null) return [];
|
||||
return Array.from(data.files).filter((file) => file.type.startsWith("image/"));
|
||||
}
|
||||
|
||||
function readFileAsBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => { reject(reader.error ?? new Error("Failed to read file")); };
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
if (typeof result !== "string") { reject(new Error("Unexpected file reader result")); return; }
|
||||
const commaIndex = result.indexOf(",");
|
||||
resolve(commaIndex === -1 ? result : result.slice(commaIndex + 1));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
const proseInputAssistanceAttributes: Record<string, string> = {
|
||||
spellcheck: "true",
|
||||
autocorrect: "on",
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface ToolExecutionPart {
|
||||
|
||||
export type ChatPart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; mimeType: string; data: string }
|
||||
| { type: "thinking"; text: string }
|
||||
| { type: "skillInvocation"; name: string; location: string; content: string }
|
||||
| { type: "skillRead"; name: string; path: string }
|
||||
@@ -290,6 +291,7 @@ export const chatStyles = css`
|
||||
.msg.event-group.live > summary { border-bottom-color: var(--pi-success-border); background: var(--pi-success-bg); color: var(--pi-success); }
|
||||
.msg.event-group > summary .label { margin: 0; }
|
||||
.group-body { padding: 0 12px 12px; }
|
||||
.chat-image { display: block; max-width: 100%; max-height: 320px; margin: 8px 0 0; border: 1px solid var(--pi-border-muted); border-radius: 8px; object-fit: contain; }
|
||||
.group-msg { max-width: 100%; min-width: 0; box-sizing: border-box; padding: 10px 0; border-top: 1px solid var(--pi-border-muted); color: var(--pi-text); overflow: visible; }
|
||||
.group-msg.tool { color: var(--pi-warning); }
|
||||
.group-msg.tool-execution-shell { color: var(--pi-text); }
|
||||
@@ -457,6 +459,12 @@ export const promptEditorStyles = css`
|
||||
.markdown-editor .cm-focused { outline: none; }
|
||||
.shell-mode textarea, .shell-mode .markdown-editor .cm-editor { border-color: var(--pi-success); box-shadow: 0 0 0 1px var(--pi-success-ring); }
|
||||
.mode-hint { position: absolute; right: 8px; bottom: 8px; max-width: calc(100% - 16px); border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 2px 8px; font-size: 12px; pointer-events: none; }
|
||||
.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-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; }
|
||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||
button:disabled, textarea:disabled, .markdown-editor-disabled .cm-editor { opacity: .5; cursor: not-allowed; }
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api as defaultApi, type CommandResult, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type ThinkingLevel } from "../api";
|
||||
import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type ThinkingLevel } from "../api";
|
||||
import type { AppState } from "../appState";
|
||||
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
|
||||
import { textMessage } from "../chatMessages";
|
||||
@@ -168,14 +168,15 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
async send(text: string, streamingBehavior?: "steer" | "followUp") {
|
||||
async send(text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[]) {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("/")) return this.runCommand(text);
|
||||
if (isShellInput(text)) return this.runShell(text);
|
||||
const hasAttachments = attachments !== undefined && attachments.length > 0;
|
||||
if (!hasAttachments && trimmed.startsWith("/")) return this.runCommand(text);
|
||||
if (!hasAttachments && isShellInput(text)) return this.runShell(text);
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true) return;
|
||||
try {
|
||||
await this.api.prompt(session, text, streamingBehavior, selectedMachineId(this.getState()));
|
||||
await this.api.prompt(session, text, streamingBehavior, selectedMachineId(this.getState()), attachments);
|
||||
this.markCachedNewSessionPersisted(session);
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
@@ -194,6 +195,12 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
async saveAttachments(attachments: PromptAttachment[], folder?: string) {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true || attachments.length === 0) return [];
|
||||
return this.api.saveAttachments(session, attachments, selectedMachineId(this.getState()), folder);
|
||||
}
|
||||
|
||||
async runCommand(text: string) {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true) return;
|
||||
|
||||
+20
-1
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { loadPiWebConfig, savePiWebConfig } from "./config.js";
|
||||
import { DEFAULT_MAX_UPLOAD_BYTES, loadPiWebConfig, maxUploadBytes, savePiWebConfig } from "./config.js";
|
||||
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
@@ -37,6 +37,25 @@ describe("PI WEB config persistence", () => {
|
||||
|
||||
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config plugin enabled values must be booleans");
|
||||
});
|
||||
|
||||
it("persists and reads maxUploadBytes", () => {
|
||||
savePiWebConfig({ maxUploadBytes: 1234 }, testOptions());
|
||||
expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maxUploadBytes", () => {
|
||||
it("defaults when nothing is configured", () => {
|
||||
expect(maxUploadBytes({}, {})).toBe(DEFAULT_MAX_UPLOAD_BYTES);
|
||||
});
|
||||
|
||||
it("prefers the env override over config", () => {
|
||||
expect(maxUploadBytes({ PI_WEB_MAX_UPLOAD_BYTES: "2048" }, { maxUploadBytes: 99 })).toBe(2048);
|
||||
});
|
||||
|
||||
it("falls back to config when env is unset or invalid", () => {
|
||||
expect(maxUploadBytes({ PI_WEB_MAX_UPLOAD_BYTES: "not-a-number" }, { maxUploadBytes: 555 })).toBe(555);
|
||||
});
|
||||
});
|
||||
|
||||
function testOptions(): { env: NodeJS.ProcessEnv } {
|
||||
|
||||
@@ -26,6 +26,23 @@ export function defaultPiWebDataDir(): string {
|
||||
return join(homedir(), ".pi-web");
|
||||
}
|
||||
|
||||
/**
|
||||
* Default maximum HTTP body size (bytes) for the web/API and session daemon.
|
||||
* Generous headroom for base64 image attachments (well above pi's 4.5MB
|
||||
* per-image inline limit so several images fit in one request).
|
||||
*/
|
||||
export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export function maxUploadBytes(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): number {
|
||||
const fromEnv = env["PI_WEB_MAX_UPLOAD_BYTES"];
|
||||
if (fromEnv !== undefined && fromEnv !== "") {
|
||||
const parsed = Number(fromEnv);
|
||||
if (Number.isInteger(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
if (config.maxUploadBytes !== undefined) return config.maxUploadBytes;
|
||||
return DEFAULT_MAX_UPLOAD_BYTES;
|
||||
}
|
||||
|
||||
export function piWebDataDir(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
|
||||
const configured = env["PI_WEB_DATA_DIR"];
|
||||
if (configured === undefined || configured === "") return defaultPiWebDataDir();
|
||||
@@ -55,6 +72,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf
|
||||
const host = env["PI_WEB_HOST"];
|
||||
const port = env["PI_WEB_PORT"] ?? env["PORT"];
|
||||
const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"];
|
||||
const maxUpload = env["PI_WEB_MAX_UPLOAD_BYTES"];
|
||||
|
||||
return {
|
||||
...loaded,
|
||||
@@ -63,6 +81,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf
|
||||
...(host !== undefined && host !== "" ? { host } : {}),
|
||||
...(port !== undefined && port !== "" ? { port: parsePort(port, "PI_WEB_PORT") } : {}),
|
||||
...(allowedHosts !== undefined && allowedHosts !== "" ? { allowedHosts: parseAllowedHostsEnv(allowedHosts) } : {}),
|
||||
...(maxUpload !== undefined && maxUpload !== "" ? { maxUploadBytes: parseMaxUploadBytes(maxUpload, "PI_WEB_MAX_UPLOAD_BYTES") } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -77,6 +96,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
|
||||
delete existing["allowedHosts"];
|
||||
delete existing["shortcuts"];
|
||||
delete existing["plugins"];
|
||||
delete existing["maxUploadBytes"];
|
||||
const merged = { ...existing, ...piWebConfigRecord(normalized) };
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
||||
@@ -97,6 +117,7 @@ function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
|
||||
...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}),
|
||||
...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}),
|
||||
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
|
||||
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,9 +128,16 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
|
||||
...(value["allowedHosts"] !== undefined ? { allowedHosts: parseAllowedHosts(value["allowedHosts"], path) } : {}),
|
||||
...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}),
|
||||
...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}),
|
||||
...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMaxUploadBytes(value: unknown, key: string, path = "environment"): number {
|
||||
const bytes = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN;
|
||||
if (!Number.isInteger(bytes) || bytes < 1) throw new Error(`PI WEB config ${key} must be a positive integer: ${path}`);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parseString(value: unknown, key: string, path: string): string {
|
||||
if (typeof value !== "string" || value === "") throw new Error(`PI WEB config ${key} must be a non-empty string: ${path}`);
|
||||
return value;
|
||||
|
||||
+3
-1
@@ -34,6 +34,8 @@ export interface AppDependencies {
|
||||
config?: PiWebConfigService;
|
||||
clientDist?: string | false;
|
||||
logger?: FastifyServerOptions["logger"];
|
||||
/** Maximum accepted HTTP request body size in bytes. */
|
||||
bodyLimit?: number;
|
||||
}
|
||||
|
||||
function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string): void {
|
||||
@@ -88,7 +90,7 @@ function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string)
|
||||
}
|
||||
|
||||
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: deps.logger ?? true });
|
||||
const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) });
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { effectivePiWebConfig } from "../config.js";
|
||||
import { effectivePiWebConfig, maxUploadBytes } from "../config.js";
|
||||
import { buildApp } from "./app.js";
|
||||
|
||||
const app = await buildApp();
|
||||
const { config } = effectivePiWebConfig();
|
||||
const app = await buildApp({ bodyLimit: maxUploadBytes(process.env, config) });
|
||||
await app.listen({ port: config.port ?? 8504, host: config.host ?? "127.0.0.1" });
|
||||
|
||||
@@ -15,8 +15,9 @@ import { TerminalService } from "./terminals/terminalService.js";
|
||||
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
|
||||
import { getPiWebRuntimeComponent } from "./piWebStatus.js";
|
||||
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { maxUploadBytes } from "../config.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes() });
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
const eventHub = new SessionEventHub();
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { mkdtemp, readFile, readdir, rm } 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;
|
||||
|
||||
beforeEach(async () => {
|
||||
workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const pngBase64 = pngBytes.toString("base64");
|
||||
|
||||
describe("saveAttachmentsToWorkspace", () => {
|
||||
it("writes attachments into the default folder and returns relative paths", async () => {
|
||||
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
|
||||
const saved = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[
|
||||
{ kind: "image", mimeType: "image/png", data: pngBase64, name: "a.png" },
|
||||
{ kind: "image", mimeType: "image/webp", data: pngBase64, name: "b.webp" },
|
||||
],
|
||||
{ now: fixedNow },
|
||||
);
|
||||
|
||||
expect(saved).toHaveLength(2);
|
||||
expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/paste-`)).toBe(true);
|
||||
expect(saved[0]?.path.endsWith(".png")).toBe(true);
|
||||
expect(saved[1]?.path.endsWith(".webp")).toBe(true);
|
||||
expect(saved[0]?.size).toBe(pngBytes.byteLength);
|
||||
|
||||
const folderEntries = await readdir(join(workspace, ".pi-web", "paste"));
|
||||
expect(folderEntries).toHaveLength(2);
|
||||
|
||||
const firstPath = saved[0]?.path ?? "";
|
||||
const written = await readFile(join(workspace, firstPath));
|
||||
expect(written.equals(pngBytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("honors a custom folder", async () => {
|
||||
const saved = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
|
||||
{ folder: "uploads/images" },
|
||||
);
|
||||
expect(saved[0]?.path.startsWith("uploads/images/")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty for no attachments", async () => {
|
||||
expect(await saveAttachmentsToWorkspace(workspace, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { 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 { extensionForImageMimeType } from "../../shared/promptAttachments.js";
|
||||
import { resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
|
||||
|
||||
/**
|
||||
* Default workspace-relative folder used when saving pasted/dropped
|
||||
* attachments for the agent to read with its own tools.
|
||||
*/
|
||||
export const DEFAULT_ATTACHMENT_FOLDER = ".pi-web/paste";
|
||||
|
||||
export interface InlineImage {
|
||||
image: ImageContent;
|
||||
/** Optional human-readable dimension note produced by pi when resizing. */
|
||||
dimensionNote?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert validated attachments into pi-compatible inline image content.
|
||||
*
|
||||
* Mirrors pi's own CLI/TUI behaviour: each image is run through pi's
|
||||
* `resizeImage` so it fits within pi's max dimensions and inline byte budget
|
||||
* (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[]> {
|
||||
const results: InlineImage[] = [];
|
||||
for (const attachment of attachments) {
|
||||
const bytes = Buffer.from(attachment.data, "base64");
|
||||
const resized = await resizeImage(bytes, attachment.mimeType);
|
||||
if (resized === null) continue;
|
||||
const note = formatDimensionNote(resized);
|
||||
results.push({
|
||||
image: { type: "image", data: resized.data, mimeType: resized.mimeType },
|
||||
...(note === undefined ? {} : { dimensionNote: note }),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface SaveAttachmentsOptions {
|
||||
/** Workspace-relative folder to write into. Defaults to `.pi-web/paste`. */
|
||||
folder?: string;
|
||||
/** Clock injection for deterministic tests. */
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write attachments into a workspace folder and return their relative paths.
|
||||
* Filenames are collision-safe and stay inside the workspace root.
|
||||
*/
|
||||
export async function saveAttachmentsToWorkspace(
|
||||
cwd: string,
|
||||
attachments: PromptAttachment[],
|
||||
options: SaveAttachmentsOptions = {},
|
||||
): Promise<SavedPromptAttachment[]> {
|
||||
const folder = normalizeFolder(options.folder ?? DEFAULT_ATTACHMENT_FOLDER);
|
||||
const now = options.now ?? (() => new Date());
|
||||
const { target: folderTarget } = await resolveParentInsideWorkspace(cwd, folder);
|
||||
await mkdir(folderTarget, { recursive: true });
|
||||
|
||||
const stamp = timestamp(now());
|
||||
const saved: SavedPromptAttachment[] = [];
|
||||
for (const [index, attachment] of attachments.entries()) {
|
||||
const bytes = Buffer.from(attachment.data, "base64");
|
||||
const filename = `paste-${stamp}-${String(index + 1)}.${extensionForImageMimeType(attachment.mimeType)}`;
|
||||
const relativePath = `${folder}/${filename}`;
|
||||
await writeFile(join(folderTarget, filename), bytes);
|
||||
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("/");
|
||||
}
|
||||
|
||||
function timestamp(date: Date): string {
|
||||
const pad = (value: number, length = 2) => String(value).padStart(length, "0");
|
||||
return `${String(date.getFullYear())}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}-${pad(date.getMilliseconds(), 3)}`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import type { Api, Model } from "@earendil-works/pi-ai";
|
||||
import type { Api, ImageContent, Model } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
AuthStorage,
|
||||
createAgentSessionFromServices,
|
||||
@@ -25,6 +25,10 @@ import type { AuthChange } from "./authService.js";
|
||||
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||
import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
|
||||
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
||||
import type { SavedPromptAttachment } from "../../shared/apiTypes.js";
|
||||
|
||||
import { cwdPathsEqual } from "../workingDirectory.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
|
||||
@@ -53,6 +57,7 @@ type QueuedPromptKind = "steer" | "followUp";
|
||||
interface QueuedPrompt {
|
||||
kind: QueuedPromptKind;
|
||||
text: string;
|
||||
images?: ImageContent[];
|
||||
}
|
||||
|
||||
function requirePromptText(value: unknown): string {
|
||||
@@ -147,7 +152,7 @@ export interface PiAgentSession {
|
||||
getUserMessagesForForking(): readonly { entryId: string; text: string }[];
|
||||
getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number };
|
||||
getContextUsage(): ClientSessionStatus["contextUsage"] | undefined;
|
||||
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp" }): Promise<void>;
|
||||
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>;
|
||||
executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string }>;
|
||||
abort(): Promise<void>;
|
||||
clearQueue(): { steering: string[]; followUp: string[] };
|
||||
@@ -407,30 +412,33 @@ export class PiSessionService {
|
||||
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async prompt(ref: PiSessionLookup, text: unknown, streamingBehavior?: unknown): Promise<void> {
|
||||
async prompt(ref: PiSessionLookup, text: unknown, streamingBehavior?: unknown, attachments?: unknown): Promise<void> {
|
||||
const promptText = requirePromptText(text);
|
||||
const requestedBehavior = parsePromptStreamingBehavior(streamingBehavior);
|
||||
const parsedAttachments = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
|
||||
const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
this.maybeGenerateSessionName(session, promptText);
|
||||
const isQueued = session.isStreaming || session.isCompacting;
|
||||
const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
|
||||
if (isQueued && this.hasQueuedMessageText(session, promptText)) {
|
||||
if (isQueued && images.length === 0 && this.hasQueuedMessageText(session, promptText)) {
|
||||
this.publishActivity(session, "duplicate queued message ignored", "active");
|
||||
this.publishStatus(session);
|
||||
return;
|
||||
}
|
||||
if (session.isCompacting) {
|
||||
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp");
|
||||
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images);
|
||||
return;
|
||||
}
|
||||
void this.submitPrompt(session, promptText, behavior);
|
||||
void this.submitPrompt(session, promptText, behavior, images);
|
||||
}
|
||||
|
||||
private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined): Promise<void> {
|
||||
private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined, images: ImageContent[] = []): Promise<void> {
|
||||
this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
|
||||
if (behavior === undefined) this.events.publish(session.sessionId, { type: "message.append", message: userTextMessage(text) });
|
||||
const promptPromise = session.prompt(text, behavior === undefined ? undefined : { streamingBehavior: behavior }).catch((error: unknown) => {
|
||||
if (behavior === undefined) this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
|
||||
const promptOptions = buildPromptOptions(behavior, images);
|
||||
const promptPromise = session.prompt(text, promptOptions).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.publishActivity(session, "error", "error", message);
|
||||
this.events.publish(session.sessionId, { type: "session.error", message });
|
||||
@@ -439,14 +447,22 @@ export class PiSessionService {
|
||||
return promptPromise;
|
||||
}
|
||||
|
||||
private enqueuePromptDuringCompaction(session: PiAgentSession, text: string, kind: QueuedPromptKind): void {
|
||||
private enqueuePromptDuringCompaction(session: PiAgentSession, text: string, kind: QueuedPromptKind, images: ImageContent[] = []): void {
|
||||
const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
|
||||
queue.push({ kind, text });
|
||||
queue.push({ kind, text, ...(images.length > 0 ? { images } : {}) });
|
||||
this.compactionPromptQueues.set(session.sessionId, queue);
|
||||
this.publishActivity(session, "message queued during compaction", "active");
|
||||
this.publishStatus(session);
|
||||
}
|
||||
|
||||
async saveAttachments(ref: PiSessionLookup, attachments: unknown, folder?: string): Promise<SavedPromptAttachment[]> {
|
||||
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
|
||||
if (parsed.length === 0) return [];
|
||||
await this.assertWritable(ref);
|
||||
const active = await this.getActive(ref);
|
||||
return saveAttachmentsToWorkspace(active.runtime.cwd, parsed, folder === undefined ? {} : { folder });
|
||||
}
|
||||
|
||||
async shell(ref: PiSessionLookup, text: string): Promise<void> {
|
||||
await this.assertWritable(ref);
|
||||
const active = await this.getActive(ref);
|
||||
@@ -768,14 +784,14 @@ export class PiSessionService {
|
||||
const queued = this.takeCompactionPromptQueue(sessionId);
|
||||
if (queued.length === 0) return;
|
||||
this.publishStatus(session);
|
||||
for (const prompt of queued) void this.submitPrompt(session, prompt.text, prompt.kind);
|
||||
for (const prompt of queued) void this.submitPrompt(session, prompt.text, prompt.kind, prompt.images);
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = this.shiftCompactionPrompt(sessionId);
|
||||
if (prompt === undefined) return;
|
||||
this.publishStatus(session);
|
||||
const submitted = this.submitPrompt(session, prompt.text, undefined);
|
||||
const submitted = this.submitPrompt(session, prompt.text, undefined, prompt.images);
|
||||
void submitted.finally(() => { this.scheduleCompactionQueueDrain(sessionId); });
|
||||
}
|
||||
|
||||
@@ -1165,6 +1181,26 @@ function userTextMessage(text: string): { role: "user"; content: string } {
|
||||
return { role: "user", content: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the optimistic user message echoed to clients. When images are present
|
||||
* we mirror pi's content-array shape (`[{type:"text"}, {type:"image"}, ...]`) so
|
||||
* the local echo matches what pi persists in the session branch.
|
||||
*/
|
||||
function userMessage(text: string, images: ImageContent[]): { role: "user"; content: string | (ImageContent | { type: "text"; text: string })[] } {
|
||||
if (images.length === 0) return userTextMessage(text);
|
||||
const content: (ImageContent | { type: "text"; text: string })[] = [];
|
||||
if (text !== "") content.push({ type: "text", text });
|
||||
content.push(...images);
|
||||
return { role: "user", content };
|
||||
}
|
||||
|
||||
function buildPromptOptions(behavior: QueuedPromptKind | undefined, images: ImageContent[]): { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] } | undefined {
|
||||
const options: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] } = {};
|
||||
if (behavior !== undefined) options.streamingBehavior = behavior;
|
||||
if (images.length > 0) options.images = images;
|
||||
return Object.keys(options).length > 0 ? options : undefined;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
@@ -53,6 +53,28 @@ describe("session routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards prompt attachments and supports the save-attachments route", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService(eventHub);
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
|
||||
const attachments = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||
try {
|
||||
const promptResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { text: "look", attachments } });
|
||||
expect(promptResponse.statusCode).toBe(200);
|
||||
expect(routeService.calls.at(-1)).toEqual({ lookup: "session-1", text: "look", attachments });
|
||||
|
||||
const saveResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/attachments", payload: { attachments, folder: "uploads" } });
|
||||
expect(saveResponse.statusCode).toBe(200);
|
||||
expect(saveResponse.json()).toEqual({ attachments: [{ path: "uploads/shot.png", mimeType: "image/png", size: 3 }] });
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes cwd when per-session routes include workspace context", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
@@ -98,10 +120,19 @@ class CapturingRouteSessionService extends PiSessionService {
|
||||
});
|
||||
}
|
||||
|
||||
override prompt(lookup: string | PiSessionRef, text: unknown): Promise<void> {
|
||||
this.calls.push({ lookup, text });
|
||||
override prompt(lookup: string | PiSessionRef, text: unknown, _streamingBehavior?: unknown, attachments?: unknown): Promise<void> {
|
||||
this.calls.push(attachments === undefined ? { lookup, text } : { lookup, text, attachments });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
override saveAttachments(_lookup: string | PiSessionRef, attachments: unknown, folder?: string) {
|
||||
const list = Array.isArray(attachments) ? attachments : [];
|
||||
return Promise.resolve(list.map((attachment: { mimeType: string; data: string; name?: string }) => ({
|
||||
path: `${folder ?? ".pi-web/paste"}/${attachment.name ?? "file.png"}`,
|
||||
mimeType: attachment.mimeType,
|
||||
size: Buffer.from(attachment.data, "base64").byteLength,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
class RejectingSessionManager implements PiSessionManagerGateway {
|
||||
|
||||
@@ -18,6 +18,13 @@ interface PromptRequestBody {
|
||||
cwd?: unknown;
|
||||
text?: unknown;
|
||||
streamingBehavior?: unknown;
|
||||
attachments?: unknown;
|
||||
}
|
||||
|
||||
interface AttachmentsRequestBody {
|
||||
cwd?: unknown;
|
||||
attachments?: unknown;
|
||||
folder?: unknown;
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void {
|
||||
@@ -121,13 +128,25 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
app.post<{ Params: { sessionId: string }; Body: PromptRequestBody | undefined }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
|
||||
try {
|
||||
const body = optionalRecord(request.body);
|
||||
await sessions.prompt(sessionLookupFromBody(request.params.sessionId, body), body["text"], body["streamingBehavior"]);
|
||||
await sessions.prompt(sessionLookupFromBody(request.params.sessionId, body), body["text"], body["streamingBehavior"], body["attachments"]);
|
||||
return { accepted: true };
|
||||
} catch (error) {
|
||||
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: AttachmentsRequestBody | undefined }>(`${prefix}/sessions/:sessionId/attachments`, async (request, reply) => {
|
||||
try {
|
||||
const body = optionalRecord(request.body);
|
||||
const folder = body["folder"];
|
||||
if (folder !== undefined && typeof folder !== "string") throw new Error("folder field must be a string");
|
||||
const attachments = await sessions.saveAttachments(sessionLookupFromBody(request.params.sessionId, body), body["attachments"], folder);
|
||||
return { attachments };
|
||||
} catch (error) {
|
||||
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/shell`, async (request, reply) => {
|
||||
try {
|
||||
const body = optionalRecord(request.body);
|
||||
|
||||
@@ -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