@firstpick/pi-package-webui 0.1.1 → 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 +33 -13
- package/bin/pi-webui.mjs +457 -15
- package/index.ts +304 -4
- package/package.json +7 -2
- package/public/app.js +644 -40
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/index.html +48 -22
- package/public/manifest.webmanifest +40 -0
- package/public/service-worker.js +46 -0
- package/public/styles.css +704 -26
- package/tests/mobile-static.test.mjs +170 -0
package/bin/pi-webui.mjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
-
import { access, readFile, readdir, stat } from "node:fs/promises";
|
|
6
|
-
import { networkInterfaces } from "node:os";
|
|
5
|
+
import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { homedir, networkInterfaces } from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { StringDecoder } from "node:string_decoder";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -17,14 +17,44 @@ const DEFAULT_HOST = "127.0.0.1";
|
|
|
17
17
|
const DEFAULT_PORT = 31415;
|
|
18
18
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
19
19
|
const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
20
|
+
const EVENT_HISTORY_LIMIT = 200;
|
|
21
|
+
const STATUS_RPC_TIMEOUT_MS = 1_800;
|
|
22
|
+
const FAST_PICK_LIMIT = 30;
|
|
20
23
|
|
|
21
24
|
const MIME_TYPES = new Map([
|
|
22
25
|
[".html", "text/html; charset=utf-8"],
|
|
23
26
|
[".js", "text/javascript; charset=utf-8"],
|
|
24
27
|
[".css", "text/css; charset=utf-8"],
|
|
25
28
|
[".svg", "image/svg+xml"],
|
|
29
|
+
[".png", "image/png"],
|
|
30
|
+
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
|
26
31
|
]);
|
|
27
32
|
|
|
33
|
+
const NATIVE_SLASH_COMMANDS = [
|
|
34
|
+
{ name: "settings", description: "Open settings menu" },
|
|
35
|
+
{ name: "model", description: "Select model (opens selector UI)" },
|
|
36
|
+
{ name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
|
|
37
|
+
{ name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
|
|
38
|
+
{ name: "import", description: "Import and resume a session from a JSONL file" },
|
|
39
|
+
{ name: "share", description: "Share session as a secret GitHub gist" },
|
|
40
|
+
{ name: "copy", description: "Copy last agent message to clipboard" },
|
|
41
|
+
{ name: "name", description: "Set session display name" },
|
|
42
|
+
{ name: "session", description: "Show session info and stats" },
|
|
43
|
+
{ name: "changelog", description: "Show changelog entries" },
|
|
44
|
+
{ name: "hotkeys", description: "Show all keyboard shortcuts" },
|
|
45
|
+
{ name: "fork", description: "Create a new fork from a previous user message" },
|
|
46
|
+
{ name: "clone", description: "Duplicate the current session at the current position" },
|
|
47
|
+
{ name: "tree", description: "Navigate session tree (switch branches)" },
|
|
48
|
+
{ name: "login", description: "Configure provider authentication" },
|
|
49
|
+
{ name: "logout", description: "Remove provider authentication" },
|
|
50
|
+
{ name: "new", description: "Start a new session" },
|
|
51
|
+
{ name: "compact", description: "Manually compact the session context" },
|
|
52
|
+
{ name: "resume", description: "Resume a different session" },
|
|
53
|
+
{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" },
|
|
54
|
+
{ name: "quit", description: "Quit Pi" },
|
|
55
|
+
].map((command) => ({ ...command, source: "native", location: "Pi" }));
|
|
56
|
+
const NATIVE_SLASH_COMMAND_NAMES = new Set(NATIVE_SLASH_COMMANDS.map((command) => command.name));
|
|
57
|
+
|
|
28
58
|
function usage() {
|
|
29
59
|
console.log(`pi-webui ${packageJson.version}
|
|
30
60
|
|
|
@@ -352,6 +382,51 @@ function sendSse(res, event) {
|
|
|
352
382
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
353
383
|
}
|
|
354
384
|
|
|
385
|
+
function rpcSuccess(command, data = {}) {
|
|
386
|
+
return { type: "response", command, success: true, data };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function parseSlashCommand(message) {
|
|
390
|
+
const text = String(message || "").trim();
|
|
391
|
+
if (!text.startsWith("/") || text.includes("\n")) return undefined;
|
|
392
|
+
const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
|
|
393
|
+
if (!match) return undefined;
|
|
394
|
+
const name = match[1].toLowerCase();
|
|
395
|
+
if (!NATIVE_SLASH_COMMAND_NAMES.has(name)) return undefined;
|
|
396
|
+
return { name, args: (match[2] || "").trim(), text };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const eventHistory = [];
|
|
400
|
+
|
|
401
|
+
function truncateStatusText(value, maxLength = 240) {
|
|
402
|
+
const text = String(value || "").replace(/\s+/g, " ").trim();
|
|
403
|
+
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function statusEventSummary(event) {
|
|
407
|
+
const summary = {
|
|
408
|
+
timestamp: new Date().toISOString(),
|
|
409
|
+
type: String(event?.type || "event"),
|
|
410
|
+
};
|
|
411
|
+
for (const key of ["tabId", "tabTitle", "pid", "cwd", "code", "signal", "command", "queueLength", "pendingMessageCount"]) {
|
|
412
|
+
if (event?.[key] !== undefined) summary[key] = event[key];
|
|
413
|
+
}
|
|
414
|
+
if (event?.assistantMessageEvent?.type) summary.updateType = event.assistantMessageEvent.type;
|
|
415
|
+
if (event?.message?.role) summary.messageRole = event.message.role;
|
|
416
|
+
if (event?.error) summary.error = truncateStatusText(event.error);
|
|
417
|
+
if (event?.text && summary.type === "pi_stderr") summary.text = truncateStatusText(event.text);
|
|
418
|
+
return summary;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function recordEvent(event) {
|
|
422
|
+
eventHistory.push(statusEventSummary(event));
|
|
423
|
+
if (eventHistory.length > EVENT_HISTORY_LIMIT) eventHistory.splice(0, eventHistory.length - EVENT_HISTORY_LIMIT);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function latestEvents(limit = 40) {
|
|
427
|
+
return eventHistory.slice(-Math.max(0, Math.min(EVENT_HISTORY_LIMIT, limit)));
|
|
428
|
+
}
|
|
429
|
+
|
|
355
430
|
function runCommand(command, args, { cwd, timeoutMs = 2000 } = {}) {
|
|
356
431
|
return new Promise((resolve) => {
|
|
357
432
|
const child = spawn(command, args, {
|
|
@@ -434,6 +509,137 @@ function uniquePathItems(items) {
|
|
|
434
509
|
return result;
|
|
435
510
|
}
|
|
436
511
|
|
|
512
|
+
function normalizePathFastPicks(value) {
|
|
513
|
+
const items = Array.isArray(value) ? value : Array.isArray(value?.picks) ? value.picks : [];
|
|
514
|
+
const seen = new Set();
|
|
515
|
+
const picks = [];
|
|
516
|
+
for (const item of items) {
|
|
517
|
+
const rawCwd = typeof item === "string" ? item : item?.cwd;
|
|
518
|
+
if (!rawCwd) continue;
|
|
519
|
+
let cwd;
|
|
520
|
+
try {
|
|
521
|
+
cwd = path.resolve(options.cwd, expandUserPath(rawCwd));
|
|
522
|
+
} catch {
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (!cwd || seen.has(cwd)) continue;
|
|
526
|
+
seen.add(cwd);
|
|
527
|
+
const displayCwd = String(typeof item === "object" && item?.displayCwd ? item.displayCwd : displayPath(cwd)).slice(0, 4096);
|
|
528
|
+
picks.push({ cwd, displayCwd });
|
|
529
|
+
if (picks.length >= FAST_PICK_LIMIT) break;
|
|
530
|
+
}
|
|
531
|
+
return picks;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function fastPicksStorageFile() {
|
|
535
|
+
if (process.env.PI_WEBUI_FAST_PICKS_FILE) return path.resolve(expandUserPath(process.env.PI_WEBUI_FAST_PICKS_FILE));
|
|
536
|
+
const stateRoot = process.env.XDG_STATE_HOME || path.join(homedir(), ".local", "state");
|
|
537
|
+
return path.join(stateRoot, "pi-webui", "fast-picks.json");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
let pathFastPicksCache = null;
|
|
541
|
+
|
|
542
|
+
async function readPathFastPicks() {
|
|
543
|
+
if (pathFastPicksCache) return pathFastPicksCache;
|
|
544
|
+
try {
|
|
545
|
+
const parsed = JSON.parse(await readFile(fastPicksStorageFile(), "utf8"));
|
|
546
|
+
pathFastPicksCache = normalizePathFastPicks(parsed);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (error?.code !== "ENOENT") console.warn(`failed to read path fast picks: ${sanitizeError(error)}`);
|
|
549
|
+
pathFastPicksCache = [];
|
|
550
|
+
}
|
|
551
|
+
return pathFastPicksCache;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function writePathFastPicks(picks) {
|
|
555
|
+
const normalized = normalizePathFastPicks(picks);
|
|
556
|
+
const storageFile = fastPicksStorageFile();
|
|
557
|
+
await mkdir(path.dirname(storageFile), { recursive: true });
|
|
558
|
+
const tmpFile = `${storageFile}.${process.pid}.${Date.now()}.tmp`;
|
|
559
|
+
await writeFile(tmpFile, `${JSON.stringify({ version: 1, picks: normalized }, null, 2)}\n`, { mode: 0o600 });
|
|
560
|
+
await rename(tmpFile, storageFile);
|
|
561
|
+
pathFastPicksCache = normalized;
|
|
562
|
+
return normalized;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function parseCliScopedModelPatterns() {
|
|
566
|
+
for (let index = 0; index < options.piArgs.length; index++) {
|
|
567
|
+
const arg = options.piArgs[index];
|
|
568
|
+
if (arg === "--models" && options.piArgs[index + 1]) return options.piArgs[index + 1].split(",").map((item) => item.trim()).filter(Boolean);
|
|
569
|
+
if (arg.startsWith("--models=")) return arg.slice("--models=".length).split(",").map((item) => item.trim()).filter(Boolean);
|
|
570
|
+
}
|
|
571
|
+
return undefined;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function readJsonFileIfExists(filePath) {
|
|
575
|
+
try {
|
|
576
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
577
|
+
} catch (error) {
|
|
578
|
+
if (error?.code === "ENOENT") return undefined;
|
|
579
|
+
console.warn(`failed to read ${filePath}: ${sanitizeError(error)}`);
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function configuredScopedModelPatterns(cwd = options.cwd) {
|
|
585
|
+
const cliPatterns = parseCliScopedModelPatterns();
|
|
586
|
+
if (cliPatterns !== undefined) return { patterns: cliPatterns, source: "cli" };
|
|
587
|
+
|
|
588
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR ? path.resolve(expandUserPath(process.env.PI_CODING_AGENT_DIR)) : path.join(homedir(), ".pi", "agent");
|
|
589
|
+
const [globalSettings, projectSettings] = await Promise.all([
|
|
590
|
+
readJsonFileIfExists(path.join(agentDir, "settings.json")),
|
|
591
|
+
readJsonFileIfExists(path.join(cwd, ".pi", "settings.json")),
|
|
592
|
+
]);
|
|
593
|
+
|
|
594
|
+
if (Array.isArray(projectSettings?.enabledModels)) return { patterns: projectSettings.enabledModels, source: "project" };
|
|
595
|
+
if (Array.isArray(globalSettings?.enabledModels)) return { patterns: globalSettings.enabledModels, source: "global" };
|
|
596
|
+
return { patterns: [], source: "none" };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function stripThinkingSuffix(pattern) {
|
|
600
|
+
const text = String(pattern || "").trim();
|
|
601
|
+
const slashIndex = text.indexOf("/");
|
|
602
|
+
const colonIndex = text.lastIndexOf(":");
|
|
603
|
+
if (colonIndex > (slashIndex === -1 ? -1 : slashIndex)) return text.slice(0, colonIndex);
|
|
604
|
+
return text;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function globToRegExp(pattern) {
|
|
608
|
+
const escaped = pattern.replace(/[.+^${}()|\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
609
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function modelMatchesPattern(model, pattern) {
|
|
613
|
+
const clean = stripThinkingSuffix(pattern).toLowerCase();
|
|
614
|
+
if (!clean) return false;
|
|
615
|
+
const full = `${model.provider}/${model.id}`.toLowerCase();
|
|
616
|
+
const id = String(model.id || "").toLowerCase();
|
|
617
|
+
if (/[?*\[]/.test(clean)) return globToRegExp(clean).test(full) || globToRegExp(clean).test(id);
|
|
618
|
+
return full === clean || id === clean || full.includes(clean) || id.includes(clean);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function resolveScopedModelsFromPatterns(patterns, models) {
|
|
622
|
+
const scoped = [];
|
|
623
|
+
const seen = new Set();
|
|
624
|
+
for (const pattern of patterns || []) {
|
|
625
|
+
for (const model of models || []) {
|
|
626
|
+
const key = `${model.provider}/${model.id}`;
|
|
627
|
+
if (seen.has(key) || !modelMatchesPattern(model, pattern)) continue;
|
|
628
|
+
seen.add(key);
|
|
629
|
+
scoped.push(model);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return scoped;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function getScopedModelData(tab) {
|
|
636
|
+
const { patterns, source } = await configuredScopedModelPatterns(tab.cwd);
|
|
637
|
+
if (!patterns.length) return { models: [], patterns, source };
|
|
638
|
+
const response = await tab.rpc.send({ type: "get_available_models" });
|
|
639
|
+
if (response.success === false) throw makeHttpError(400, response.error || "failed to load available models");
|
|
640
|
+
return { models: resolveScopedModelsFromPatterns(patterns, response.data?.models || []), patterns, source };
|
|
641
|
+
}
|
|
642
|
+
|
|
437
643
|
function pathPickerRoots(activeCwd, viewedCwd) {
|
|
438
644
|
const home = process.env.HOME || process.env.USERPROFILE;
|
|
439
645
|
return uniquePathItems([
|
|
@@ -650,7 +856,7 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
|
|
|
650
856
|
function normalizeStaticPath(urlPath) {
|
|
651
857
|
if (urlPath === "/") return "index.html";
|
|
652
858
|
const name = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
|
|
653
|
-
if (!["index.html", "app.js", "styles.css"].includes(name)) return undefined;
|
|
859
|
+
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;
|
|
654
860
|
return name;
|
|
655
861
|
}
|
|
656
862
|
|
|
@@ -795,6 +1001,7 @@ function attachRpcToTab(tab, rpc) {
|
|
|
795
1001
|
tab.rpc = rpc;
|
|
796
1002
|
tab.rpcUnsubscribe = rpc.onEvent((event) => {
|
|
797
1003
|
const scopedEvent = { ...event, tabId: tab.id, tabTitle: tab.title };
|
|
1004
|
+
recordEvent(scopedEvent);
|
|
798
1005
|
for (const client of tab.sseClients) sendSse(client, scopedEvent);
|
|
799
1006
|
});
|
|
800
1007
|
}
|
|
@@ -856,8 +1063,10 @@ async function updateTabCwd(id, cwd) {
|
|
|
856
1063
|
|
|
857
1064
|
const piArgs = buildPiArgsForTab(tab.index, tab.title);
|
|
858
1065
|
const piCommand = await resolvePiCommand(piArgs);
|
|
1066
|
+
const restartingEvent = { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd };
|
|
1067
|
+
recordEvent(restartingEvent);
|
|
859
1068
|
for (const client of tab.sseClients) {
|
|
860
|
-
sendSse(client,
|
|
1069
|
+
sendSse(client, restartingEvent);
|
|
861
1070
|
}
|
|
862
1071
|
|
|
863
1072
|
const oldRpc = tab.rpc;
|
|
@@ -870,19 +1079,134 @@ async function updateTabCwd(id, cwd) {
|
|
|
870
1079
|
attachRpcToTab(tab, rpc);
|
|
871
1080
|
rpc.start();
|
|
872
1081
|
|
|
1082
|
+
const changedEvent = { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid };
|
|
1083
|
+
recordEvent(changedEvent);
|
|
873
1084
|
for (const client of tab.sseClients) {
|
|
874
|
-
sendSse(client,
|
|
1085
|
+
sendSse(client, changedEvent);
|
|
875
1086
|
}
|
|
876
1087
|
return { tab, changed: true };
|
|
877
1088
|
}
|
|
878
1089
|
|
|
1090
|
+
async function restartTabRpc(tab, reason = "reload") {
|
|
1091
|
+
const state = await tab.rpc.send({ type: "get_state" });
|
|
1092
|
+
if (state.success === false) throw makeHttpError(400, state.error || "Unable to read Pi state before reload");
|
|
1093
|
+
if (state.data?.isStreaming) throw makeHttpError(409, "Wait for the current response to finish before reloading.");
|
|
1094
|
+
if (state.data?.isCompacting) throw makeHttpError(409, "Wait for compaction to finish before reloading.");
|
|
1095
|
+
|
|
1096
|
+
const piArgs = buildPiArgsForTab(tab.index, tab.title);
|
|
1097
|
+
if (state.data?.sessionFile && !options.noSession) piArgs.push("--session", state.data.sessionFile);
|
|
1098
|
+
const piCommand = await resolvePiCommand(piArgs);
|
|
1099
|
+
const reloadingEvent = { type: "webui_tab_reloading", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, reason, sessionFile: state.data?.sessionFile };
|
|
1100
|
+
recordEvent(reloadingEvent);
|
|
1101
|
+
for (const client of tab.sseClients) sendSse(client, reloadingEvent);
|
|
1102
|
+
|
|
1103
|
+
const oldRpc = tab.rpc;
|
|
1104
|
+
tab.rpcUnsubscribe?.();
|
|
1105
|
+
tab.rpcUnsubscribe = undefined;
|
|
1106
|
+
oldRpc.stop();
|
|
1107
|
+
|
|
1108
|
+
const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
|
|
1109
|
+
attachRpcToTab(tab, rpc);
|
|
1110
|
+
rpc.start();
|
|
1111
|
+
|
|
1112
|
+
const reloadedEvent = { type: "webui_tab_reloaded", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid, reason, sessionFile: state.data?.sessionFile };
|
|
1113
|
+
recordEvent(reloadedEvent);
|
|
1114
|
+
for (const client of tab.sseClients) sendSse(client, reloadedEvent);
|
|
1115
|
+
return tab;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
async function getCommandData(tab) {
|
|
1119
|
+
const response = await tab.rpc.send({ type: "get_commands" });
|
|
1120
|
+
if (response.success === false) throw makeHttpError(400, response.error || "failed to load commands");
|
|
1121
|
+
return { commands: [...NATIVE_SLASH_COMMANDS, ...(response.data?.commands || [])] };
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function formatSessionOutput(tab, state, stats) {
|
|
1125
|
+
return [
|
|
1126
|
+
`Session: ${state.sessionName || state.sessionId || "unknown"}`,
|
|
1127
|
+
`Tab: ${tab.title}`,
|
|
1128
|
+
`CWD: ${tab.cwd}`,
|
|
1129
|
+
`Model: ${state.model ? `${state.model.provider}/${state.model.id}` : "none"}`,
|
|
1130
|
+
`Thinking: ${state.thinkingLevel || "unknown"}`,
|
|
1131
|
+
`Status: ${state.isStreaming ? "running" : state.isCompacting ? "compacting" : "idle"}`,
|
|
1132
|
+
`Messages: ${state.messageCount ?? "?"}`,
|
|
1133
|
+
`Queue: ${state.pendingMessageCount ?? 0}`,
|
|
1134
|
+
`Session file: ${state.sessionFile || "none"}`,
|
|
1135
|
+
stats ? `Tokens: input ${stats.tokens?.input ?? 0}, output ${stats.tokens?.output ?? 0}, cache read ${stats.tokens?.cacheRead ?? 0}` : undefined,
|
|
1136
|
+
stats?.cost !== undefined ? `Cost: ${stats.cost}` : undefined,
|
|
1137
|
+
].filter(Boolean).join("\n");
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function webuiHotkeysOutput() {
|
|
1141
|
+
return [
|
|
1142
|
+
"Web UI hotkeys:",
|
|
1143
|
+
"Enter: send on desktop; newline on mobile",
|
|
1144
|
+
"Ctrl/Cmd+Enter: send from textarea",
|
|
1145
|
+
"Tab: accept slash-command suggestion",
|
|
1146
|
+
"Arrow up/down: move through slash-command suggestions",
|
|
1147
|
+
"Escape: close actions, tabs, model picker, or mobile drawer",
|
|
1148
|
+
"Mobile: Send button submits; Return inserts a newline",
|
|
1149
|
+
].join("\n");
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function handleNativeSlashCommand(tab, body) {
|
|
1153
|
+
const parsed = parseSlashCommand(body.message);
|
|
1154
|
+
if (!parsed) return undefined;
|
|
1155
|
+
|
|
1156
|
+
switch (parsed.name) {
|
|
1157
|
+
case "reload": {
|
|
1158
|
+
const reloaded = await restartTabRpc(tab, "slash-command");
|
|
1159
|
+
return rpcSuccess("native_slash_command", { command: "reload", tab: tabMeta(reloaded), message: "Reloaded keybindings, extensions, skills, prompts, and themes." });
|
|
1160
|
+
}
|
|
1161
|
+
case "new": {
|
|
1162
|
+
const response = await tab.rpc.send({ type: "new_session" });
|
|
1163
|
+
return response.success === false ? response : rpcSuccess("native_slash_command", { command: "new", message: "Started a new session.", result: response.data });
|
|
1164
|
+
}
|
|
1165
|
+
case "compact": {
|
|
1166
|
+
const response = await tab.rpc.send(parsed.args ? { type: "compact", customInstructions: parsed.args } : { type: "compact" });
|
|
1167
|
+
return response.success === false ? response : rpcSuccess("native_slash_command", { command: "compact", message: "Compaction finished.", result: response.data });
|
|
1168
|
+
}
|
|
1169
|
+
case "name": {
|
|
1170
|
+
if (!parsed.args) throw makeHttpError(400, "Usage: /name <session name>");
|
|
1171
|
+
const response = await tab.rpc.send({ type: "set_session_name", name: parsed.args });
|
|
1172
|
+
return response.success === false ? response : rpcSuccess("native_slash_command", { command: "name", message: `Session name set to: ${parsed.args}` });
|
|
1173
|
+
}
|
|
1174
|
+
case "session": {
|
|
1175
|
+
const [state, stats] = await Promise.all([
|
|
1176
|
+
tab.rpc.send({ type: "get_state" }),
|
|
1177
|
+
tab.rpc.send({ type: "get_session_stats" }).catch((error) => ({ success: false, error: sanitizeError(error) })),
|
|
1178
|
+
]);
|
|
1179
|
+
if (state.success === false) return state;
|
|
1180
|
+
return rpcSuccess("native_slash_command", { command: "session", message: formatSessionOutput(tab, state.data || {}, stats.success === false ? null : stats.data) });
|
|
1181
|
+
}
|
|
1182
|
+
case "copy": {
|
|
1183
|
+
const response = await tab.rpc.send({ type: "get_last_assistant_text" });
|
|
1184
|
+
if (response.success === false) return response;
|
|
1185
|
+
const text = String(response.data?.text || "");
|
|
1186
|
+
if (!text.trim()) throw makeHttpError(400, "No assistant message to copy.");
|
|
1187
|
+
return rpcSuccess("native_slash_command", { command: "copy", message: "Copied the last assistant message.", copyText: text });
|
|
1188
|
+
}
|
|
1189
|
+
case "hotkeys": {
|
|
1190
|
+
return rpcSuccess("native_slash_command", { command: "hotkeys", message: webuiHotkeysOutput() });
|
|
1191
|
+
}
|
|
1192
|
+
case "clone": {
|
|
1193
|
+
const response = await tab.rpc.send({ type: "clone" });
|
|
1194
|
+
return response.success === false ? response : rpcSuccess("native_slash_command", { command: "clone", message: "Cloned the current session.", result: response.data });
|
|
1195
|
+
}
|
|
1196
|
+
default:
|
|
1197
|
+
throw makeHttpError(400, `/${parsed.name} is a native Pi TUI command, but this Web UI cannot run that interactive command yet.`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
879
1201
|
function closeTab(id) {
|
|
880
1202
|
const tab = tabs.get(id);
|
|
881
1203
|
if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
|
|
882
1204
|
if (tabs.size <= 1) throw makeHttpError(400, "Cannot close the last Pi tab");
|
|
883
1205
|
|
|
1206
|
+
const closingEvent = { type: "webui_tab_closing", tabId: tab.id, tabTitle: tab.title };
|
|
1207
|
+
recordEvent(closingEvent);
|
|
884
1208
|
for (const client of tab.sseClients) {
|
|
885
|
-
sendSse(client,
|
|
1209
|
+
sendSse(client, closingEvent);
|
|
886
1210
|
client.end();
|
|
887
1211
|
}
|
|
888
1212
|
tab.sseClients.clear();
|
|
@@ -910,6 +1234,7 @@ function getRequestedTab(req, url, body = {}) {
|
|
|
910
1234
|
return tab;
|
|
911
1235
|
}
|
|
912
1236
|
|
|
1237
|
+
const serverStartedAt = new Date().toISOString();
|
|
913
1238
|
const initialTab = await createTab();
|
|
914
1239
|
let currentHost = options.host;
|
|
915
1240
|
let networkRebindInProgress = false;
|
|
@@ -940,8 +1265,10 @@ function networkStatus() {
|
|
|
940
1265
|
|
|
941
1266
|
function closeSseClientsForRebind(nextHost) {
|
|
942
1267
|
for (const tab of tabs.values()) {
|
|
1268
|
+
const rebindEvent = { type: "webui_network_rebinding", tabId: tab.id, tabTitle: tab.title, host: nextHost, port: options.port };
|
|
1269
|
+
recordEvent(rebindEvent);
|
|
943
1270
|
for (const client of tab.sseClients) {
|
|
944
|
-
sendSse(client,
|
|
1271
|
+
sendSse(client, rebindEvent);
|
|
945
1272
|
client.end();
|
|
946
1273
|
}
|
|
947
1274
|
tab.sseClients.clear();
|
|
@@ -1009,6 +1336,75 @@ async function openToLocalNetwork() {
|
|
|
1009
1336
|
}
|
|
1010
1337
|
}
|
|
1011
1338
|
|
|
1339
|
+
async function safeRpcData(tab, command, timeoutMs = STATUS_RPC_TIMEOUT_MS) {
|
|
1340
|
+
try {
|
|
1341
|
+
const response = await tab.rpc.send(command, timeoutMs);
|
|
1342
|
+
if (response?.success === false) return { ok: false, error: response.error || `${command.type} failed` };
|
|
1343
|
+
return { ok: true, data: response?.data ?? null };
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
return { ok: false, error: sanitizeError(error) };
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function providerList(models) {
|
|
1350
|
+
const providers = new Set();
|
|
1351
|
+
for (const model of Array.isArray(models) ? models : []) {
|
|
1352
|
+
if (model?.provider) providers.add(String(model.provider));
|
|
1353
|
+
}
|
|
1354
|
+
return [...providers].sort();
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
async function tabStatusDetails(tab) {
|
|
1358
|
+
const [stateResult, modelsResult, statsResult, workspaceResult] = await Promise.all([
|
|
1359
|
+
safeRpcData(tab, { type: "get_state" }),
|
|
1360
|
+
safeRpcData(tab, { type: "get_available_models" }),
|
|
1361
|
+
safeRpcData(tab, { type: "get_session_stats" }),
|
|
1362
|
+
getWorkspaceInfo(tab.cwd, tab.rpc.startedAt).then((data) => ({ ok: true, data })).catch((error) => ({ ok: false, error: sanitizeError(error) })),
|
|
1363
|
+
]);
|
|
1364
|
+
const models = modelsResult.ok ? modelsResult.data?.models || [] : [];
|
|
1365
|
+
return {
|
|
1366
|
+
...tabMeta(tab),
|
|
1367
|
+
state: stateResult.ok ? stateResult.data : null,
|
|
1368
|
+
stateError: stateResult.ok ? undefined : stateResult.error,
|
|
1369
|
+
stats: statsResult.ok ? statsResult.data : null,
|
|
1370
|
+
statsError: statsResult.ok ? undefined : statsResult.error,
|
|
1371
|
+
workspace: workspaceResult.ok ? workspaceResult.data : null,
|
|
1372
|
+
workspaceError: workspaceResult.ok ? undefined : workspaceResult.error,
|
|
1373
|
+
models: {
|
|
1374
|
+
count: models.length,
|
|
1375
|
+
providers: providerList(models),
|
|
1376
|
+
error: modelsResult.ok ? undefined : modelsResult.error,
|
|
1377
|
+
},
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
|
|
1382
|
+
const tab = firstTab();
|
|
1383
|
+
const network = networkStatus();
|
|
1384
|
+
const data = {
|
|
1385
|
+
online: true,
|
|
1386
|
+
webuiVersion: packageJson.version,
|
|
1387
|
+
webuiPid: process.pid,
|
|
1388
|
+
startedAt: serverStartedAt,
|
|
1389
|
+
cwd: options.cwd,
|
|
1390
|
+
boundHost: currentHost,
|
|
1391
|
+
port: options.port,
|
|
1392
|
+
pageUrl: network.localUrl,
|
|
1393
|
+
boundUrl: `http://${formatUrlHost(currentHost)}:${options.port}/`,
|
|
1394
|
+
network,
|
|
1395
|
+
piPid: tab?.rpc.child?.pid,
|
|
1396
|
+
piRunning: !!tab?.rpc.child && tab.rpc.child.exitCode === null,
|
|
1397
|
+
tabs: listTabs(),
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
if (detailed) {
|
|
1401
|
+
data.tabs = await Promise.all([...tabs.values()].map((item) => tabStatusDetails(item)));
|
|
1402
|
+
data.events = latestEvents(eventLimit);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
return data;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1012
1408
|
const server = createServer(async (req, res) => {
|
|
1013
1409
|
try {
|
|
1014
1410
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
@@ -1068,20 +1464,28 @@ const server = createServer(async (req, res) => {
|
|
|
1068
1464
|
}
|
|
1069
1465
|
|
|
1070
1466
|
if (url.pathname === "/api/health" && req.method === "GET") {
|
|
1071
|
-
const
|
|
1467
|
+
const status = await webuiStatus();
|
|
1072
1468
|
sendJson(res, 200, {
|
|
1073
1469
|
ok: true,
|
|
1074
|
-
webuiVersion:
|
|
1075
|
-
webuiPid:
|
|
1076
|
-
piPid:
|
|
1077
|
-
piRunning:
|
|
1078
|
-
cwd:
|
|
1079
|
-
network:
|
|
1080
|
-
tabs:
|
|
1470
|
+
webuiVersion: status.webuiVersion,
|
|
1471
|
+
webuiPid: status.webuiPid,
|
|
1472
|
+
piPid: status.piPid,
|
|
1473
|
+
piRunning: status.piRunning,
|
|
1474
|
+
cwd: status.cwd,
|
|
1475
|
+
network: status.network,
|
|
1476
|
+
tabs: status.tabs,
|
|
1081
1477
|
});
|
|
1082
1478
|
return;
|
|
1083
1479
|
}
|
|
1084
1480
|
|
|
1481
|
+
if (url.pathname === "/api/webui-status" && req.method === "GET") {
|
|
1482
|
+
const detailed = ["1", "true", "yes", "detailed"].includes(String(url.searchParams.get("detailed") || "").toLowerCase());
|
|
1483
|
+
const parsedEventLimit = Number.parseInt(url.searchParams.get("events") || "40", 10);
|
|
1484
|
+
const eventLimit = Number.isFinite(parsedEventLimit) ? parsedEventLimit : 40;
|
|
1485
|
+
sendJson(res, 200, { ok: true, data: await webuiStatus({ detailed, eventLimit }) });
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1085
1489
|
if (url.pathname === "/api/network" && req.method === "GET") {
|
|
1086
1490
|
sendJson(res, 200, { ok: true, data: networkStatus() });
|
|
1087
1491
|
return;
|
|
@@ -1122,6 +1526,44 @@ const server = createServer(async (req, res) => {
|
|
|
1122
1526
|
return;
|
|
1123
1527
|
}
|
|
1124
1528
|
|
|
1529
|
+
if (url.pathname === "/api/path-fast-picks" && req.method === "GET") {
|
|
1530
|
+
sendJson(res, 200, { ok: true, data: { picks: await readPathFastPicks() } });
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
if (url.pathname === "/api/path-fast-picks" && req.method === "POST") {
|
|
1535
|
+
const body = await readJsonBody(req);
|
|
1536
|
+
const picks = await writePathFastPicks(body.picks ?? body);
|
|
1537
|
+
sendJson(res, 200, { ok: true, data: { picks } });
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if (url.pathname === "/api/scoped-models" && req.method === "GET") {
|
|
1542
|
+
const tab = getRequestedTab(req, url);
|
|
1543
|
+
sendJson(res, 200, { ok: true, data: await getScopedModelData(tab) });
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
if (url.pathname === "/api/commands" && req.method === "GET") {
|
|
1548
|
+
const tab = getRequestedTab(req, url);
|
|
1549
|
+
sendJson(res, 200, { type: "response", command: "get_commands", success: true, data: await getCommandData(tab) });
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
if (url.pathname === "/api/prompt" && req.method === "POST") {
|
|
1554
|
+
const body = await readJsonBody(req);
|
|
1555
|
+
const tab = getRequestedTab(req, url, body);
|
|
1556
|
+
const nativeResponse = await handleNativeSlashCommand(tab, body);
|
|
1557
|
+
if (nativeResponse) {
|
|
1558
|
+
sendJson(res, nativeResponse.success === false ? 400 : 200, nativeResponse);
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
const command = commandFromPost(url.pathname, body);
|
|
1562
|
+
const response = await tab.rpc.send(command);
|
|
1563
|
+
sendJson(res, response.success === false ? 400 : 200, response);
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1125
1567
|
if (url.pathname.startsWith("/api/git-workflow/")) {
|
|
1126
1568
|
const body = req.method === "POST" ? await readJsonBody(req) : {};
|
|
1127
1569
|
const tab = getRequestedTab(req, url, body);
|