@narumitw/pi-webui 0.21.0 → 0.25.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/README.md +59 -27
- package/package.json +3 -1
- package/src/attachments.ts +715 -0
- package/src/conversation.ts +23 -6
- package/src/drafts.ts +226 -0
- package/src/image-limits.ts +36 -0
- package/src/image-pipeline.ts +325 -0
- package/src/images.ts +182 -140
- package/src/pi-settings.ts +11 -6
- package/src/runtime.ts +138 -34
- package/src/sent-images.ts +279 -0
- package/src/server.ts +490 -30
- package/src/settings.ts +102 -7
- package/src/vendor.d.ts +30 -0
- package/src/web/app.js +558 -100
- package/src/web/image-drag.js +86 -0
- package/src/web/index.html +25 -2
- package/src/web/state.js +178 -8
- package/src/web/styles.css +176 -11
- package/src/web/transcript.js +66 -9
package/src/images.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
2
|
-
import
|
|
2
|
+
import { DEFAULT_IMAGE_LIMITS, type ImageLimits, PROVIDER_IMAGE_LIMITS } from "./image-limits.js";
|
|
3
|
+
import {
|
|
4
|
+
detectImageFormat,
|
|
5
|
+
type ProcessedBrowserImage,
|
|
6
|
+
type ProcessImageOptions,
|
|
7
|
+
processImage,
|
|
8
|
+
} from "./image-pipeline.js";
|
|
3
9
|
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
maxPixels: 50_000_000,
|
|
9
|
-
maxDimension: 2_000,
|
|
10
|
-
maxBase64Bytes: 4_500_000,
|
|
11
|
-
} as const;
|
|
10
|
+
export { DEFAULT_IMAGE_LIMITS, PROVIDER_IMAGE_LIMITS } from "./image-limits.js";
|
|
11
|
+
export type { ProcessedBrowserImage } from "./image-pipeline.js";
|
|
12
|
+
|
|
13
|
+
export { detectImageFormat } from "./image-pipeline.js";
|
|
12
14
|
|
|
13
15
|
export interface BrowserImageInput {
|
|
14
16
|
name?: string;
|
|
@@ -29,13 +31,124 @@ export interface ProcessBrowserImageOptions {
|
|
|
29
31
|
signal?: AbortSignal;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
interface ImageProcessorOptions {
|
|
35
|
+
autoResize: boolean;
|
|
36
|
+
maxPixels: number;
|
|
37
|
+
maxDimension?: number;
|
|
38
|
+
maxBase64Bytes?: number;
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type ImageProcessFunction = (
|
|
43
|
+
source: Uint8Array,
|
|
44
|
+
options: ProcessImageOptions,
|
|
45
|
+
) => Promise<ImageContent>;
|
|
46
|
+
|
|
47
|
+
interface QueuedImageProcess {
|
|
48
|
+
source: Uint8Array;
|
|
49
|
+
options: ProcessImageOptions;
|
|
50
|
+
resolve: (result: ImageContent) => void;
|
|
51
|
+
reject: (error: unknown) => void;
|
|
52
|
+
removeAbortListener?: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class ImageProcessor {
|
|
56
|
+
private readonly queue: QueuedImageProcess[] = [];
|
|
57
|
+
private active = 0;
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
private readonly concurrency = 2,
|
|
61
|
+
private readonly processor: ImageProcessFunction = processProviderImage,
|
|
62
|
+
) {
|
|
63
|
+
if (!Number.isSafeInteger(concurrency) || concurrency <= 0) {
|
|
64
|
+
throw new Error("Image processing concurrency must be a positive integer");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process(source: Uint8Array, options: ImageProcessorOptions): Promise<ImageContent> {
|
|
69
|
+
const resolved: ProcessImageOptions = {
|
|
70
|
+
...options,
|
|
71
|
+
maxDimension: options.maxDimension ?? PROVIDER_IMAGE_LIMITS.maxDimension,
|
|
72
|
+
maxBase64Bytes: options.maxBase64Bytes ?? PROVIDER_IMAGE_LIMITS.maxBase64Bytes,
|
|
73
|
+
};
|
|
74
|
+
assertNotAborted(resolved.signal);
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const job: QueuedImageProcess = { source, options: resolved, resolve, reject };
|
|
77
|
+
if (resolved.signal) {
|
|
78
|
+
const onAbort = () => {
|
|
79
|
+
const index = this.queue.indexOf(job);
|
|
80
|
+
if (index === -1) return;
|
|
81
|
+
this.queue.splice(index, 1);
|
|
82
|
+
job.removeAbortListener?.();
|
|
83
|
+
reject(abortError());
|
|
84
|
+
};
|
|
85
|
+
resolved.signal.addEventListener("abort", onAbort, { once: true });
|
|
86
|
+
job.removeAbortListener = () => resolved.signal?.removeEventListener("abort", onAbort);
|
|
87
|
+
}
|
|
88
|
+
this.queue.push(job);
|
|
89
|
+
this.pump();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private pump(): void {
|
|
94
|
+
while (this.active < this.concurrency && this.queue.length > 0) {
|
|
95
|
+
const job = this.queue.shift();
|
|
96
|
+
if (!job) return;
|
|
97
|
+
job.removeAbortListener?.();
|
|
98
|
+
if (job.options.signal?.aborted) {
|
|
99
|
+
job.reject(abortError());
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
this.active += 1;
|
|
103
|
+
void this.processor(job.source, job.options)
|
|
104
|
+
.then(job.resolve, job.reject)
|
|
105
|
+
.finally(() => {
|
|
106
|
+
this.active -= 1;
|
|
107
|
+
this.pump();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function validateStagedBrowserImages(
|
|
114
|
+
inputs: readonly BrowserImageInput[],
|
|
115
|
+
limits: Readonly<ImageLimits> = DEFAULT_IMAGE_LIMITS,
|
|
116
|
+
): void {
|
|
117
|
+
if (inputs.length > limits.maxImages) {
|
|
118
|
+
throw new Error(`Too many images; maximum is ${limits.maxImages}.`);
|
|
119
|
+
}
|
|
120
|
+
let total = 0;
|
|
121
|
+
for (const [index, input] of inputs.entries()) {
|
|
122
|
+
total += decodeInput(input, index, PROVIDER_IMAGE_LIMITS.maxBase64Bytes, false).byteLength;
|
|
123
|
+
if (total > limits.maxBatchBytes) throw new Error("Combined image input is too large.");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function processStagedImage(
|
|
128
|
+
source: Uint8Array,
|
|
129
|
+
options: ProcessBrowserImageOptions = {},
|
|
130
|
+
): Promise<ProcessedBrowserImage> {
|
|
131
|
+
const limits = resolvedLimits(options);
|
|
132
|
+
if (options.blockImages) throw new Error("Pi image sending is disabled.");
|
|
133
|
+
if (options.supportsImages === false)
|
|
134
|
+
throw new Error("The current model does not support images.");
|
|
135
|
+
assertNotAborted(options.signal);
|
|
136
|
+
if (source.byteLength === 0) throw new Error("Image is empty.");
|
|
137
|
+
if (source.byteLength > limits.maxImageBytes) throw new Error("Image is too large.");
|
|
138
|
+
return processImage(source, {
|
|
139
|
+
autoResize: options.autoResize !== false,
|
|
140
|
+
maxPixels: limits.maxPixels,
|
|
141
|
+
maxDimension: limits.maxDimension,
|
|
142
|
+
maxBase64Bytes: limits.maxBase64Bytes,
|
|
143
|
+
signal: options.signal,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
33
146
|
|
|
34
147
|
export async function processBrowserImages(
|
|
35
148
|
inputs: BrowserImageInput[],
|
|
36
149
|
options: ProcessBrowserImageOptions = {},
|
|
37
150
|
): Promise<ImageContent[]> {
|
|
38
|
-
const limits =
|
|
151
|
+
const limits = resolvedLimits(options);
|
|
39
152
|
if (options.blockImages) throw new Error("Pi image sending is disabled.");
|
|
40
153
|
if (options.supportsImages === false)
|
|
41
154
|
throw new Error("The current model does not support images.");
|
|
@@ -44,35 +157,50 @@ export async function processBrowserImages(
|
|
|
44
157
|
assertNotAborted(options.signal);
|
|
45
158
|
|
|
46
159
|
const decoded = inputs.map((input, index) => decodeInput(input, index, limits.maxImageBytes));
|
|
47
|
-
const totalSourceBytes = decoded.reduce((total, item) => total + item.
|
|
160
|
+
const totalSourceBytes = decoded.reduce((total, item) => total + item.byteLength, 0);
|
|
48
161
|
if (totalSourceBytes > limits.maxPromptBytes) {
|
|
49
162
|
throw new Error("Combined image input is too large.");
|
|
50
163
|
}
|
|
51
164
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
165
|
+
const processor = new ImageProcessor(2);
|
|
166
|
+
const output = await Promise.all(
|
|
167
|
+
decoded.map((bytes) =>
|
|
168
|
+
processor.process(bytes, {
|
|
169
|
+
autoResize: options.autoResize !== false,
|
|
170
|
+
maxPixels: limits.maxPixels,
|
|
171
|
+
maxDimension: limits.maxDimension,
|
|
172
|
+
maxBase64Bytes: limits.maxBase64Bytes,
|
|
173
|
+
signal: options.signal,
|
|
174
|
+
}),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
const totalOutputBytes = output.reduce(
|
|
178
|
+
(total, item) => total + Buffer.byteLength(item.data, "base64"),
|
|
179
|
+
0,
|
|
180
|
+
);
|
|
181
|
+
if (totalOutputBytes > limits.maxPromptBytes) {
|
|
182
|
+
throw new Error("Combined processed images are too large.");
|
|
70
183
|
}
|
|
71
184
|
return output;
|
|
72
185
|
}
|
|
73
186
|
|
|
74
|
-
function
|
|
75
|
-
|
|
187
|
+
function resolvedLimits(options: ProcessBrowserImageOptions): {
|
|
188
|
+
maxImages: number;
|
|
189
|
+
maxImageBytes: number;
|
|
190
|
+
maxPromptBytes: number;
|
|
191
|
+
maxPixels: number;
|
|
192
|
+
maxDimension: number;
|
|
193
|
+
maxBase64Bytes: number;
|
|
194
|
+
} {
|
|
195
|
+
const result = {
|
|
196
|
+
maxImages: DEFAULT_IMAGE_LIMITS.maxImages,
|
|
197
|
+
maxImageBytes: DEFAULT_IMAGE_LIMITS.maxImageBytes,
|
|
198
|
+
maxPromptBytes: DEFAULT_IMAGE_LIMITS.maxBatchBytes,
|
|
199
|
+
maxPixels: DEFAULT_IMAGE_LIMITS.maxImagePixels,
|
|
200
|
+
maxDimension: PROVIDER_IMAGE_LIMITS.maxDimension,
|
|
201
|
+
maxBase64Bytes: PROVIDER_IMAGE_LIMITS.maxBase64Bytes,
|
|
202
|
+
};
|
|
203
|
+
const mutable = result as Record<keyof typeof result, number>;
|
|
76
204
|
for (const key of [
|
|
77
205
|
"maxImages",
|
|
78
206
|
"maxImageBytes",
|
|
@@ -84,7 +212,7 @@ function definedLimits(options: ProcessBrowserImageOptions): Partial<typeof DEFA
|
|
|
84
212
|
const value = options[key];
|
|
85
213
|
if (value === undefined) continue;
|
|
86
214
|
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`Invalid image limit: ${key}`);
|
|
87
|
-
|
|
215
|
+
mutable[key] = value;
|
|
88
216
|
}
|
|
89
217
|
return result;
|
|
90
218
|
}
|
|
@@ -93,7 +221,8 @@ function decodeInput(
|
|
|
93
221
|
input: BrowserImageInput,
|
|
94
222
|
index: number,
|
|
95
223
|
maxImageBytes: number,
|
|
96
|
-
|
|
224
|
+
requireFormat = true,
|
|
225
|
+
): Buffer {
|
|
97
226
|
if (!input || typeof input.data !== "string")
|
|
98
227
|
throw new Error(`Image ${index + 1} has invalid data.`);
|
|
99
228
|
if (!input.data) throw new Error(`Image ${index + 1} is empty.`);
|
|
@@ -105,117 +234,30 @@ function decodeInput(
|
|
|
105
234
|
const bytes = Buffer.from(input.data, "base64");
|
|
106
235
|
if (bytes.byteLength === 0) throw new Error(`Image ${index + 1} is empty.`);
|
|
107
236
|
if (bytes.byteLength > maxImageBytes) throw new Error(`Image ${index + 1} is too large.`);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return { bytes, format };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async function processOne(
|
|
114
|
-
bytes: Buffer,
|
|
115
|
-
format: SupportedFormat,
|
|
116
|
-
limits: typeof DEFAULT_IMAGE_LIMITS,
|
|
117
|
-
autoResize: boolean,
|
|
118
|
-
signal?: AbortSignal,
|
|
119
|
-
): Promise<Buffer> {
|
|
120
|
-
assertNotAborted(signal);
|
|
121
|
-
const metadata = await sharp(bytes, {
|
|
122
|
-
animated: format === "gif",
|
|
123
|
-
limitInputPixels: limits.maxPixels,
|
|
124
|
-
}).metadata();
|
|
125
|
-
assertNotAborted(signal);
|
|
126
|
-
const { width, height } = validatedDimensions(metadata, limits.maxPixels);
|
|
127
|
-
const oversized = width > limits.maxDimension || height > limits.maxDimension;
|
|
128
|
-
if (oversized && !autoResize) throw new Error("Image dimensions exceed Pi's limit.");
|
|
129
|
-
|
|
130
|
-
let targetWidth = oversized ? Math.min(width, limits.maxDimension) : width;
|
|
131
|
-
let targetHeight = oversized ? Math.min(height, limits.maxDimension) : height;
|
|
132
|
-
const ratio = Math.min(targetWidth / width, targetHeight / height, 1);
|
|
133
|
-
targetWidth = Math.max(1, Math.round(width * ratio));
|
|
134
|
-
targetHeight = Math.max(1, Math.round(height * ratio));
|
|
135
|
-
|
|
136
|
-
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
137
|
-
assertNotAborted(signal);
|
|
138
|
-
let pipeline = sharp(bytes, {
|
|
139
|
-
animated: format === "gif",
|
|
140
|
-
limitInputPixels: limits.maxPixels,
|
|
141
|
-
}).autoOrient();
|
|
142
|
-
if (targetWidth !== width || targetHeight !== height) {
|
|
143
|
-
pipeline = pipeline.resize({
|
|
144
|
-
width: targetWidth,
|
|
145
|
-
height: targetHeight,
|
|
146
|
-
fit: "inside",
|
|
147
|
-
withoutEnlargement: true,
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
pipeline = encode(pipeline, format);
|
|
151
|
-
const output = await pipeline.toBuffer();
|
|
152
|
-
assertNotAborted(signal);
|
|
153
|
-
if (output.toString("base64").length <= limits.maxBase64Bytes) return output;
|
|
154
|
-
if (!autoResize) throw new Error("Processed image exceeds Pi's inline limit.");
|
|
155
|
-
targetWidth = Math.max(1, Math.floor(targetWidth * 0.75));
|
|
156
|
-
targetHeight = Math.max(1, Math.floor(targetHeight * 0.75));
|
|
157
|
-
}
|
|
158
|
-
throw new Error("Processed image exceeds Pi's inline limit after resizing.");
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function validatedDimensions(
|
|
162
|
-
metadata: Metadata,
|
|
163
|
-
maxPixels: number,
|
|
164
|
-
): { width: number; height: number } {
|
|
165
|
-
const width = metadata.autoOrient.width ?? metadata.width;
|
|
166
|
-
const totalHeight = metadata.autoOrient.height ?? metadata.height;
|
|
167
|
-
if (!width || !totalHeight) throw new Error("Image dimensions could not be decoded.");
|
|
168
|
-
const pages = metadata.pages ?? 1;
|
|
169
|
-
const height = metadata.pageHeight ?? Math.floor(totalHeight / pages);
|
|
170
|
-
if (height <= 0 || width * height * pages > maxPixels) {
|
|
171
|
-
throw new Error("Image pixel count exceeds the limit.");
|
|
237
|
+
if (requireFormat && !detectImageFormat(bytes)) {
|
|
238
|
+
throw new Error(`Image ${index + 1} uses an unsupported format.`);
|
|
172
239
|
}
|
|
173
|
-
return
|
|
240
|
+
return bytes;
|
|
174
241
|
}
|
|
175
242
|
|
|
176
|
-
function
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function detectFormat(bytes: Uint8Array): SupportedFormat | undefined {
|
|
190
|
-
if (
|
|
191
|
-
bytes.length >= 8 &&
|
|
192
|
-
bytes[0] === 0x89 &&
|
|
193
|
-
bytes[1] === 0x50 &&
|
|
194
|
-
bytes[2] === 0x4e &&
|
|
195
|
-
bytes[3] === 0x47 &&
|
|
196
|
-
bytes[4] === 0x0d &&
|
|
197
|
-
bytes[5] === 0x0a &&
|
|
198
|
-
bytes[6] === 0x1a &&
|
|
199
|
-
bytes[7] === 0x0a
|
|
200
|
-
)
|
|
201
|
-
return "png";
|
|
202
|
-
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
|
|
203
|
-
return "jpeg";
|
|
204
|
-
if (
|
|
205
|
-
bytes.length >= 12 &&
|
|
206
|
-
Buffer.from(bytes.subarray(0, 4)).toString("ascii") === "RIFF" &&
|
|
207
|
-
Buffer.from(bytes.subarray(8, 12)).toString("ascii") === "WEBP"
|
|
208
|
-
)
|
|
209
|
-
return "webp";
|
|
210
|
-
if (bytes.length >= 6 && Buffer.from(bytes.subarray(0, 4)).toString("ascii") === "GIF8")
|
|
211
|
-
return "gif";
|
|
212
|
-
return undefined;
|
|
243
|
+
async function processProviderImage(
|
|
244
|
+
source: Uint8Array,
|
|
245
|
+
options: ProcessImageOptions,
|
|
246
|
+
): Promise<ImageContent> {
|
|
247
|
+
const processed = await processImage(source, options);
|
|
248
|
+
return {
|
|
249
|
+
type: "image",
|
|
250
|
+
data: processed.bytes.toString("base64"),
|
|
251
|
+
mimeType: processed.mimeType,
|
|
252
|
+
};
|
|
213
253
|
}
|
|
214
254
|
|
|
215
|
-
function
|
|
216
|
-
|
|
255
|
+
function assertNotAborted(signal?: AbortSignal): void {
|
|
256
|
+
if (signal?.aborted) throw abortError();
|
|
217
257
|
}
|
|
218
258
|
|
|
219
|
-
function
|
|
220
|
-
|
|
259
|
+
function abortError(): Error {
|
|
260
|
+
const error = new Error("Image processing aborted.");
|
|
261
|
+
error.name = "AbortError";
|
|
262
|
+
return error;
|
|
221
263
|
}
|
package/src/pi-settings.ts
CHANGED
|
@@ -43,15 +43,20 @@ async function readPatch(
|
|
|
43
43
|
}
|
|
44
44
|
try {
|
|
45
45
|
const parsed = JSON.parse(text) as unknown;
|
|
46
|
-
if (!isRecord(parsed)
|
|
46
|
+
if (!isRecord(parsed)) return undefined;
|
|
47
|
+
const images = parsed.images;
|
|
48
|
+
if (images === undefined) return undefined;
|
|
49
|
+
if (!isRecord(images)) {
|
|
50
|
+
warnings.push(`Ignored invalid Pi images settings in ${path}.`);
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
47
53
|
const patch: ImageSettingsPatch = {};
|
|
48
|
-
if (typeof
|
|
49
|
-
else if (
|
|
54
|
+
if (typeof images.autoResize === "boolean") patch.autoResize = images.autoResize;
|
|
55
|
+
else if (images.autoResize !== undefined) {
|
|
50
56
|
warnings.push(`Ignored non-boolean images.autoResize in ${path}.`);
|
|
51
57
|
}
|
|
52
|
-
if (typeof
|
|
53
|
-
|
|
54
|
-
else if (parsed.images.blockImages !== undefined) {
|
|
58
|
+
if (typeof images.blockImages === "boolean") patch.blockImages = images.blockImages;
|
|
59
|
+
else if (images.blockImages !== undefined) {
|
|
55
60
|
warnings.push(`Ignored non-boolean images.blockImages in ${path}.`);
|
|
56
61
|
}
|
|
57
62
|
return patch;
|