This repository has been archived on 2026-08-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
pi-web/src/config.ts
T
Federico Jaramillo Martinez d17050e144 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
2026-06-13 13:49:39 +02:00

203 lines
9.2 KiB
TypeScript

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import type { PiWebConfigValues } from "./shared/apiTypes.js";
import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js";
export type PiWebConfig = PiWebConfigValues;
export interface LoadedPiWebConfig {
path: string;
exists: boolean;
config: PiWebConfig;
}
export interface LoadOptions {
env?: NodeJS.ProcessEnv;
cwd?: string;
}
export function defaultPiWebConfigPath(env: NodeJS.ProcessEnv = process.env): string {
const xdgConfigHome = env["XDG_CONFIG_HOME"];
return join(xdgConfigHome !== undefined && xdgConfigHome !== "" ? xdgConfigHome : join(homedir(), ".config"), "pi-web", "config.json");
}
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();
return resolve(cwd, configured);
}
export function piWebConfigPath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
const configured = env["PI_WEB_CONFIG"];
if (configured === undefined || configured === "") return defaultPiWebConfigPath(env);
return resolve(cwd, configured);
}
export function loadPiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig {
const env = options.env ?? process.env;
const path = piWebConfigPath(env, options.cwd ?? process.cwd());
if (!existsSync(path)) return { path, exists: false, config: {} };
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isRecord(parsed)) throw new Error(`PI WEB config must be a JSON object: ${path}`);
return { path, exists: true, config: parsePiWebConfig(parsed, path) };
}
export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig {
const loaded = loadPiWebConfig(options);
const env = options.env ?? process.env;
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,
config: {
...loaded.config,
...(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") } : {}),
},
};
}
export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): LoadedPiWebConfig {
const env = options.env ?? process.env;
const path = piWebConfigPath(env, options.cwd ?? process.cwd());
const normalized = parsePiWebConfig(piWebConfigRecord(config), path);
const existing = readExistingConfigObject(path);
delete existing["host"];
delete existing["port"];
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");
return { path, exists: true, config: normalized };
}
function readExistingConfigObject(path: string): Record<string, unknown> {
if (!existsSync(path)) return {};
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isRecord(parsed)) throw new Error(`PI WEB config must be a JSON object: ${path}`);
return parsed;
}
function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
return {
...(config.host !== undefined ? { host: config.host } : {}),
...(config.port !== undefined ? { port: config.port } : {}),
...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}),
...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}),
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
};
}
function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebConfig {
return {
...(value["host"] !== undefined ? { host: parseString(value["host"], "host", path) } : {}),
...(value["port"] !== undefined ? { port: parsePort(value["port"], "port", path) } : {}),
...(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;
}
function parsePort(value: unknown, key: string, path = "environment"): number {
const port = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN;
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`PI WEB config ${key} must be an integer from 1 to 65535: ${path}`);
return port;
}
function parseAllowedHosts(value: unknown, path: string): string[] | true {
if (value === true) return true;
if (!isNonEmptyStringArray(value)) {
throw new Error(`PI WEB config allowedHosts must be true or an array of non-empty strings: ${path}`);
}
return value;
}
function parseAllowedHostsEnv(value: string): string[] | true {
if (value === "true") return true;
return value.split(",").map((host) => host.trim()).filter((host) => host !== "");
}
function parseShortcuts(value: unknown, path: string): Record<string, string | null> {
if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`);
return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => {
if (shortcut !== null && (typeof shortcut !== "string" || shortcut === "")) {
throw new Error(`PI WEB config shortcut values must be non-empty strings or null: ${path}`);
}
return [actionId, shortcut];
}));
}
function parsePlugins(value: unknown, path: string): NonNullable<PiWebConfigValues["plugins"]> {
if (!isRecord(value) || Array.isArray(value)) throw new Error(`PI WEB config plugins must be an object: ${path}`);
return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => {
if (!isPiWebPluginId(pluginId)) throw new Error(`PI WEB config plugin ids must match ${piWebPluginIdPattern.source}: ${path}`);
if (!isRecord(config) || Array.isArray(config)) throw new Error(`PI WEB config plugin entries must be objects: ${path}`);
const enabled = config["enabled"];
if (enabled !== undefined && typeof enabled !== "boolean") throw new Error(`PI WEB config plugin enabled values must be booleans: ${path}`);
const settings = config["settings"];
if (settings !== undefined && (!isRecord(settings) || Array.isArray(settings))) throw new Error(`PI WEB config plugin settings must be objects: ${path}`);
return [pluginId, config];
}));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
}
export function examplePiWebConfig(config: PiWebConfig = {}): string {
return `${JSON.stringify({ host: config.host ?? "127.0.0.1", port: config.port ?? 8504, allowedHosts: config.allowedHosts ?? [] }, null, 2)}\n`;
}
export function piWebConfigDir(env: NodeJS.ProcessEnv = process.env): string {
return dirname(defaultPiWebConfigPath(env));
}