@firstpick/pi-package-webui 0.1.0 → 0.1.1
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 +121 -43
- package/bin/pi-webui.mjs +433 -40
- package/index.ts +150 -18
- package/package.json +1 -1
- package/public/app.js +556 -19
- package/public/favicon.svg +8 -0
- package/public/index.html +28 -0
- package/public/styles.css +317 -0
package/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { spawn, type
|
|
1
|
+
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import type { Readable } from "node:stream";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
|
|
@@ -20,6 +21,14 @@ type StartWebuiOptions = {
|
|
|
20
21
|
piArgs: string[];
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
type ExistingWebui = {
|
|
25
|
+
webuiVersion?: string;
|
|
26
|
+
webuiPid?: number;
|
|
27
|
+
piPid?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type WebuiChild = ChildProcessByStdio<null, Readable, Readable>;
|
|
31
|
+
|
|
23
32
|
function tokenizeArgs(input: string): string[] {
|
|
24
33
|
const tokens: string[] = [];
|
|
25
34
|
let current = "";
|
|
@@ -125,20 +134,145 @@ function urlFor(options: StartWebuiOptions): string {
|
|
|
125
134
|
return `http://${host}:${options.port}/`;
|
|
126
135
|
}
|
|
127
136
|
|
|
128
|
-
async function
|
|
137
|
+
async function fetchJsonWithTimeout(url: string, init: RequestInit = {}, timeoutMs = 900): Promise<{ ok: boolean; status: number; body: any } | null> {
|
|
129
138
|
const controller = new AbortController();
|
|
130
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
139
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
131
140
|
try {
|
|
132
|
-
const response = await fetch(
|
|
141
|
+
const response = await fetch(url, { ...init, signal: controller.signal });
|
|
133
142
|
const body = await response.json().catch(() => undefined);
|
|
134
|
-
return response.ok
|
|
143
|
+
return { ok: response.ok, status: response.status, body };
|
|
135
144
|
} catch {
|
|
136
|
-
return
|
|
145
|
+
return null;
|
|
137
146
|
} finally {
|
|
138
147
|
clearTimeout(timeout);
|
|
139
148
|
}
|
|
140
149
|
}
|
|
141
150
|
|
|
151
|
+
async function probeExistingWebui(url: string): Promise<ExistingWebui | null> {
|
|
152
|
+
const result = await fetchJsonWithTimeout(`${url.replace(/\/$/, "")}/api/health`);
|
|
153
|
+
const body = result?.body;
|
|
154
|
+
if (!result?.ok || body?.ok !== true || typeof body.webuiVersion !== "string") return null;
|
|
155
|
+
return body;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function sleep(ms: number): Promise<void> {
|
|
159
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function waitForWebuiToStop(url: string, timeoutMs = 7_000): Promise<boolean> {
|
|
163
|
+
const deadline = Date.now() + timeoutMs;
|
|
164
|
+
while (Date.now() < deadline) {
|
|
165
|
+
if (!(await probeExistingWebui(url))) return true;
|
|
166
|
+
await sleep(180);
|
|
167
|
+
}
|
|
168
|
+
return !(await probeExistingWebui(url));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function requestWebuiShutdown(url: string): Promise<boolean> {
|
|
172
|
+
const result = await fetchJsonWithTimeout(`${url.replace(/\/$/, "")}/api/shutdown`, { method: "POST" }, 1_500);
|
|
173
|
+
return result?.ok === true && result.body?.ok === true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isProcessRunning(pid: number): boolean {
|
|
177
|
+
try {
|
|
178
|
+
process.kill(pid, 0);
|
|
179
|
+
return true;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return (error as NodeJS.ErrnoException)?.code === "EPERM";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function terminatePid(pid: number): Promise<void> {
|
|
186
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid || !isProcessRunning(pid)) return;
|
|
187
|
+
try {
|
|
188
|
+
process.kill(pid, "SIGTERM");
|
|
189
|
+
} catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const deadline = Date.now() + 4_000;
|
|
194
|
+
while (Date.now() < deadline) {
|
|
195
|
+
if (!isProcessRunning(pid)) return;
|
|
196
|
+
await sleep(160);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
if (isProcessRunning(pid)) process.kill(pid, "SIGKILL");
|
|
201
|
+
} catch {
|
|
202
|
+
// Ignore kill races; the restart path verifies the port separately.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function runCommand(command: string, args: string[], timeoutMs = 1_500): Promise<{ exitCode?: number; stdout: string; stderr: string }> {
|
|
207
|
+
return new Promise((resolve) => {
|
|
208
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
209
|
+
let stdout = "";
|
|
210
|
+
let stderr = "";
|
|
211
|
+
let settled = false;
|
|
212
|
+
const finish = (result: { exitCode?: number; stdout: string; stderr: string }) => {
|
|
213
|
+
if (settled) return;
|
|
214
|
+
settled = true;
|
|
215
|
+
clearTimeout(timeout);
|
|
216
|
+
resolve(result);
|
|
217
|
+
};
|
|
218
|
+
const timeout = setTimeout(() => {
|
|
219
|
+
child.kill("SIGKILL");
|
|
220
|
+
finish({ stdout, stderr });
|
|
221
|
+
}, timeoutMs);
|
|
222
|
+
child.stdout.on("data", (chunk) => {
|
|
223
|
+
stdout += String(chunk);
|
|
224
|
+
if (stdout.length > 100_000) stdout = stdout.slice(-100_000);
|
|
225
|
+
});
|
|
226
|
+
child.stderr.on("data", (chunk) => {
|
|
227
|
+
stderr += String(chunk);
|
|
228
|
+
if (stderr.length > 20_000) stderr = stderr.slice(-20_000);
|
|
229
|
+
});
|
|
230
|
+
child.on("error", (error) => finish({ stdout, stderr: stderr || (error instanceof Error ? error.message : String(error)) }));
|
|
231
|
+
child.on("exit", (exitCode) => finish({ exitCode: exitCode ?? undefined, stdout, stderr }));
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function commandLooksLikeWebui(command: string, options: StartWebuiOptions): boolean {
|
|
236
|
+
if (!command.includes("pi-webui.mjs")) return false;
|
|
237
|
+
const escapedPort = String(options.port).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
238
|
+
return new RegExp(`(?:^|\\s)--port\\s+${escapedPort}(?:\\s|$)`).test(command);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function findWebuiPidsByCommand(options: StartWebuiOptions): Promise<number[]> {
|
|
242
|
+
if (process.platform === "win32") return [];
|
|
243
|
+
let result = await runCommand("ps", ["-Ao", "pid=,args="], 1_500);
|
|
244
|
+
if (result.exitCode !== 0) result = await runCommand("ps", ["-eo", "pid=,args="], 1_500);
|
|
245
|
+
if (result.exitCode !== 0) return [];
|
|
246
|
+
|
|
247
|
+
const pids: number[] = [];
|
|
248
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
249
|
+
const match = line.match(/^\s*(\d+)\s+(.+)$/);
|
|
250
|
+
if (!match) continue;
|
|
251
|
+
const pid = Number.parseInt(match[1], 10);
|
|
252
|
+
const command = match[2];
|
|
253
|
+
if (pid !== process.pid && commandLooksLikeWebui(command, options)) pids.push(pid);
|
|
254
|
+
}
|
|
255
|
+
return [...new Set(pids)];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function stopExistingWebui(url: string, options: StartWebuiOptions, existing: ExistingWebui): Promise<void> {
|
|
259
|
+
if (await requestWebuiShutdown(url)) {
|
|
260
|
+
if (await waitForWebuiToStop(url)) return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (Number.isInteger(existing.webuiPid)) {
|
|
264
|
+
await terminatePid(existing.webuiPid!);
|
|
265
|
+
if (await waitForWebuiToStop(url)) return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const pid of await findWebuiPidsByCommand(options)) {
|
|
269
|
+
await terminatePid(pid);
|
|
270
|
+
}
|
|
271
|
+
if (await waitForWebuiToStop(url)) return;
|
|
272
|
+
|
|
273
|
+
throw new Error(`Existing Pi Web UI is still running at ${url}. Stop it manually and retry.`);
|
|
274
|
+
}
|
|
275
|
+
|
|
142
276
|
function openDefaultBrowser(url: string): void {
|
|
143
277
|
let command: string;
|
|
144
278
|
let args: string[];
|
|
@@ -158,15 +292,15 @@ function openDefaultBrowser(url: string): void {
|
|
|
158
292
|
child.unref();
|
|
159
293
|
}
|
|
160
294
|
|
|
161
|
-
function releaseStartedChild(child:
|
|
295
|
+
function releaseStartedChild(child: WebuiChild): void {
|
|
162
296
|
child.stdout.removeAllListeners("data");
|
|
163
297
|
child.stderr.removeAllListeners("data");
|
|
164
|
-
child.stdout.unref?.();
|
|
165
|
-
child.stderr.unref?.();
|
|
298
|
+
(child.stdout as Readable & { unref?: () => void }).unref?.();
|
|
299
|
+
(child.stderr as Readable & { unref?: () => void }).unref?.();
|
|
166
300
|
child.unref();
|
|
167
301
|
}
|
|
168
302
|
|
|
169
|
-
function terminateFailedChild(child:
|
|
303
|
+
function terminateFailedChild(child: WebuiChild): void {
|
|
170
304
|
if (child.exitCode === null) child.kill("SIGTERM");
|
|
171
305
|
setTimeout(() => {
|
|
172
306
|
if (child.exitCode === null) child.kill("SIGKILL");
|
|
@@ -175,7 +309,7 @@ function terminateFailedChild(child: ChildProcessWithoutNullStreams): void {
|
|
|
175
309
|
child.stderr.destroy();
|
|
176
310
|
}
|
|
177
311
|
|
|
178
|
-
function waitForWebuiUrl(child:
|
|
312
|
+
function waitForWebuiUrl(child: WebuiChild): Promise<string> {
|
|
179
313
|
return new Promise((resolve, reject) => {
|
|
180
314
|
let settled = false;
|
|
181
315
|
let output = "";
|
|
@@ -249,17 +383,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
249
383
|
const url = urlFor(options);
|
|
250
384
|
ctx.ui.setStatus("pi-webui", "starting webui…");
|
|
251
385
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
ctx.ui.
|
|
255
|
-
|
|
256
|
-
setTimeout(() => ctx.ui.setStatus("pi-webui", ""), 20_000).unref?.();
|
|
257
|
-
return;
|
|
386
|
+
const existing = await probeExistingWebui(url);
|
|
387
|
+
if (existing) {
|
|
388
|
+
ctx.ui.setStatus("pi-webui", "restarting existing webui…");
|
|
389
|
+
await stopExistingWebui(url, options, existing);
|
|
258
390
|
}
|
|
259
391
|
|
|
260
392
|
const startedUrl = await startWebui(options, ctx);
|
|
261
393
|
if (options.open) openDefaultBrowser(startedUrl);
|
|
262
|
-
ctx.ui.notify(
|
|
394
|
+
ctx.ui.notify(`${existing ? "Pi Web UI restarted" : "Pi Web UI started"}:\n${startedUrl}`, "info");
|
|
263
395
|
ctx.ui.setStatus("pi-webui", startedUrl);
|
|
264
396
|
setTimeout(() => ctx.ui.setStatus("pi-webui", ""), 20_000).unref?.();
|
|
265
397
|
} catch (error) {
|