@firstpick/pi-package-webui 0.1.4 → 0.1.6
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 +34 -11
- package/bin/pi-webui.mjs +608 -26
- package/index.ts +82 -10
- package/package.json +34 -4
- package/public/app.js +3118 -211
- package/public/catppuccin-mocha-background.png +0 -0
- package/public/index.html +152 -52
- package/public/matrix-background.webp +0 -0
- package/public/service-worker.js +3 -1
- package/public/styles.css +772 -17
- package/tests/mobile-static.test.mjs +231 -36
package/bin/pi-webui.mjs
CHANGED
|
@@ -4,10 +4,11 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
7
|
-
import { homedir, networkInterfaces } from "node:os";
|
|
7
|
+
import { homedir, networkInterfaces, tmpdir } from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { StringDecoder } from "node:string_decoder";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
|
|
12
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
const require = createRequire(import.meta.url);
|
|
@@ -19,6 +20,14 @@ const DEFAULT_HOST = "127.0.0.1";
|
|
|
19
20
|
const DEFAULT_PORT = 31415;
|
|
20
21
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
21
22
|
const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
23
|
+
const PROMPT_BODY_LIMIT_BYTES = 24 * 1024 * 1024;
|
|
24
|
+
const UPLOAD_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
|
|
25
|
+
const ATTACHMENT_UPLOAD_MAX_FILES = 12;
|
|
26
|
+
const ATTACHMENT_UPLOAD_MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
27
|
+
const ATTACHMENT_UPLOAD_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
28
|
+
const INLINE_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
|
29
|
+
const INLINE_IMAGE_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
|
|
30
|
+
const RPC_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
|
22
31
|
const EVENT_HISTORY_LIMIT = 200;
|
|
23
32
|
const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
|
|
24
33
|
const STATUS_RPC_TIMEOUT_MS = 1_800;
|
|
@@ -29,6 +38,8 @@ const PATH_SUGGESTION_SCAN_LIMIT = 5000;
|
|
|
29
38
|
const PATH_SUGGESTION_MAX_OUTPUT_LENGTH = 300000;
|
|
30
39
|
const PATH_SUGGESTION_EXCLUDED_DIRS = new Set([".git", "node_modules"]);
|
|
31
40
|
const RESTORE_TAB_LIMIT = 30;
|
|
41
|
+
const SESSION_SELECTOR_LIMIT = 200;
|
|
42
|
+
const TREE_SELECTOR_TEXT_LIMIT = 260;
|
|
32
43
|
const NETWORK_REBIND_DELAY_MS = 100;
|
|
33
44
|
const NETWORK_REBIND_FORCE_CLOSE_MS = 750;
|
|
34
45
|
const AUTO_TAB_TITLE_MAX_LENGTH = 44;
|
|
@@ -77,6 +88,7 @@ const MIME_TYPES = new Map([
|
|
|
77
88
|
[".css", "text/css; charset=utf-8"],
|
|
78
89
|
[".svg", "image/svg+xml"],
|
|
79
90
|
[".png", "image/png"],
|
|
91
|
+
[".webp", "image/webp"],
|
|
80
92
|
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
|
81
93
|
]);
|
|
82
94
|
|
|
@@ -104,6 +116,15 @@ const NATIVE_SLASH_COMMANDS = [
|
|
|
104
116
|
{ name: "quit", description: "Quit Pi" },
|
|
105
117
|
].map((command) => ({ ...command, source: "native", location: "Pi" }));
|
|
106
118
|
const NATIVE_SLASH_COMMAND_NAMES = new Set(NATIVE_SLASH_COMMANDS.map((command) => command.name));
|
|
119
|
+
const OPTIONAL_FEATURE_PACKAGES = new Map([
|
|
120
|
+
["gitWorkflow", "@firstpick/pi-prompts-git-pr"],
|
|
121
|
+
["releaseNpm", "@firstpick/pi-extension-release-npm"],
|
|
122
|
+
["releaseAur", "@firstpick/pi-extension-release-aur"],
|
|
123
|
+
["todoProgressWidget", "@firstpick/pi-extension-todo-progress"],
|
|
124
|
+
["gitFooterStatus", "@firstpick/pi-extension-git-footer-status"],
|
|
125
|
+
["statsCommand", "@firstpick/pi-extension-stats"],
|
|
126
|
+
["themeBundle", "@firstpick/pi-themes-bundle"],
|
|
127
|
+
]);
|
|
107
128
|
|
|
108
129
|
function usage() {
|
|
109
130
|
console.log(`pi-webui ${packageJson.version}
|
|
@@ -119,7 +140,7 @@ Options:
|
|
|
119
140
|
--cwd <path> Working directory for the Pi session (default: current dir)
|
|
120
141
|
--pi <command> Pi executable to spawn (default: bundled dependency, then "pi")
|
|
121
142
|
--no-session Start Pi RPC with --no-session
|
|
122
|
-
--name <name> Initial
|
|
143
|
+
--name <name> Initial Web UI tab display name
|
|
123
144
|
-h, --help Show this help
|
|
124
145
|
-v, --version Print version
|
|
125
146
|
|
|
@@ -232,6 +253,10 @@ function sanitizeError(error) {
|
|
|
232
253
|
return error.stack || error.message || String(error);
|
|
233
254
|
}
|
|
234
255
|
|
|
256
|
+
function delay(ms) {
|
|
257
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
258
|
+
}
|
|
259
|
+
|
|
235
260
|
class PiRpcProcess {
|
|
236
261
|
constructor({ command, args, displayCommand, cwd }) {
|
|
237
262
|
this.command = command;
|
|
@@ -274,6 +299,10 @@ class PiRpcProcess {
|
|
|
274
299
|
this.emit({ type: "pi_process_start", pid: this.child.pid, cwd: this.cwd, command: this.displayCommand, args: this.args });
|
|
275
300
|
}
|
|
276
301
|
|
|
302
|
+
isRunning() {
|
|
303
|
+
return !!this.child && this.child.exitCode === null && !this.child.killed;
|
|
304
|
+
}
|
|
305
|
+
|
|
277
306
|
onEvent(listener) {
|
|
278
307
|
this.listeners.add(listener);
|
|
279
308
|
return () => this.listeners.delete(listener);
|
|
@@ -344,7 +373,7 @@ class PiRpcProcess {
|
|
|
344
373
|
}
|
|
345
374
|
|
|
346
375
|
send(command, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
347
|
-
if (!this.
|
|
376
|
+
if (!this.isRunning() || !this.child?.stdin) {
|
|
348
377
|
return Promise.reject(new Error("Pi RPC process is not running"));
|
|
349
378
|
}
|
|
350
379
|
|
|
@@ -367,7 +396,7 @@ class PiRpcProcess {
|
|
|
367
396
|
}
|
|
368
397
|
|
|
369
398
|
async writeRaw(command) {
|
|
370
|
-
if (!this.
|
|
399
|
+
if (!this.isRunning() || !this.child?.stdin) {
|
|
371
400
|
throw new Error("Pi RPC process is not running");
|
|
372
401
|
}
|
|
373
402
|
|
|
@@ -415,12 +444,24 @@ function sendError(res, statusCode, error) {
|
|
|
415
444
|
sendJson(res, statusCode, { ok: false, error: sanitizeError(error) });
|
|
416
445
|
}
|
|
417
446
|
|
|
418
|
-
|
|
447
|
+
function formatBytes(bytes) {
|
|
448
|
+
const value = Number(bytes) || 0;
|
|
449
|
+
if (value < 1024) return `${value} B`;
|
|
450
|
+
const units = ["KB", "MB", "GB"];
|
|
451
|
+
let scaled = value / 1024;
|
|
452
|
+
for (const unit of units) {
|
|
453
|
+
if (scaled < 1024 || unit === units[units.length - 1]) return `${scaled.toFixed(scaled >= 10 ? 1 : 2)} ${unit}`;
|
|
454
|
+
scaled /= 1024;
|
|
455
|
+
}
|
|
456
|
+
return `${value} B`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function readJsonBody(req, { limitBytes = BODY_LIMIT_BYTES } = {}) {
|
|
419
460
|
const chunks = [];
|
|
420
461
|
let size = 0;
|
|
421
462
|
for await (const chunk of req) {
|
|
422
463
|
size += chunk.length;
|
|
423
|
-
if (size >
|
|
464
|
+
if (size > limitBytes) throw makeHttpError(413, `Request body too large (limit ${formatBytes(limitBytes)})`);
|
|
424
465
|
chunks.push(chunk);
|
|
425
466
|
}
|
|
426
467
|
if (chunks.length === 0) return {};
|
|
@@ -634,6 +675,49 @@ function runCommand(command, args, { cwd, timeoutMs = 2000, maxOutputLength = 20
|
|
|
634
675
|
});
|
|
635
676
|
}
|
|
636
677
|
|
|
678
|
+
function optionalDependencyInstallRoot() {
|
|
679
|
+
const parts = packageRoot.split(path.sep);
|
|
680
|
+
const nodeModulesIndex = parts.lastIndexOf("node_modules");
|
|
681
|
+
if (nodeModulesIndex >= 0) {
|
|
682
|
+
const root = parts.slice(0, nodeModulesIndex).join(path.sep);
|
|
683
|
+
return root || path.parse(packageRoot).root;
|
|
684
|
+
}
|
|
685
|
+
return packageRoot;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function formatCommandForDisplay(command, args) {
|
|
689
|
+
return [command, ...args].map((part) => (/\s/.test(part) ? JSON.stringify(part) : part)).join(" ");
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function installOptionalFeaturePackage(featureId) {
|
|
693
|
+
const packageName = OPTIONAL_FEATURE_PACKAGES.get(featureId);
|
|
694
|
+
if (!packageName) throw makeHttpError(400, `Unknown optional feature: ${featureId}`);
|
|
695
|
+
|
|
696
|
+
const installRoot = optionalDependencyInstallRoot();
|
|
697
|
+
const npmCommand = process.env.PI_WEBUI_NPM_BIN || "npm";
|
|
698
|
+
const args = ["install", "--prefix", installRoot, packageName];
|
|
699
|
+
const result = await runCommand(npmCommand, args, {
|
|
700
|
+
cwd: installRoot,
|
|
701
|
+
timeoutMs: 5 * 60 * 1000,
|
|
702
|
+
maxOutputLength: 80000,
|
|
703
|
+
});
|
|
704
|
+
const command = formatCommandForDisplay(npmCommand, args);
|
|
705
|
+
const ok = result.exitCode === 0 && !result.timedOut && !result.error;
|
|
706
|
+
if (!ok) {
|
|
707
|
+
const details = [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
|
|
708
|
+
throw makeHttpError(500, `Optional feature install failed: ${command}${details ? `\n${details}` : ""}`);
|
|
709
|
+
}
|
|
710
|
+
return {
|
|
711
|
+
featureId,
|
|
712
|
+
packageName,
|
|
713
|
+
installRoot,
|
|
714
|
+
command,
|
|
715
|
+
stdout: result.stdout,
|
|
716
|
+
stderr: result.stderr,
|
|
717
|
+
message: `Installed optional feature package ${packageName}. Reload the active Pi tab to load new resources.`,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
637
721
|
function displayPath(cwd) {
|
|
638
722
|
const normalized = cwd.replace(/\\/g, "/");
|
|
639
723
|
const home = (process.env.USERPROFILE || process.env.HOME || "").replace(/\\/g, "/");
|
|
@@ -809,9 +893,9 @@ function resolveScopedModelsFromPatterns(patterns, models) {
|
|
|
809
893
|
async function getScopedModelData(tab) {
|
|
810
894
|
const { patterns, source } = await configuredScopedModelPatterns(tab.cwd);
|
|
811
895
|
if (!patterns.length) return { models: [], patterns, source };
|
|
812
|
-
const response = await tab
|
|
896
|
+
const response = await safeRpcResponse(tab, { type: "get_available_models" });
|
|
813
897
|
if (response.success === false) throw makeHttpError(400, response.error || "failed to load available models");
|
|
814
|
-
return { models: resolveScopedModelsFromPatterns(patterns, response.data?.models || []), patterns, source };
|
|
898
|
+
return { models: resolveScopedModelsFromPatterns(patterns, response.data?.models || []), patterns, source, rpcRunning: response.rpcRunning !== false };
|
|
815
899
|
}
|
|
816
900
|
|
|
817
901
|
function pathPickerRoots(activeCwd, viewedCwd) {
|
|
@@ -1299,7 +1383,7 @@ async function readBundledThemes() {
|
|
|
1299
1383
|
function normalizeStaticPath(urlPath) {
|
|
1300
1384
|
if (urlPath === "/") return "index.html";
|
|
1301
1385
|
const name = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
|
|
1302
|
-
if (!["index.html", "app.js", "styles.css", "favicon.svg", "apple-touch-icon.png", "icon-192.png", "icon-512.png", "manifest.webmanifest", "service-worker.js"].includes(name)) return undefined;
|
|
1386
|
+
if (!["index.html", "app.js", "styles.css", "favicon.svg", "apple-touch-icon.png", "icon-192.png", "icon-512.png", "catppuccin-mocha-background.png", "matrix-background.webp", "manifest.webmanifest", "service-worker.js"].includes(name)) return undefined;
|
|
1303
1387
|
return name;
|
|
1304
1388
|
}
|
|
1305
1389
|
|
|
@@ -1320,6 +1404,97 @@ async function serveStatic(req, res, url) {
|
|
|
1320
1404
|
return true;
|
|
1321
1405
|
}
|
|
1322
1406
|
|
|
1407
|
+
function requestBodyLimitForPath(pathname) {
|
|
1408
|
+
if (pathname === "/api/attachments") return UPLOAD_BODY_LIMIT_BYTES;
|
|
1409
|
+
if (["/api/prompt", "/api/steer", "/api/follow-up"].includes(pathname)) return PROMPT_BODY_LIMIT_BYTES;
|
|
1410
|
+
return BODY_LIMIT_BYTES;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function sanitizeUploadFileName(name) {
|
|
1414
|
+
const base = path.basename(String(name || "attachment").replace(/\0/g, ""));
|
|
1415
|
+
const safe = base.replace(/[^A-Za-z0-9._ -]+/g, "_").replace(/\s+/g, " ").trim().slice(0, 180);
|
|
1416
|
+
return safe && safe !== "." && safe !== ".." ? safe : "attachment";
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function normalizeMimeType(value) {
|
|
1420
|
+
const mimeType = String(value || "application/octet-stream").split(";", 1)[0].trim().toLowerCase();
|
|
1421
|
+
return mimeType || "application/octet-stream";
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function stripDataUrlPrefix(data) {
|
|
1425
|
+
const text = String(data || "").trim();
|
|
1426
|
+
if (!text.toLowerCase().startsWith("data:")) return text;
|
|
1427
|
+
const comma = text.indexOf(",");
|
|
1428
|
+
return comma === -1 ? text : text.slice(comma + 1);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function decodeAttachmentData(data) {
|
|
1432
|
+
const base64 = stripDataUrlPrefix(data).replace(/\s+/g, "");
|
|
1433
|
+
if (!base64) throw new Error("attachment data is required");
|
|
1434
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) throw new Error("attachment data must be base64 encoded");
|
|
1435
|
+
return Buffer.from(base64, "base64");
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
async function saveUploadedAttachments(body) {
|
|
1439
|
+
const rawFiles = Array.isArray(body?.files) ? body.files : [];
|
|
1440
|
+
if (rawFiles.length === 0) throw new Error("files are required");
|
|
1441
|
+
if (rawFiles.length > ATTACHMENT_UPLOAD_MAX_FILES) throw new Error(`attachments are limited to ${ATTACHMENT_UPLOAD_MAX_FILES} files`);
|
|
1442
|
+
|
|
1443
|
+
const decoded = [];
|
|
1444
|
+
let totalBytes = 0;
|
|
1445
|
+
for (const [index, file] of rawFiles.entries()) {
|
|
1446
|
+
const buffer = decodeAttachmentData(file?.data);
|
|
1447
|
+
if (buffer.length === 0) throw new Error(`attachment ${index + 1} is empty`);
|
|
1448
|
+
if (buffer.length > ATTACHMENT_UPLOAD_MAX_FILE_BYTES) throw new Error(`attachment ${index + 1} exceeds ${formatBytes(ATTACHMENT_UPLOAD_MAX_FILE_BYTES)}`);
|
|
1449
|
+
totalBytes += buffer.length;
|
|
1450
|
+
if (totalBytes > ATTACHMENT_UPLOAD_MAX_TOTAL_BYTES) throw new Error(`attachments exceed ${formatBytes(ATTACHMENT_UPLOAD_MAX_TOTAL_BYTES)} total`);
|
|
1451
|
+
decoded.push({
|
|
1452
|
+
id: String(file?.id || `attachment-${index + 1}`).slice(0, 120),
|
|
1453
|
+
name: sanitizeUploadFileName(file?.name),
|
|
1454
|
+
mimeType: normalizeMimeType(file?.mimeType || file?.type),
|
|
1455
|
+
size: buffer.length,
|
|
1456
|
+
buffer,
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const uploadDir = path.join(tmpdir(), "pi-webui-uploads", randomUUID());
|
|
1461
|
+
await mkdir(uploadDir, { recursive: true });
|
|
1462
|
+
const saved = [];
|
|
1463
|
+
for (const [index, file] of decoded.entries()) {
|
|
1464
|
+
const fileName = `${String(index + 1).padStart(2, "0")}-${file.name}`;
|
|
1465
|
+
const filePath = path.join(uploadDir, fileName);
|
|
1466
|
+
await writeFile(filePath, file.buffer);
|
|
1467
|
+
saved.push({ id: file.id, name: file.name, mimeType: file.mimeType, size: file.size, path: filePath });
|
|
1468
|
+
}
|
|
1469
|
+
return { files: saved, uploadDir };
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function normalizeRpcImages(value) {
|
|
1473
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
1474
|
+
if (value.length > ATTACHMENT_UPLOAD_MAX_FILES) throw new Error(`images are limited to ${ATTACHMENT_UPLOAD_MAX_FILES} files`);
|
|
1475
|
+
const images = [];
|
|
1476
|
+
let totalBytes = 0;
|
|
1477
|
+
for (const [index, image] of value.entries()) {
|
|
1478
|
+
const mimeType = normalizeMimeType(image?.mimeType);
|
|
1479
|
+
if (!RPC_IMAGE_MIME_TYPES.has(mimeType)) throw new Error(`image ${index + 1} has unsupported MIME type ${mimeType}`);
|
|
1480
|
+
const data = stripDataUrlPrefix(image?.data).replace(/\s+/g, "");
|
|
1481
|
+
if (!data) throw new Error(`image ${index + 1} data is required`);
|
|
1482
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data)) throw new Error(`image ${index + 1} data must be base64 encoded`);
|
|
1483
|
+
const approxBytes = Math.floor((data.length * 3) / 4);
|
|
1484
|
+
if (approxBytes > INLINE_IMAGE_MAX_BYTES) throw new Error(`image ${index + 1} exceeds ${formatBytes(INLINE_IMAGE_MAX_BYTES)} inline limit`);
|
|
1485
|
+
totalBytes += approxBytes;
|
|
1486
|
+
if (totalBytes > INLINE_IMAGE_TOTAL_MAX_BYTES) throw new Error(`inline images exceed ${formatBytes(INLINE_IMAGE_TOTAL_MAX_BYTES)} total`);
|
|
1487
|
+
images.push({ type: "image", data, mimeType });
|
|
1488
|
+
}
|
|
1489
|
+
return images.length ? images : undefined;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function attachImages(command, body) {
|
|
1493
|
+
const images = normalizeRpcImages(body?.images);
|
|
1494
|
+
if (images) command.images = images;
|
|
1495
|
+
return command;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1323
1498
|
function commandFromPost(pathname, body) {
|
|
1324
1499
|
switch (pathname) {
|
|
1325
1500
|
case "/api/prompt": {
|
|
@@ -1329,17 +1504,17 @@ function commandFromPost(pathname, body) {
|
|
|
1329
1504
|
if (body.streamingBehavior === "steer" || body.streamingBehavior === "followUp") {
|
|
1330
1505
|
command.streamingBehavior = body.streamingBehavior;
|
|
1331
1506
|
}
|
|
1332
|
-
return command;
|
|
1507
|
+
return attachImages(command, body);
|
|
1333
1508
|
}
|
|
1334
1509
|
case "/api/steer": {
|
|
1335
1510
|
const message = String(body.message || "").trim();
|
|
1336
1511
|
if (!message) throw new Error("message is required");
|
|
1337
|
-
return { type: "steer", message };
|
|
1512
|
+
return attachImages({ type: "steer", message }, body);
|
|
1338
1513
|
}
|
|
1339
1514
|
case "/api/follow-up": {
|
|
1340
1515
|
const message = String(body.message || "").trim();
|
|
1341
1516
|
if (!message) throw new Error("message is required");
|
|
1342
|
-
return { type: "follow_up", message };
|
|
1517
|
+
return attachImages({ type: "follow_up", message }, body);
|
|
1343
1518
|
}
|
|
1344
1519
|
case "/api/abort":
|
|
1345
1520
|
return { type: "abort" };
|
|
@@ -1358,6 +1533,18 @@ function commandFromPost(pathname, body) {
|
|
|
1358
1533
|
}
|
|
1359
1534
|
return { type: "set_thinking_level", level };
|
|
1360
1535
|
}
|
|
1536
|
+
case "/api/steering-mode": {
|
|
1537
|
+
const mode = String(body.mode || "").trim();
|
|
1538
|
+
if (!["all", "one-at-a-time"].includes(mode)) throw new Error("Invalid steering mode");
|
|
1539
|
+
return { type: "set_steering_mode", mode };
|
|
1540
|
+
}
|
|
1541
|
+
case "/api/follow-up-mode": {
|
|
1542
|
+
const mode = String(body.mode || "").trim();
|
|
1543
|
+
if (!["all", "one-at-a-time"].includes(mode)) throw new Error("Invalid follow-up mode");
|
|
1544
|
+
return { type: "set_follow_up_mode", mode };
|
|
1545
|
+
}
|
|
1546
|
+
case "/api/auto-compaction":
|
|
1547
|
+
return { type: "set_auto_compaction", enabled: body.enabled === true };
|
|
1361
1548
|
case "/api/compact":
|
|
1362
1549
|
return body.customInstructions ? { type: "compact", customInstructions: String(body.customInstructions) } : { type: "compact" };
|
|
1363
1550
|
default:
|
|
@@ -1449,9 +1636,9 @@ function buildPiArgsForTab(tabIndex, title) {
|
|
|
1449
1636
|
const args = ["--mode", "rpc"];
|
|
1450
1637
|
if (options.noSession) args.push("--no-session");
|
|
1451
1638
|
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1639
|
+
// Keep tab naming inside Web UI metadata. Some bundled Pi CLI versions do not
|
|
1640
|
+
// support --name, and passing Web UI-generated tab titles through to child
|
|
1641
|
+
// RPC processes makes every tab after the first exit immediately.
|
|
1455
1642
|
args.push(...options.piArgs);
|
|
1456
1643
|
return args;
|
|
1457
1644
|
}
|
|
@@ -1729,6 +1916,18 @@ function defaultTabTitle(tabIndex) {
|
|
|
1729
1916
|
return `Terminal ${tabIndex}`;
|
|
1730
1917
|
}
|
|
1731
1918
|
|
|
1919
|
+
async function primeTabRpc(tab) {
|
|
1920
|
+
try {
|
|
1921
|
+
const response = await tab.rpc.send({ type: "get_state" }, 1500);
|
|
1922
|
+
if (response.success !== false) {
|
|
1923
|
+
rememberTabState(tab, response.data);
|
|
1924
|
+
reconcileTabActivityFromState(tab, response.data);
|
|
1925
|
+
}
|
|
1926
|
+
} catch (error) {
|
|
1927
|
+
if (!/Timed out waiting for RPC response/i.test(sanitizeError(error))) throw error;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1732
1931
|
function attachRpcToTab(tab, rpc) {
|
|
1733
1932
|
tab.rpcUnsubscribe?.();
|
|
1734
1933
|
tab.rpc = rpc;
|
|
@@ -1777,6 +1976,15 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
|
|
|
1777
1976
|
attachRpcToTab(tab, rpc);
|
|
1778
1977
|
tabs.set(id, tab);
|
|
1779
1978
|
rpc.start();
|
|
1979
|
+
try {
|
|
1980
|
+
await primeTabRpc(tab);
|
|
1981
|
+
} catch (error) {
|
|
1982
|
+
if (!tab.rpc.isRunning()) {
|
|
1983
|
+
tab.rpcUnsubscribe?.();
|
|
1984
|
+
tabs.delete(id);
|
|
1985
|
+
throw new Error(`Pi RPC process failed while starting ${tabTitle}: ${sanitizeError(error)}`);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1780
1988
|
if (sessionFile && !options.noSession) {
|
|
1781
1989
|
recordEvent({ type: "webui_tab_restored", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd });
|
|
1782
1990
|
}
|
|
@@ -1799,7 +2007,7 @@ function tabMeta(tab) {
|
|
|
1799
2007
|
createdAt: tab.createdAt,
|
|
1800
2008
|
startedAt: tab.rpc.startedAt,
|
|
1801
2009
|
pid: tab.rpc.child?.pid,
|
|
1802
|
-
running:
|
|
2010
|
+
running: tab.rpc.isRunning(),
|
|
1803
2011
|
command: tab.rpc.displayCommand,
|
|
1804
2012
|
clientCount: tab.sseClients.size,
|
|
1805
2013
|
pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
|
|
@@ -1968,10 +2176,296 @@ async function restartTabRpc(tab, reason = "reload") {
|
|
|
1968
2176
|
return tab;
|
|
1969
2177
|
}
|
|
1970
2178
|
|
|
2179
|
+
function rpcUnavailableMessage(tab) {
|
|
2180
|
+
return `Pi RPC process for ${tab?.title || "terminal"} is not running`;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
function fallbackRpcResponse(tab, command, error) {
|
|
2184
|
+
const message = sanitizeError(error) || rpcUnavailableMessage(tab);
|
|
2185
|
+
const base = { type: "response", command: command.type, success: true, rpcRunning: false, error: message };
|
|
2186
|
+
switch (command.type) {
|
|
2187
|
+
case "get_state":
|
|
2188
|
+
return {
|
|
2189
|
+
...base,
|
|
2190
|
+
data: {
|
|
2191
|
+
model: null,
|
|
2192
|
+
thinkingLevel: "off",
|
|
2193
|
+
isStreaming: false,
|
|
2194
|
+
isCompacting: false,
|
|
2195
|
+
steeringMode: "one-at-a-time",
|
|
2196
|
+
followUpMode: "one-at-a-time",
|
|
2197
|
+
sessionFile: tab?.sessionFile,
|
|
2198
|
+
sessionId: tab?.id,
|
|
2199
|
+
sessionName: tab?.title,
|
|
2200
|
+
autoCompactionEnabled: false,
|
|
2201
|
+
messageCount: 0,
|
|
2202
|
+
pendingMessageCount: 0,
|
|
2203
|
+
rpcRunning: false,
|
|
2204
|
+
rpcError: message,
|
|
2205
|
+
},
|
|
2206
|
+
};
|
|
2207
|
+
case "get_messages":
|
|
2208
|
+
return { ...base, data: { messages: [] } };
|
|
2209
|
+
case "get_available_models":
|
|
2210
|
+
return { ...base, data: { models: [] } };
|
|
2211
|
+
case "get_session_stats":
|
|
2212
|
+
return { ...base, data: null };
|
|
2213
|
+
case "get_last_assistant_text":
|
|
2214
|
+
return { ...base, data: { text: "" } };
|
|
2215
|
+
default:
|
|
2216
|
+
return { ...base, success: false, error: message };
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
async function safeRpcResponse(tab, command, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
2221
|
+
try {
|
|
2222
|
+
return await tab.rpc.send(command, timeoutMs);
|
|
2223
|
+
} catch (error) {
|
|
2224
|
+
const message = sanitizeError(error);
|
|
2225
|
+
if (/Pi RPC process is not running/i.test(message)) return fallbackRpcResponse(tab, command, error);
|
|
2226
|
+
throw error;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
|
|
1971
2230
|
async function getCommandData(tab) {
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
2231
|
+
try {
|
|
2232
|
+
const response = await tab.rpc.send({ type: "get_commands" });
|
|
2233
|
+
if (response.success === false) throw makeHttpError(400, response.error || "failed to load commands");
|
|
2234
|
+
return { commands: [...NATIVE_SLASH_COMMANDS, ...(response.data?.commands || [])], rpcRunning: true };
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
const message = sanitizeError(error);
|
|
2237
|
+
if (!/Pi RPC process is not running/i.test(message)) throw error;
|
|
2238
|
+
return { commands: [...NATIVE_SLASH_COMMANDS], rpcRunning: false, error: message };
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
function resolveCliPath(value) {
|
|
2243
|
+
const text = String(value || "").trim();
|
|
2244
|
+
if (!text) return "";
|
|
2245
|
+
return path.isAbsolute(text) ? text : path.resolve(options.cwd, text);
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
function resolveTabPath(tab, value) {
|
|
2249
|
+
const text = String(value || "").trim();
|
|
2250
|
+
if (!text) return "";
|
|
2251
|
+
return path.isAbsolute(text) ? text : path.resolve(tab?.cwd || options.cwd, text);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function configuredSessionDir() {
|
|
2255
|
+
for (let index = 0; index < options.piArgs.length; index++) {
|
|
2256
|
+
const arg = options.piArgs[index];
|
|
2257
|
+
if (arg === "--session-dir" && options.piArgs[index + 1]) return resolveCliPath(options.piArgs[index + 1]);
|
|
2258
|
+
if (arg.startsWith("--session-dir=")) return resolveCliPath(arg.slice("--session-dir=".length));
|
|
2259
|
+
}
|
|
2260
|
+
return undefined;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
function requirePersistentSessions() {
|
|
2264
|
+
if (options.noSession) throw makeHttpError(400, "Session selectors are unavailable when Web UI was started with --no-session.");
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
function isoDate(value) {
|
|
2268
|
+
const date = value instanceof Date ? value : new Date(value || 0);
|
|
2269
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
function normalizeSessionInfo(info, currentSessionFile) {
|
|
2273
|
+
const sessionPath = String(info.path || "");
|
|
2274
|
+
return {
|
|
2275
|
+
path: sessionPath,
|
|
2276
|
+
id: String(info.id || ""),
|
|
2277
|
+
name: info.name || undefined,
|
|
2278
|
+
cwd: String(info.cwd || ""),
|
|
2279
|
+
created: isoDate(info.created),
|
|
2280
|
+
modified: isoDate(info.modified),
|
|
2281
|
+
messageCount: Number.isFinite(info.messageCount) ? info.messageCount : 0,
|
|
2282
|
+
firstMessage: truncateStatusText(info.firstMessage || "(no messages)", 220),
|
|
2283
|
+
parentSessionPath: info.parentSessionPath || undefined,
|
|
2284
|
+
current: !!currentSessionFile && path.resolve(sessionPath) === path.resolve(currentSessionFile),
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
async function currentSessionState(tab) {
|
|
2289
|
+
const response = await safeRpcResponse(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
2290
|
+
if (response.success === false) throw makeHttpError(400, response.error || "failed to load current session state");
|
|
2291
|
+
rememberTabState(tab, response.data);
|
|
2292
|
+
return response.data || {};
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
async function getSessionSelectorData(tab, scope = "current") {
|
|
2296
|
+
requirePersistentSessions();
|
|
2297
|
+
const state = await currentSessionState(tab).catch(() => tab.lastState || {});
|
|
2298
|
+
const sessionDir = configuredSessionDir();
|
|
2299
|
+
const listAll = String(scope || "current").toLowerCase() === "all";
|
|
2300
|
+
const sessions = listAll ? await SessionManager.listAll(sessionDir) : await SessionManager.list(tab.cwd, sessionDir);
|
|
2301
|
+
return {
|
|
2302
|
+
scope: listAll ? "all" : "current",
|
|
2303
|
+
sessionDir: sessionDir || undefined,
|
|
2304
|
+
currentSessionFile: state.sessionFile || tabRestorableSessionFile(tab),
|
|
2305
|
+
sessions: sessions.slice(0, SESSION_SELECTOR_LIMIT).map((info) => normalizeSessionInfo(info, state.sessionFile || tabRestorableSessionFile(tab))),
|
|
2306
|
+
limited: sessions.length > SESSION_SELECTOR_LIMIT,
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
function extractSessionTextContent(content) {
|
|
2311
|
+
if (typeof content === "string") return content;
|
|
2312
|
+
if (!Array.isArray(content)) return "";
|
|
2313
|
+
return content
|
|
2314
|
+
.map((part) => {
|
|
2315
|
+
if (typeof part === "string") return part;
|
|
2316
|
+
if (part?.type === "text" && typeof part.text === "string") return part.text;
|
|
2317
|
+
if (part?.type === "toolCall") return `[tool call: ${part.toolName || "tool"}]`;
|
|
2318
|
+
if (part?.type === "thinking") return "[thinking]";
|
|
2319
|
+
if (part?.type === "image") return "[image]";
|
|
2320
|
+
return "";
|
|
2321
|
+
})
|
|
2322
|
+
.filter(Boolean)
|
|
2323
|
+
.join(" ");
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function sessionTreeEntryLabel(entry) {
|
|
2327
|
+
if (!entry || typeof entry !== "object") return "entry";
|
|
2328
|
+
if (entry.type === "message") return entry.message?.role || "message";
|
|
2329
|
+
if (entry.type === "branch_summary") return "branch summary";
|
|
2330
|
+
if (entry.type === "compaction") return "compaction";
|
|
2331
|
+
if (entry.type === "model_change") return "model";
|
|
2332
|
+
if (entry.type === "thinking_level_change") return "thinking";
|
|
2333
|
+
if (entry.type === "custom_message") return entry.customType || "custom";
|
|
2334
|
+
return entry.type || "entry";
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
function sessionTreeEntryText(entry) {
|
|
2338
|
+
if (!entry || typeof entry !== "object") return "";
|
|
2339
|
+
if (entry.type === "message") return extractSessionTextContent(entry.message?.content);
|
|
2340
|
+
if (entry.type === "custom_message") return extractSessionTextContent(entry.content);
|
|
2341
|
+
if (entry.type === "branch_summary") return entry.summary || "branch summary";
|
|
2342
|
+
if (entry.type === "compaction") return entry.summary || "compaction summary";
|
|
2343
|
+
if (entry.type === "model_change") return [entry.provider, entry.modelId].filter(Boolean).join("/");
|
|
2344
|
+
if (entry.type === "thinking_level_change") return entry.thinkingLevel || "";
|
|
2345
|
+
return "";
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
function flattenSessionTree(nodes, { depth = 0, leafId, result = [] } = {}) {
|
|
2349
|
+
for (const node of nodes || []) {
|
|
2350
|
+
const entry = node.entry || {};
|
|
2351
|
+
result.push({
|
|
2352
|
+
id: entry.id,
|
|
2353
|
+
parentId: entry.parentId ?? null,
|
|
2354
|
+
depth,
|
|
2355
|
+
type: entry.type || "entry",
|
|
2356
|
+
role: entry.message?.role || undefined,
|
|
2357
|
+
label: node.label || undefined,
|
|
2358
|
+
timestamp: entry.timestamp || undefined,
|
|
2359
|
+
title: sessionTreeEntryLabel(entry),
|
|
2360
|
+
text: truncateStatusText(sessionTreeEntryText(entry), TREE_SELECTOR_TEXT_LIMIT),
|
|
2361
|
+
childCount: Array.isArray(node.children) ? node.children.length : 0,
|
|
2362
|
+
currentLeaf: !!leafId && entry.id === leafId,
|
|
2363
|
+
});
|
|
2364
|
+
flattenSessionTree(node.children || [], { depth: depth + 1, leafId, result });
|
|
2365
|
+
}
|
|
2366
|
+
return result;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
async function getSessionTreeData(tab) {
|
|
2370
|
+
requirePersistentSessions();
|
|
2371
|
+
const state = await currentSessionState(tab).catch(() => tab.lastState || {});
|
|
2372
|
+
const sessionFile = state.sessionFile || tabRestorableSessionFile(tab);
|
|
2373
|
+
if (!sessionFile) throw makeHttpError(400, "No persisted session file is available for /tree.");
|
|
2374
|
+
const manager = SessionManager.open(sessionFile, configuredSessionDir(), tab.cwd);
|
|
2375
|
+
const leafId = manager.getLeafId();
|
|
2376
|
+
return {
|
|
2377
|
+
sessionFile: manager.getSessionFile(),
|
|
2378
|
+
sessionId: manager.getSessionId(),
|
|
2379
|
+
cwd: manager.getCwd(),
|
|
2380
|
+
leafId,
|
|
2381
|
+
nodes: flattenSessionTree(manager.getTree(), { leafId }),
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
async function getForkMessagesData(tab) {
|
|
2386
|
+
const response = await safeRpcResponse(tab, { type: "get_fork_messages" });
|
|
2387
|
+
if (response.success === false) throw makeHttpError(400, response.error || "failed to load fork points");
|
|
2388
|
+
return { messages: Array.isArray(response.data?.messages) ? response.data.messages : [] };
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
async function requireIdleForSessionAction(tab, actionLabel) {
|
|
2392
|
+
const state = await currentSessionState(tab);
|
|
2393
|
+
if (state.isStreaming || state.isCompacting) throw makeHttpError(409, `Wait for the current agent run or compaction to finish before ${actionLabel}.`);
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
async function runForkCommand(tab, entryId) {
|
|
2397
|
+
await requireIdleForSessionAction(tab, "forking the session");
|
|
2398
|
+
const targetEntryId = String(entryId || "").trim();
|
|
2399
|
+
if (!targetEntryId) throw makeHttpError(400, "entryId is required");
|
|
2400
|
+
const response = await tab.rpc.send({ type: "fork", entryId: targetEntryId });
|
|
2401
|
+
if (response.success === false) return response;
|
|
2402
|
+
const state = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
2403
|
+
if (state.ok) rememberTabState(tab, state.data);
|
|
2404
|
+
return rpcSuccess("fork", {
|
|
2405
|
+
message: response.data?.cancelled ? "Fork cancelled." : "Forked the current session.",
|
|
2406
|
+
text: response.data?.text || "",
|
|
2407
|
+
result: response.data,
|
|
2408
|
+
tab: tabMeta(tab),
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
async function runCloneCommand(tab) {
|
|
2413
|
+
await requireIdleForSessionAction(tab, "cloning the session");
|
|
2414
|
+
const response = await tab.rpc.send({ type: "clone" });
|
|
2415
|
+
if (response.success === false) return response;
|
|
2416
|
+
const state = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
2417
|
+
if (state.ok) rememberTabState(tab, state.data);
|
|
2418
|
+
return rpcSuccess("clone", {
|
|
2419
|
+
message: response.data?.cancelled ? "Clone cancelled." : "Cloned the current session.",
|
|
2420
|
+
result: response.data,
|
|
2421
|
+
tab: tabMeta(tab),
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
async function switchTabSession(tab, sessionPath) {
|
|
2426
|
+
requirePersistentSessions();
|
|
2427
|
+
await requireIdleForSessionAction(tab, "switching sessions");
|
|
2428
|
+
const targetPath = resolveTabPath(tab, sessionPath);
|
|
2429
|
+
if (!targetPath) throw makeHttpError(400, "sessionPath is required");
|
|
2430
|
+
if (!targetPath.endsWith(".jsonl")) throw makeHttpError(400, "sessionPath must point to a .jsonl session file");
|
|
2431
|
+
const targetStats = await stat(targetPath).catch(() => null);
|
|
2432
|
+
if (!targetStats?.isFile()) throw makeHttpError(404, `Session file not found: ${targetPath}`);
|
|
2433
|
+
const manager = SessionManager.open(targetPath, configuredSessionDir());
|
|
2434
|
+
const response = await tab.rpc.send({ type: "switch_session", sessionPath: manager.getSessionFile() });
|
|
2435
|
+
if (response.success === false) return response;
|
|
2436
|
+
if (!response.data?.cancelled) {
|
|
2437
|
+
tab.cwd = manager.getCwd();
|
|
2438
|
+
const state = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
2439
|
+
if (state.ok) rememberTabState(tab, state.data);
|
|
2440
|
+
}
|
|
2441
|
+
return rpcSuccess("switch_session", {
|
|
2442
|
+
message: response.data?.cancelled ? "Resume cancelled." : "Resumed selected session.",
|
|
2443
|
+
result: response.data,
|
|
2444
|
+
tab: tabMeta(tab),
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
async function navigateSessionTree(tab, body) {
|
|
2449
|
+
requirePersistentSessions();
|
|
2450
|
+
await requireIdleForSessionAction(tab, "navigating the session tree");
|
|
2451
|
+
const entryId = String(body.entryId || body.targetId || "").trim();
|
|
2452
|
+
if (!entryId) throw makeHttpError(400, "entryId is required");
|
|
2453
|
+
const payload = {
|
|
2454
|
+
entryId,
|
|
2455
|
+
summarize: body.summarize === true,
|
|
2456
|
+
customInstructions: typeof body.customInstructions === "string" ? body.customInstructions : undefined,
|
|
2457
|
+
replaceInstructions: body.replaceInstructions === true,
|
|
2458
|
+
label: typeof body.label === "string" ? body.label : undefined,
|
|
2459
|
+
};
|
|
2460
|
+
const response = await tab.rpc.send({ type: "prompt", message: `/webui-tree-navigate ${JSON.stringify(payload)}` });
|
|
2461
|
+
if (response.success === false) return response;
|
|
2462
|
+
const state = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
2463
|
+
if (state.ok) rememberTabState(tab, state.data);
|
|
2464
|
+
return rpcSuccess("tree", {
|
|
2465
|
+
message: "Navigated the session tree.",
|
|
2466
|
+
result: response.data,
|
|
2467
|
+
tab: tabMeta(tab),
|
|
2468
|
+
});
|
|
1975
2469
|
}
|
|
1976
2470
|
|
|
1977
2471
|
function formatSessionOutput(tab, state, stats) {
|
|
@@ -2047,8 +2541,8 @@ async function handleNativeSlashCommand(tab, body) {
|
|
|
2047
2541
|
return rpcSuccess("native_slash_command", { command: "hotkeys", message: webuiHotkeysOutput() });
|
|
2048
2542
|
}
|
|
2049
2543
|
case "clone": {
|
|
2050
|
-
const response = await tab
|
|
2051
|
-
return response.success === false ? response : rpcSuccess("native_slash_command", { command: "clone", message: "Cloned the current session.", result: response.data });
|
|
2544
|
+
const response = await runCloneCommand(tab);
|
|
2545
|
+
return response.success === false ? response : rpcSuccess("native_slash_command", { command: "clone", message: response.data?.message || "Cloned the current session.", result: response.data?.result });
|
|
2052
2546
|
}
|
|
2053
2547
|
default:
|
|
2054
2548
|
throw makeHttpError(400, `/${parsed.name} is a native Pi TUI command, but this Web UI cannot run that interactive command yet.`);
|
|
@@ -2080,6 +2574,23 @@ async function closeTab(id) {
|
|
|
2080
2574
|
return tab;
|
|
2081
2575
|
}
|
|
2082
2576
|
|
|
2577
|
+
async function closeTabs(ids) {
|
|
2578
|
+
const uniqueIds = [...new Set((Array.isArray(ids) ? ids : []).map((id) => String(id || "").trim()).filter(Boolean))];
|
|
2579
|
+
const targetTabs = uniqueIds.map((id) => tabs.get(id)).filter(Boolean);
|
|
2580
|
+
if (!targetTabs.length) return [];
|
|
2581
|
+
|
|
2582
|
+
if (targetTabs.length >= tabs.size) {
|
|
2583
|
+
await createTab({ cwd: targetTabs[0]?.cwd || options.cwd });
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
const closed = [];
|
|
2587
|
+
for (const tab of targetTabs) {
|
|
2588
|
+
if (!tabs.has(tab.id)) continue;
|
|
2589
|
+
closed.push(await closeTab(tab.id));
|
|
2590
|
+
}
|
|
2591
|
+
return closed;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2083
2594
|
function requestedTabId(req, url, body) {
|
|
2084
2595
|
const header = req.headers["x-pi-webui-tab"];
|
|
2085
2596
|
const headerValue = Array.isArray(header) ? header[0] : header;
|
|
@@ -2332,13 +2843,13 @@ async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
|
|
|
2332
2843
|
piPid: tab?.rpc.child?.pid,
|
|
2333
2844
|
piRunning: !!tab?.rpc.child && tab.rpc.child.exitCode === null,
|
|
2334
2845
|
tabs: statusTabs,
|
|
2335
|
-
restorableTabs: mergeRestorableTabDescriptors(statusTabs
|
|
2846
|
+
restorableTabs: mergeRestorableTabDescriptors(statusTabs),
|
|
2336
2847
|
};
|
|
2337
2848
|
|
|
2338
2849
|
if (detailed) {
|
|
2339
2850
|
const detailedTabs = await Promise.all([...tabs.values()].map((item) => tabStatusDetails(item)));
|
|
2340
2851
|
data.tabs = detailedTabs;
|
|
2341
|
-
data.restorableTabs = mergeRestorableTabDescriptors(detailedTabs
|
|
2852
|
+
data.restorableTabs = mergeRestorableTabDescriptors(detailedTabs);
|
|
2342
2853
|
data.closedTabs = closedRestorableTabs.slice();
|
|
2343
2854
|
data.events = latestEvents(eventLimit);
|
|
2344
2855
|
}
|
|
@@ -2362,6 +2873,13 @@ const server = createServer(async (req, res) => {
|
|
|
2362
2873
|
return;
|
|
2363
2874
|
}
|
|
2364
2875
|
|
|
2876
|
+
if (url.pathname === "/api/tabs/close" && req.method === "POST") {
|
|
2877
|
+
const body = await readJsonBody(req);
|
|
2878
|
+
const closed = await closeTabs(body.ids || body.tabIds || []);
|
|
2879
|
+
sendJson(res, 200, { ok: true, data: { closedIds: closed.map((tab) => tab.id), tabs: listTabs(), activeTabId: firstTab()?.id || null } });
|
|
2880
|
+
return;
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2365
2883
|
if (url.pathname.startsWith("/api/tabs/") && req.method === "PATCH") {
|
|
2366
2884
|
const id = decodeURIComponent(url.pathname.slice("/api/tabs/".length));
|
|
2367
2885
|
const body = await readJsonBody(req);
|
|
@@ -2505,12 +3023,76 @@ const server = createServer(async (req, res) => {
|
|
|
2505
3023
|
return;
|
|
2506
3024
|
}
|
|
2507
3025
|
|
|
3026
|
+
if (url.pathname === "/api/attachments" && req.method === "POST") {
|
|
3027
|
+
const body = await readJsonBody(req, { limitBytes: requestBodyLimitForPath(url.pathname) });
|
|
3028
|
+
sendJson(res, 201, { ok: true, data: await saveUploadedAttachments(body) });
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
|
|
2508
3032
|
if (url.pathname === "/api/scoped-models" && req.method === "GET") {
|
|
2509
3033
|
const tab = getRequestedTab(req, url);
|
|
2510
3034
|
sendJson(res, 200, { ok: true, data: await getScopedModelData(tab) });
|
|
2511
3035
|
return;
|
|
2512
3036
|
}
|
|
2513
3037
|
|
|
3038
|
+
if (url.pathname === "/api/fork-messages" && req.method === "GET") {
|
|
3039
|
+
const tab = getRequestedTab(req, url);
|
|
3040
|
+
sendJson(res, 200, { ok: true, data: await getForkMessagesData(tab) });
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
if (url.pathname === "/api/sessions" && req.method === "GET") {
|
|
3045
|
+
const tab = getRequestedTab(req, url);
|
|
3046
|
+
sendJson(res, 200, { ok: true, data: await getSessionSelectorData(tab, url.searchParams.get("scope") || "current") });
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
if (url.pathname === "/api/session-tree" && req.method === "GET") {
|
|
3051
|
+
const tab = getRequestedTab(req, url);
|
|
3052
|
+
sendJson(res, 200, { ok: true, data: await getSessionTreeData(tab) });
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
if (url.pathname === "/api/fork" && req.method === "POST") {
|
|
3057
|
+
const body = await readJsonBody(req);
|
|
3058
|
+
const tab = getRequestedTab(req, url, body);
|
|
3059
|
+
const response = await runForkCommand(tab, body.entryId);
|
|
3060
|
+
sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
|
|
3061
|
+
return;
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
if (url.pathname === "/api/clone" && req.method === "POST") {
|
|
3065
|
+
const body = await readJsonBody(req);
|
|
3066
|
+
const tab = getRequestedTab(req, url, body);
|
|
3067
|
+
const response = await runCloneCommand(tab);
|
|
3068
|
+
sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
|
|
3069
|
+
return;
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
if (url.pathname === "/api/switch-session" && req.method === "POST") {
|
|
3073
|
+
const body = await readJsonBody(req);
|
|
3074
|
+
const tab = getRequestedTab(req, url, body);
|
|
3075
|
+
const response = await switchTabSession(tab, body.sessionPath || body.path);
|
|
3076
|
+
sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
|
|
3077
|
+
return;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
if (url.pathname === "/api/tree-navigate" && req.method === "POST") {
|
|
3081
|
+
const body = await readJsonBody(req);
|
|
3082
|
+
const tab = getRequestedTab(req, url, body);
|
|
3083
|
+
const response = await navigateSessionTree(tab, body);
|
|
3084
|
+
sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
if (url.pathname === "/api/optional-feature-install" && req.method === "POST") {
|
|
3089
|
+
if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Installing optional Web UI features is only allowed from localhost");
|
|
3090
|
+
const body = await readJsonBody(req);
|
|
3091
|
+
const data = await installOptionalFeaturePackage(String(body.featureId || ""));
|
|
3092
|
+
sendJson(res, 200, { ok: true, data });
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
2514
3096
|
if (url.pathname === "/api/commands" && req.method === "GET") {
|
|
2515
3097
|
const tab = getRequestedTab(req, url);
|
|
2516
3098
|
sendJson(res, 200, { type: "response", command: "get_commands", success: true, data: await getCommandData(tab) });
|
|
@@ -2526,7 +3108,7 @@ const server = createServer(async (req, res) => {
|
|
|
2526
3108
|
}
|
|
2527
3109
|
|
|
2528
3110
|
if (url.pathname === "/api/prompt" && req.method === "POST") {
|
|
2529
|
-
const body = await readJsonBody(req);
|
|
3111
|
+
const body = await readJsonBody(req, { limitBytes: requestBodyLimitForPath(url.pathname) });
|
|
2530
3112
|
const tab = getRequestedTab(req, url, body);
|
|
2531
3113
|
const nativeResponse = await handleNativeSlashCommand(tab, body);
|
|
2532
3114
|
if (nativeResponse) {
|
|
@@ -2558,7 +3140,7 @@ const server = createServer(async (req, res) => {
|
|
|
2558
3140
|
const getCommand = req.method === "GET" ? commandFromGet(url.pathname) : undefined;
|
|
2559
3141
|
if (getCommand) {
|
|
2560
3142
|
const tab = getRequestedTab(req, url);
|
|
2561
|
-
const response = await tab
|
|
3143
|
+
const response = await safeRpcResponse(tab, getCommand);
|
|
2562
3144
|
sendJson(res, response.success === false ? 400 : 200, response);
|
|
2563
3145
|
return;
|
|
2564
3146
|
}
|
|
@@ -2586,7 +3168,7 @@ const server = createServer(async (req, res) => {
|
|
|
2586
3168
|
}
|
|
2587
3169
|
|
|
2588
3170
|
if (req.method === "POST") {
|
|
2589
|
-
const body = await readJsonBody(req);
|
|
3171
|
+
const body = await readJsonBody(req, { limitBytes: requestBodyLimitForPath(url.pathname) });
|
|
2590
3172
|
const command = commandFromPost(url.pathname, body);
|
|
2591
3173
|
if (command) {
|
|
2592
3174
|
const tab = getRequestedTab(req, url, body);
|