@firstpick/pi-package-webui 0.1.0 → 0.1.2
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 +142 -44
- package/bin/pi-webui.mjs +878 -43
- package/index.ts +454 -22
- package/package.json +7 -2
- package/public/app.js +1185 -44
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.svg +8 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/index.html +74 -20
- package/public/manifest.webmanifest +40 -0
- package/public/service-worker.js +46 -0
- package/public/styles.css +1014 -19
- package/tests/mobile-static.test.mjs +170 -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
|
|
|
@@ -11,15 +12,32 @@ const DEFAULT_HOST = "127.0.0.1";
|
|
|
11
12
|
const DEFAULT_PORT = 31415;
|
|
12
13
|
const START_TIMEOUT_MS = 12_000;
|
|
13
14
|
|
|
14
|
-
type
|
|
15
|
+
type WebuiAddress = {
|
|
15
16
|
host: string;
|
|
16
17
|
port: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type StartWebuiOptions = WebuiAddress & {
|
|
17
21
|
open: boolean;
|
|
18
22
|
noSession: boolean;
|
|
19
23
|
name?: string;
|
|
20
24
|
piArgs: string[];
|
|
21
25
|
};
|
|
22
26
|
|
|
27
|
+
type WebuiStatusOptions = WebuiAddress & {
|
|
28
|
+
detailed: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type ExistingWebui = {
|
|
32
|
+
webuiVersion?: string;
|
|
33
|
+
webuiPid?: number;
|
|
34
|
+
piPid?: number;
|
|
35
|
+
network?: any;
|
|
36
|
+
tabs?: any[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type WebuiChild = ChildProcessByStdio<null, Readable, Readable>;
|
|
40
|
+
|
|
23
41
|
function tokenizeArgs(input: string): string[] {
|
|
24
42
|
const tokens: string[] = [];
|
|
25
43
|
let current = "";
|
|
@@ -120,25 +138,188 @@ function parseStartWebuiArgs(args: string): StartWebuiOptions {
|
|
|
120
138
|
return options;
|
|
121
139
|
}
|
|
122
140
|
|
|
123
|
-
function
|
|
141
|
+
function parseWebuiStatusArgs(args: string): WebuiStatusOptions {
|
|
142
|
+
const options: WebuiStatusOptions = {
|
|
143
|
+
host: DEFAULT_HOST,
|
|
144
|
+
port: DEFAULT_PORT,
|
|
145
|
+
detailed: false,
|
|
146
|
+
};
|
|
147
|
+
const tokens = tokenizeArgs(args || "");
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
150
|
+
const token = tokens[i];
|
|
151
|
+
if (["detailed", "detail", "details", "--detailed"].includes(token.toLowerCase())) {
|
|
152
|
+
options.detailed = true;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (token === "--host") {
|
|
156
|
+
options.host = takeValue(tokens, i, token);
|
|
157
|
+
i++;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (token === "--port") {
|
|
161
|
+
const port = Number.parseInt(takeValue(tokens, i, token), 10);
|
|
162
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) throw new Error("--port must be between 1 and 65535");
|
|
163
|
+
options.port = port;
|
|
164
|
+
i++;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (/^\d+$/.test(token)) {
|
|
168
|
+
const port = Number.parseInt(token, 10);
|
|
169
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) throw new Error("port must be between 1 and 65535");
|
|
170
|
+
options.port = port;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
throw new Error(`Unknown option: ${token}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return options;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function urlFor(options: WebuiAddress): string {
|
|
124
180
|
const host = options.host.includes(":") && !options.host.startsWith("[") ? `[${options.host}]` : options.host;
|
|
125
181
|
return `http://${host}:${options.port}/`;
|
|
126
182
|
}
|
|
127
183
|
|
|
128
|
-
async function
|
|
184
|
+
async function fetchJsonWithTimeout(url: string, init: RequestInit = {}, timeoutMs = 900): Promise<{ ok: boolean; status: number; body: any } | null> {
|
|
129
185
|
const controller = new AbortController();
|
|
130
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
186
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
131
187
|
try {
|
|
132
|
-
const response = await fetch(
|
|
188
|
+
const response = await fetch(url, { ...init, signal: controller.signal });
|
|
133
189
|
const body = await response.json().catch(() => undefined);
|
|
134
|
-
return response.ok
|
|
190
|
+
return { ok: response.ok, status: response.status, body };
|
|
135
191
|
} catch {
|
|
136
|
-
return
|
|
192
|
+
return null;
|
|
137
193
|
} finally {
|
|
138
194
|
clearTimeout(timeout);
|
|
139
195
|
}
|
|
140
196
|
}
|
|
141
197
|
|
|
198
|
+
async function probeExistingWebui(url: string): Promise<ExistingWebui | null> {
|
|
199
|
+
const result = await fetchJsonWithTimeout(`${url.replace(/\/$/, "")}/api/health`);
|
|
200
|
+
const body = result?.body;
|
|
201
|
+
if (!result?.ok || body?.ok !== true || typeof body.webuiVersion !== "string") return null;
|
|
202
|
+
return body;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sleep(ms: number): Promise<void> {
|
|
206
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function waitForWebuiToStop(url: string, timeoutMs = 7_000): Promise<boolean> {
|
|
210
|
+
const deadline = Date.now() + timeoutMs;
|
|
211
|
+
while (Date.now() < deadline) {
|
|
212
|
+
if (!(await probeExistingWebui(url))) return true;
|
|
213
|
+
await sleep(180);
|
|
214
|
+
}
|
|
215
|
+
return !(await probeExistingWebui(url));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function requestWebuiShutdown(url: string): Promise<boolean> {
|
|
219
|
+
const result = await fetchJsonWithTimeout(`${url.replace(/\/$/, "")}/api/shutdown`, { method: "POST" }, 1_500);
|
|
220
|
+
return result?.ok === true && result.body?.ok === true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isProcessRunning(pid: number): boolean {
|
|
224
|
+
try {
|
|
225
|
+
process.kill(pid, 0);
|
|
226
|
+
return true;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return (error as NodeJS.ErrnoException)?.code === "EPERM";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function terminatePid(pid: number): Promise<void> {
|
|
233
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid || !isProcessRunning(pid)) return;
|
|
234
|
+
try {
|
|
235
|
+
process.kill(pid, "SIGTERM");
|
|
236
|
+
} catch {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const deadline = Date.now() + 4_000;
|
|
241
|
+
while (Date.now() < deadline) {
|
|
242
|
+
if (!isProcessRunning(pid)) return;
|
|
243
|
+
await sleep(160);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
if (isProcessRunning(pid)) process.kill(pid, "SIGKILL");
|
|
248
|
+
} catch {
|
|
249
|
+
// Ignore kill races; the restart path verifies the port separately.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function runCommand(command: string, args: string[], timeoutMs = 1_500): Promise<{ exitCode?: number; stdout: string; stderr: string }> {
|
|
254
|
+
return new Promise((resolve) => {
|
|
255
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
256
|
+
let stdout = "";
|
|
257
|
+
let stderr = "";
|
|
258
|
+
let settled = false;
|
|
259
|
+
const finish = (result: { exitCode?: number; stdout: string; stderr: string }) => {
|
|
260
|
+
if (settled) return;
|
|
261
|
+
settled = true;
|
|
262
|
+
clearTimeout(timeout);
|
|
263
|
+
resolve(result);
|
|
264
|
+
};
|
|
265
|
+
const timeout = setTimeout(() => {
|
|
266
|
+
child.kill("SIGKILL");
|
|
267
|
+
finish({ stdout, stderr });
|
|
268
|
+
}, timeoutMs);
|
|
269
|
+
child.stdout.on("data", (chunk) => {
|
|
270
|
+
stdout += String(chunk);
|
|
271
|
+
if (stdout.length > 100_000) stdout = stdout.slice(-100_000);
|
|
272
|
+
});
|
|
273
|
+
child.stderr.on("data", (chunk) => {
|
|
274
|
+
stderr += String(chunk);
|
|
275
|
+
if (stderr.length > 20_000) stderr = stderr.slice(-20_000);
|
|
276
|
+
});
|
|
277
|
+
child.on("error", (error) => finish({ stdout, stderr: stderr || (error instanceof Error ? error.message : String(error)) }));
|
|
278
|
+
child.on("exit", (exitCode) => finish({ exitCode: exitCode ?? undefined, stdout, stderr }));
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function commandLooksLikeWebui(command: string, options: StartWebuiOptions): boolean {
|
|
283
|
+
if (!command.includes("pi-webui.mjs")) return false;
|
|
284
|
+
const escapedPort = String(options.port).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
285
|
+
return new RegExp(`(?:^|\\s)--port\\s+${escapedPort}(?:\\s|$)`).test(command);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function findWebuiPidsByCommand(options: StartWebuiOptions): Promise<number[]> {
|
|
289
|
+
if (process.platform === "win32") return [];
|
|
290
|
+
let result = await runCommand("ps", ["-Ao", "pid=,args="], 1_500);
|
|
291
|
+
if (result.exitCode !== 0) result = await runCommand("ps", ["-eo", "pid=,args="], 1_500);
|
|
292
|
+
if (result.exitCode !== 0) return [];
|
|
293
|
+
|
|
294
|
+
const pids: number[] = [];
|
|
295
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
296
|
+
const match = line.match(/^\s*(\d+)\s+(.+)$/);
|
|
297
|
+
if (!match) continue;
|
|
298
|
+
const pid = Number.parseInt(match[1], 10);
|
|
299
|
+
const command = match[2];
|
|
300
|
+
if (pid !== process.pid && commandLooksLikeWebui(command, options)) pids.push(pid);
|
|
301
|
+
}
|
|
302
|
+
return [...new Set(pids)];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function stopExistingWebui(url: string, options: StartWebuiOptions, existing: ExistingWebui): Promise<void> {
|
|
306
|
+
if (await requestWebuiShutdown(url)) {
|
|
307
|
+
if (await waitForWebuiToStop(url)) return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (Number.isInteger(existing.webuiPid)) {
|
|
311
|
+
await terminatePid(existing.webuiPid!);
|
|
312
|
+
if (await waitForWebuiToStop(url)) return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const pid of await findWebuiPidsByCommand(options)) {
|
|
316
|
+
await terminatePid(pid);
|
|
317
|
+
}
|
|
318
|
+
if (await waitForWebuiToStop(url)) return;
|
|
319
|
+
|
|
320
|
+
throw new Error(`Existing Pi Web UI is still running at ${url}. Stop it manually and retry.`);
|
|
321
|
+
}
|
|
322
|
+
|
|
142
323
|
function openDefaultBrowser(url: string): void {
|
|
143
324
|
let command: string;
|
|
144
325
|
let args: string[];
|
|
@@ -158,15 +339,15 @@ function openDefaultBrowser(url: string): void {
|
|
|
158
339
|
child.unref();
|
|
159
340
|
}
|
|
160
341
|
|
|
161
|
-
function releaseStartedChild(child:
|
|
342
|
+
function releaseStartedChild(child: WebuiChild): void {
|
|
162
343
|
child.stdout.removeAllListeners("data");
|
|
163
344
|
child.stderr.removeAllListeners("data");
|
|
164
|
-
child.stdout.unref?.();
|
|
165
|
-
child.stderr.unref?.();
|
|
345
|
+
(child.stdout as Readable & { unref?: () => void }).unref?.();
|
|
346
|
+
(child.stderr as Readable & { unref?: () => void }).unref?.();
|
|
166
347
|
child.unref();
|
|
167
348
|
}
|
|
168
349
|
|
|
169
|
-
function terminateFailedChild(child:
|
|
350
|
+
function terminateFailedChild(child: WebuiChild): void {
|
|
170
351
|
if (child.exitCode === null) child.kill("SIGTERM");
|
|
171
352
|
setTimeout(() => {
|
|
172
353
|
if (child.exitCode === null) child.kill("SIGKILL");
|
|
@@ -175,7 +356,7 @@ function terminateFailedChild(child: ChildProcessWithoutNullStreams): void {
|
|
|
175
356
|
child.stderr.destroy();
|
|
176
357
|
}
|
|
177
358
|
|
|
178
|
-
function waitForWebuiUrl(child:
|
|
359
|
+
function waitForWebuiUrl(child: WebuiChild): Promise<string> {
|
|
179
360
|
return new Promise((resolve, reject) => {
|
|
180
361
|
let settled = false;
|
|
181
362
|
let output = "";
|
|
@@ -227,15 +408,245 @@ async function startWebui(options: StartWebuiOptions, ctx: ExtensionCommandConte
|
|
|
227
408
|
return waitForWebuiUrl(child);
|
|
228
409
|
}
|
|
229
410
|
|
|
411
|
+
type WebuiStatusFetchResult = {
|
|
412
|
+
online: boolean;
|
|
413
|
+
url: string;
|
|
414
|
+
endpointSupported: boolean;
|
|
415
|
+
data?: any;
|
|
416
|
+
error?: string;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
async function fetchWebuiStatus(options: WebuiStatusOptions): Promise<WebuiStatusFetchResult> {
|
|
420
|
+
const url = urlFor(options);
|
|
421
|
+
const baseUrl = url.replace(/\/$/, "");
|
|
422
|
+
const query = options.detailed ? "?detailed=1&events=40" : "";
|
|
423
|
+
const statusResult = await fetchJsonWithTimeout(`${baseUrl}/api/webui-status${query}`, {}, options.detailed ? 7_000 : 1_500);
|
|
424
|
+
if (statusResult?.ok && statusResult.body?.ok === true) {
|
|
425
|
+
return { online: true, url, endpointSupported: true, data: statusResult.body.data };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const healthResult = await fetchJsonWithTimeout(`${baseUrl}/api/health`, {}, 1_500);
|
|
429
|
+
if (healthResult?.ok && healthResult.body?.ok === true) {
|
|
430
|
+
return {
|
|
431
|
+
online: true,
|
|
432
|
+
url,
|
|
433
|
+
endpointSupported: false,
|
|
434
|
+
data: {
|
|
435
|
+
...healthResult.body,
|
|
436
|
+
online: true,
|
|
437
|
+
pageUrl: healthResult.body.network?.localUrl || url,
|
|
438
|
+
port: options.port,
|
|
439
|
+
},
|
|
440
|
+
error: statusResult?.body?.error,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
online: false,
|
|
446
|
+
url,
|
|
447
|
+
endpointSupported: false,
|
|
448
|
+
error: statusResult?.body?.error || healthResult?.body?.error || "No Pi Web UI responded at this URL",
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function yesNo(value: unknown): string {
|
|
453
|
+
return value ? "yes" : "no";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function modelLabel(model: any): string {
|
|
457
|
+
if (!model) return "unknown";
|
|
458
|
+
return [model.provider, model.id].filter(Boolean).join("/") || "unknown";
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function sessionLabel(state: any): string {
|
|
462
|
+
return state?.sessionName || state?.sessionId || "unknown";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function displayPath(value: unknown): string {
|
|
466
|
+
const text = String(value || "").trim();
|
|
467
|
+
if (!text) return "unknown";
|
|
468
|
+
const normalized = text.replace(/\\/g, "/");
|
|
469
|
+
const home = (process.env.USERPROFILE || process.env.HOME || "").replace(/\\/g, "/");
|
|
470
|
+
return home && normalized.toLowerCase().startsWith(home.toLowerCase()) ? `~${normalized.slice(home.length)}` || "~" : normalized;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function compactSessionFile(value: unknown): string {
|
|
474
|
+
const shown = displayPath(value);
|
|
475
|
+
if (shown === "unknown") return "in-memory/unknown";
|
|
476
|
+
const parts = shown.split("/");
|
|
477
|
+
if (parts.length <= 4) return shown;
|
|
478
|
+
return `${parts.slice(0, 3).join("/")}/…/${parts.at(-1)}`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function formatStatusTime(value: unknown): string {
|
|
482
|
+
const date = new Date(String(value || ""));
|
|
483
|
+
if (Number.isNaN(date.getTime())) return String(value || "unknown");
|
|
484
|
+
return date.toLocaleString();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function formatEventTime(value: unknown): string {
|
|
488
|
+
const date = new Date(String(value || ""));
|
|
489
|
+
if (Number.isNaN(date.getTime())) return String(value || "time?");
|
|
490
|
+
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function detailLine(label: string, value: unknown, indent = " "): string {
|
|
494
|
+
return `${indent}${label.padEnd(10)} ${String(value ?? "unknown")}`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function formatStats(stats: any): string {
|
|
498
|
+
if (!stats || typeof stats !== "object") return "unavailable";
|
|
499
|
+
const parts = [];
|
|
500
|
+
if (stats.userMessages !== undefined) parts.push(`${stats.userMessages} user`);
|
|
501
|
+
if (stats.assistantMessages !== undefined) parts.push(`${stats.assistantMessages} assistant`);
|
|
502
|
+
if (stats.toolCalls !== undefined) parts.push(`${stats.toolCalls} tool calls`);
|
|
503
|
+
if (stats.toolResults !== undefined) parts.push(`${stats.toolResults} tool results`);
|
|
504
|
+
if (stats.totalTokens !== undefined) parts.push(`${stats.totalTokens} tokens`);
|
|
505
|
+
if (stats.costUsd !== undefined) parts.push(`$${stats.costUsd}`);
|
|
506
|
+
return parts.length ? parts.join(" · ") : "available";
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function formatProviders(models: any): string {
|
|
510
|
+
const providers = Array.isArray(models?.providers) ? models.providers : [];
|
|
511
|
+
const providerText = providers.length ? providers.join(", ") : "unknown";
|
|
512
|
+
return models?.count ? `${models.count} models · ${providerText}` : providerText;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function eventDetails(event: any): string[] {
|
|
516
|
+
const details = [];
|
|
517
|
+
if (event.command) details.push(event.command);
|
|
518
|
+
if (event.updateType) details.push(`update ${event.updateType}`);
|
|
519
|
+
if (event.pid) details.push(`pid ${event.pid}`);
|
|
520
|
+
if (event.code !== undefined || event.signal !== undefined) details.push(`exit ${event.code ?? event.signal}`);
|
|
521
|
+
if (event.error) details.push(`error: ${event.error}`);
|
|
522
|
+
if (event.text) details.push(event.text);
|
|
523
|
+
return details;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function eventGroupKey(event: any): string {
|
|
527
|
+
return JSON.stringify([event.tabTitle || "webui", event.type || "event", ...eventDetails(event)]);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function formatEvent(event: any, count = 1): string {
|
|
531
|
+
const details = eventDetails(event);
|
|
532
|
+
const repeat = count > 1 ? ` ×${count}` : "";
|
|
533
|
+
return ` ${formatEventTime(event.timestamp)} ${event.tabTitle || "webui"} · ${event.type || "event"}${repeat}${details.length ? ` · ${details.join(" · ")}` : ""}`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function formatEventGroups(events: any[]): string[] {
|
|
537
|
+
const groups: { event: any; count: number }[] = [];
|
|
538
|
+
for (const event of events) {
|
|
539
|
+
const previous = groups.at(-1);
|
|
540
|
+
if (previous && eventGroupKey(previous.event) === eventGroupKey(event)) {
|
|
541
|
+
previous.count += 1;
|
|
542
|
+
} else {
|
|
543
|
+
groups.push({ event, count: 1 });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return groups.map((group) => formatEvent(group.event, group.count));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function formatWebuiStatus(result: WebuiStatusFetchResult, requestedDetailed: boolean): string {
|
|
550
|
+
if (!result.online) {
|
|
551
|
+
return [
|
|
552
|
+
"Pi Web UI status",
|
|
553
|
+
detailLine("URL", result.url),
|
|
554
|
+
detailLine("Online", "no"),
|
|
555
|
+
detailLine("Network", "unknown"),
|
|
556
|
+
detailLine("Error", result.error || "offline"),
|
|
557
|
+
"",
|
|
558
|
+
"Start it with: /webui-start",
|
|
559
|
+
].join("\n");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const data = result.data || {};
|
|
563
|
+
const network = data.network || {};
|
|
564
|
+
const tabs = Array.isArray(data.tabs) ? data.tabs : [];
|
|
565
|
+
const networkUrls = Array.isArray(network.networkUrls) ? network.networkUrls : [];
|
|
566
|
+
const pageUrl = data.pageUrl || network.localUrl || result.url;
|
|
567
|
+
const networkLabel = network.open ? `open to LAN${network.opening ? " (opening)" : ""}` : network.opening ? "opening" : "local only";
|
|
568
|
+
|
|
569
|
+
if (!requestedDetailed) {
|
|
570
|
+
const lines = [
|
|
571
|
+
"Pi Web UI status",
|
|
572
|
+
"",
|
|
573
|
+
detailLine("URL", pageUrl),
|
|
574
|
+
detailLine("Online", "yes"),
|
|
575
|
+
detailLine("Network", networkLabel),
|
|
576
|
+
detailLine("Tabs", tabs.length || "?"),
|
|
577
|
+
];
|
|
578
|
+
if (networkUrls.length) lines.push(detailLine("LAN URLs", networkUrls.join(", ")));
|
|
579
|
+
if (data.webuiPid) lines.push(detailLine("Web UI PID", data.webuiPid));
|
|
580
|
+
return lines.join("\n");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const lines = [
|
|
584
|
+
"Pi Web UI — detailed status",
|
|
585
|
+
"",
|
|
586
|
+
"Summary",
|
|
587
|
+
detailLine("URL", pageUrl),
|
|
588
|
+
detailLine("Online", "yes"),
|
|
589
|
+
detailLine("Network", networkLabel),
|
|
590
|
+
detailLine("Bind", `${data.boundHost || network.host || "unknown"}:${data.port || network.port || "?"}`),
|
|
591
|
+
detailLine("Version", data.webuiVersion || "unknown"),
|
|
592
|
+
detailLine("PIDs", `webui ${data.webuiPid || "unknown"} · pi ${data.piPid || "unknown"}`),
|
|
593
|
+
detailLine("Started", formatStatusTime(data.startedAt)),
|
|
594
|
+
detailLine("Root cwd", displayPath(data.cwd)),
|
|
595
|
+
];
|
|
596
|
+
|
|
597
|
+
if (networkUrls.length) lines.push(detailLine("LAN URLs", networkUrls.join(", ")));
|
|
598
|
+
|
|
599
|
+
if (!result.endpointSupported) {
|
|
600
|
+
lines.push("", "Detailed endpoint unavailable on the running server. Restart it with /webui-start to enable full details.");
|
|
601
|
+
return lines.join("\n");
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
lines.push("", `Tabs (${tabs.length})`);
|
|
605
|
+
if (!tabs.length) lines.push(" none");
|
|
606
|
+
for (const [index, tab] of tabs.entries()) {
|
|
607
|
+
const state = tab.state || {};
|
|
608
|
+
const status = tab.running ? "● running" : "○ stopped";
|
|
609
|
+
const activity = state.isStreaming ? "streaming" : state.isCompacting ? "compacting" : "idle";
|
|
610
|
+
lines.push(
|
|
611
|
+
"",
|
|
612
|
+
` ${index + 1}. ${tab.title || tab.id || "tab"} ${status}`,
|
|
613
|
+
detailLine("Process", `pid ${tab.pid || "unknown"} · clients ${tab.clientCount ?? 0} · started ${formatStatusTime(tab.startedAt)}`, " "),
|
|
614
|
+
detailLine("Workspace", displayPath(tab.workspace?.cwd || tab.cwd), " "),
|
|
615
|
+
detailLine("Session", sessionLabel(state), " "),
|
|
616
|
+
detailLine("File", compactSessionFile(state.sessionFile), " "),
|
|
617
|
+
detailLine("Model", `${modelLabel(state.model)} · thinking ${state.thinkingLevel || "unknown"}`, " "),
|
|
618
|
+
detailLine("Activity", `${activity} · messages ${state.messageCount ?? "?"} · queue ${state.pendingMessageCount ?? 0}`, " "),
|
|
619
|
+
detailLine("Providers", formatProviders(tab.models), " "),
|
|
620
|
+
detailLine("Stats", formatStats(tab.stats), " "),
|
|
621
|
+
);
|
|
622
|
+
if (tab.stateError) lines.push(detailLine("State err", tab.stateError, " "));
|
|
623
|
+
if (tab.models?.error) lines.push(detailLine("Model err", tab.models.error, " "));
|
|
624
|
+
if (tab.statsError) lines.push(detailLine("Stats err", tab.statsError, " "));
|
|
625
|
+
if (tab.workspaceError) lines.push(detailLine("Work err", tab.workspaceError, " "));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const events = Array.isArray(data.events) ? data.events.slice(-20) : [];
|
|
629
|
+
lines.push("", `Recent events (latest ${events.length}; repeated adjacent events are grouped)`);
|
|
630
|
+
lines.push(...(events.length ? formatEventGroups(events) : [" none"]));
|
|
631
|
+
return lines.join("\n");
|
|
632
|
+
}
|
|
633
|
+
|
|
230
634
|
function usage(): string {
|
|
231
635
|
return [
|
|
232
|
-
"Usage: /start
|
|
636
|
+
"Usage: /webui-start [port] [--port N] [--no-open] [--no-session] [--name NAME] [-- --model provider/model]",
|
|
233
637
|
"Starts the Pi Web UI companion server for the current cwd, prints the localhost URL, and opens it in your default browser.",
|
|
234
638
|
].join("\n");
|
|
235
639
|
}
|
|
236
640
|
|
|
641
|
+
function statusUsage(): string {
|
|
642
|
+
return [
|
|
643
|
+
"Usage: /webui-status [detailed] [port] [--port N] [--host HOST]",
|
|
644
|
+
"Shows the Pi Web UI URL, online state, and local-network exposure. Add 'detailed' for tabs, sessions, models/providers, and recent events.",
|
|
645
|
+
].join("\n");
|
|
646
|
+
}
|
|
647
|
+
|
|
237
648
|
export default function (pi: ExtensionAPI) {
|
|
238
|
-
pi.registerCommand("start
|
|
649
|
+
pi.registerCommand("webui-start", {
|
|
239
650
|
description: "Start the local Pi browser Web UI and open it",
|
|
240
651
|
handler: async (args, ctx) => {
|
|
241
652
|
let options: StartWebuiOptions;
|
|
@@ -249,17 +660,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
249
660
|
const url = urlFor(options);
|
|
250
661
|
ctx.ui.setStatus("pi-webui", "starting webui…");
|
|
251
662
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
ctx.ui.
|
|
255
|
-
|
|
256
|
-
setTimeout(() => ctx.ui.setStatus("pi-webui", ""), 20_000).unref?.();
|
|
257
|
-
return;
|
|
663
|
+
const existing = await probeExistingWebui(url);
|
|
664
|
+
if (existing) {
|
|
665
|
+
ctx.ui.setStatus("pi-webui", "restarting existing webui…");
|
|
666
|
+
await stopExistingWebui(url, options, existing);
|
|
258
667
|
}
|
|
259
668
|
|
|
260
669
|
const startedUrl = await startWebui(options, ctx);
|
|
261
670
|
if (options.open) openDefaultBrowser(startedUrl);
|
|
262
|
-
ctx.ui.notify(
|
|
671
|
+
ctx.ui.notify(`${existing ? "Pi Web UI restarted" : "Pi Web UI started"}:\n${startedUrl}`, "info");
|
|
263
672
|
ctx.ui.setStatus("pi-webui", startedUrl);
|
|
264
673
|
setTimeout(() => ctx.ui.setStatus("pi-webui", ""), 20_000).unref?.();
|
|
265
674
|
} catch (error) {
|
|
@@ -268,4 +677,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
268
677
|
}
|
|
269
678
|
},
|
|
270
679
|
});
|
|
680
|
+
|
|
681
|
+
pi.registerCommand("webui-status", {
|
|
682
|
+
description: "Show Pi Web UI URL, online state, network exposure, and optional detailed runtime info",
|
|
683
|
+
handler: async (args, ctx) => {
|
|
684
|
+
let options: WebuiStatusOptions;
|
|
685
|
+
try {
|
|
686
|
+
options = parseWebuiStatusArgs(args);
|
|
687
|
+
} catch (error) {
|
|
688
|
+
ctx.ui.notify(`${error instanceof Error ? error.message : String(error)}\n${statusUsage()}`, "error");
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
ctx.ui.setStatus("pi-webui", "checking webui status…");
|
|
693
|
+
try {
|
|
694
|
+
const result = await fetchWebuiStatus(options);
|
|
695
|
+
ctx.ui.notify(formatWebuiStatus(result, options.detailed), result.online ? "info" : "warning");
|
|
696
|
+
} catch (error) {
|
|
697
|
+
ctx.ui.notify(`Failed to check Pi Web UI status:\n${error instanceof Error ? error.message : String(error)}\n${statusUsage()}`, "error");
|
|
698
|
+
} finally {
|
|
699
|
+
ctx.ui.setStatus("pi-webui", "");
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
});
|
|
271
703
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Pi Web UI companion package with a local browser UI CLI and /
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"keywords": [
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
"bin": {
|
|
22
22
|
"pi-webui": "./bin/pi-webui.mjs"
|
|
23
23
|
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"check": "node --check public/app.js && node --check bin/pi-webui.mjs && node tests/mobile-static.test.mjs",
|
|
26
|
+
"test": "node tests/mobile-static.test.mjs"
|
|
27
|
+
},
|
|
24
28
|
"dependencies": {
|
|
25
29
|
"@earendil-works/pi-coding-agent": "^0.78.0"
|
|
26
30
|
},
|
|
@@ -28,6 +32,7 @@
|
|
|
28
32
|
"index.ts",
|
|
29
33
|
"bin",
|
|
30
34
|
"public",
|
|
35
|
+
"tests",
|
|
31
36
|
"README.md",
|
|
32
37
|
"LICENSE"
|
|
33
38
|
],
|