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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user