@narumitw/pi-webui 0.20.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/settings.ts CHANGED
@@ -9,15 +9,35 @@ import {
9
9
  } from "node:fs/promises";
10
10
  import { dirname, join } from "node:path";
11
11
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ DEFAULT_IMAGE_LIMITS,
14
+ IMAGE_HARD_LIMITS,
15
+ type ImageLimits,
16
+ imageLimits,
17
+ } from "./image-limits.js";
12
18
 
13
19
  export const SETTINGS_FILE = "pi-webui.json";
14
20
 
15
- export interface WebUISettings {
21
+ const MIB = 1024 * 1024;
22
+
23
+ export interface WebUISettings extends ImageLimits {
16
24
  startOnSessionStart: boolean;
25
+ retainSentImages: boolean;
26
+ maxRetainedImages: number;
27
+ maxRetainedBytes: number;
17
28
  }
18
29
 
19
30
  export const DEFAULT_SETTINGS: Readonly<WebUISettings> = Object.freeze({
20
31
  startOnSessionStart: false,
32
+ retainSentImages: false,
33
+ maxRetainedImages: 32,
34
+ maxRetainedBytes: 128 * MIB,
35
+ ...DEFAULT_IMAGE_LIMITS,
36
+ });
37
+
38
+ export const RETENTION_HARD_LIMITS = Object.freeze({
39
+ maxRetainedImages: 128,
40
+ maxRetainedBytes: 512 * MIB,
21
41
  });
22
42
 
23
43
  export interface SettingsLoadResult {
@@ -49,17 +69,66 @@ export function settingsFilePath(): string {
49
69
  export function normalizeSettings(value: unknown): WebUISettings | undefined {
50
70
  if (!isRecord(value)) return undefined;
51
71
  if (
52
- Object.hasOwn(value, "startOnSessionStart") &&
53
- typeof value.startOnSessionStart !== "boolean"
72
+ (Object.hasOwn(value, "startOnSessionStart") &&
73
+ typeof value.startOnSessionStart !== "boolean") ||
74
+ (Object.hasOwn(value, "retainSentImages") && typeof value.retainSentImages !== "boolean")
54
75
  ) {
55
76
  return undefined;
56
77
  }
57
- return {
78
+ for (const [key, maximum] of [
79
+ ["maxRetainedImages", RETENTION_HARD_LIMITS.maxRetainedImages],
80
+ ["maxRetainedBytes", RETENTION_HARD_LIMITS.maxRetainedBytes],
81
+ ["maxImages", IMAGE_HARD_LIMITS.maxImages],
82
+ ["maxImageBytes", IMAGE_HARD_LIMITS.maxImageBytes],
83
+ ["maxBatchBytes", IMAGE_HARD_LIMITS.maxBatchBytes],
84
+ ["maxImagePixels", IMAGE_HARD_LIMITS.maxImagePixels],
85
+ ] as const) {
86
+ if (!Object.hasOwn(value, key)) continue;
87
+ const candidate = value[key];
88
+ if (
89
+ typeof candidate !== "number" ||
90
+ !Number.isSafeInteger(candidate) ||
91
+ candidate <= 0 ||
92
+ candidate > maximum
93
+ ) {
94
+ return undefined;
95
+ }
96
+ }
97
+ const normalized = {
58
98
  startOnSessionStart:
59
99
  typeof value.startOnSessionStart === "boolean"
60
100
  ? value.startOnSessionStart
61
101
  : DEFAULT_SETTINGS.startOnSessionStart,
102
+ retainSentImages:
103
+ typeof value.retainSentImages === "boolean"
104
+ ? value.retainSentImages
105
+ : DEFAULT_SETTINGS.retainSentImages,
106
+ maxRetainedImages:
107
+ typeof value.maxRetainedImages === "number"
108
+ ? value.maxRetainedImages
109
+ : DEFAULT_SETTINGS.maxRetainedImages,
110
+ maxRetainedBytes:
111
+ typeof value.maxRetainedBytes === "number"
112
+ ? value.maxRetainedBytes
113
+ : DEFAULT_SETTINGS.maxRetainedBytes,
114
+ ...imageLimits({
115
+ maxImages: typeof value.maxImages === "number" ? value.maxImages : DEFAULT_SETTINGS.maxImages,
116
+ maxImageBytes:
117
+ typeof value.maxImageBytes === "number"
118
+ ? value.maxImageBytes
119
+ : DEFAULT_SETTINGS.maxImageBytes,
120
+ maxBatchBytes:
121
+ typeof value.maxBatchBytes === "number"
122
+ ? value.maxBatchBytes
123
+ : DEFAULT_SETTINGS.maxBatchBytes,
124
+ maxImagePixels:
125
+ typeof value.maxImagePixels === "number"
126
+ ? value.maxImagePixels
127
+ : DEFAULT_SETTINGS.maxImagePixels,
128
+ }),
62
129
  };
130
+ if (normalized.maxImageBytes > normalized.maxBatchBytes) return undefined;
131
+ return normalized;
63
132
  }
64
133
 
65
134
  export async function loadSettings(path = settingsFilePath()): Promise<SettingsLoadResult> {
@@ -86,8 +155,16 @@ export async function loadSettings(path = settingsFilePath()): Promise<SettingsL
86
155
  const document = JSON.parse(text) as unknown;
87
156
  if (!isRecord(document)) return invalid(path, "the top level must be a JSON object");
88
157
  const settings = normalizeSettings(document);
89
- if (!settings) return invalid(path, "startOnSessionStart must be a boolean");
90
- return { kind: "loaded", path, settings, source: "settings file", document };
158
+ if (!settings) return invalid(path, "recognized settings have an invalid type or limit");
159
+ const warning = elevatedLimitWarning(settings);
160
+ return {
161
+ kind: "loaded",
162
+ path,
163
+ settings,
164
+ source: "settings file",
165
+ document,
166
+ ...(warning ? { warning } : {}),
167
+ };
91
168
  } catch (error) {
92
169
  return invalid(path, formatError(error));
93
170
  }
@@ -99,7 +176,17 @@ export async function saveSettings(
99
176
  path = settingsFilePath(),
100
177
  operations: Partial<SettingsFileOperations> = {},
101
178
  ): Promise<Record<string, unknown>> {
102
- const nextDocument = { ...document, startOnSessionStart: settings.startOnSessionStart };
179
+ const nextDocument = {
180
+ ...document,
181
+ startOnSessionStart: settings.startOnSessionStart,
182
+ retainSentImages: settings.retainSentImages,
183
+ maxRetainedImages: settings.maxRetainedImages,
184
+ maxRetainedBytes: settings.maxRetainedBytes,
185
+ maxImages: settings.maxImages,
186
+ maxImageBytes: settings.maxImageBytes,
187
+ maxBatchBytes: settings.maxBatchBytes,
188
+ maxImagePixels: settings.maxImagePixels,
189
+ };
103
190
  const directory = dirname(path);
104
191
  await mkdir(directory, { recursive: true });
105
192
  const temporaryPath = temporaryFilePath(path);
@@ -147,6 +234,14 @@ export async function initializeSettings(
147
234
  }
148
235
  }
149
236
 
237
+ function elevatedLimitWarning(settings: WebUISettings): string | undefined {
238
+ const elevated = (Object.keys(DEFAULT_IMAGE_LIMITS) as Array<keyof ImageLimits>).filter(
239
+ (key) => settings[key] > DEFAULT_IMAGE_LIMITS[key],
240
+ );
241
+ if (elevated.length === 0) return undefined;
242
+ return `${SETTINGS_FILE} uses image limits above safe defaults: ${elevated.join(", ")}. Higher limits increase Pi-process memory and processing cost.`;
243
+ }
244
+
150
245
  function invalid(path: string, reason: string): SettingsLoadResult {
151
246
  return {
152
247
  kind: "invalid",
@@ -0,0 +1,30 @@
1
+ declare module "bmp-js" {
2
+ interface DecodedBmp {
3
+ data: Buffer;
4
+ width: number;
5
+ height: number;
6
+ }
7
+ export function decode(buffer: Buffer, withAlpha?: boolean): DecodedBmp;
8
+ }
9
+
10
+ declare module "heic-decode" {
11
+ interface DecodedImage {
12
+ width: number;
13
+ height: number;
14
+ data: Uint8ClampedArray;
15
+ }
16
+ interface DeferredImage {
17
+ width: number;
18
+ height: number;
19
+ decode(): Promise<DecodedImage>;
20
+ }
21
+ interface DeferredImages extends Array<DeferredImage> {
22
+ dispose(): void;
23
+ }
24
+ interface Decode {
25
+ (options: { buffer: Uint8Array }): Promise<DecodedImage>;
26
+ all(options: { buffer: Uint8Array }): Promise<DeferredImages>;
27
+ }
28
+ const decode: Decode;
29
+ export default decode;
30
+ }