Files
sproutclaw/.pi/agent/extensions/webui/index.ts
root cf5edd6394
Some checks failed
CI / build-check-test (push) Has been cancelled
feat(sproutclaw): modularize webui, extensions, and agent config layout
Restructure local extensions into per-feature directories, split WebUI
into backend modules with slash commands and systemd support, and track
prompts/skills under .pi/agent for portable Gitea deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 16:57:08 +08:00

523 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WebUI 扩展 —— 在浏览器中通过网页与 pi 对话
*
* 安装:
* 将本文件放入 .pi/extensions/webui/index.ts
*
* 用法:
* /webui on → 启动网页服务(默认端口 19133
* /webui off → 停止网页服务
* /webui down → 同 off
* /webui reload → 重载网页服务(等同 off + on保留当前端口
* /webui on 8080 → 指定端口启动
*/
import { spawn, spawnSync, execSync, type ChildProcess } from "node:child_process";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
DEFAULT_WEBUI_PORT,
WEBUI_SERVICE_NAME,
ensureSystemdService,
getSystemdDisabledReason,
isSystemdManaged,
syncSystemdPort,
systemdControl,
type SystemdServiceConfig,
} from "./systemd/service.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PID_FILE = join(__dirname, ".webui.pid");
const DIST_DIR = join(__dirname, "frontend", "dist");
const FRONTEND_DIR = join(__dirname, "frontend");
let serverProcess: ChildProcess | null = null;
let serverPort = DEFAULT_WEBUI_PORT;
let systemdConfig: SystemdServiceConfig | null = null;
function findTsx(): string {
// 从扩展所在目录向上找 repo 根目录
let dir = __dirname;
for (let i = 0; i < 10; i++) {
const candidate = join(dir, "node_modules", ".bin", "tsx");
if (existsSync(candidate)) return candidate;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
// fallback
return join(__dirname, "..", "..", "..", "node_modules", ".bin", "tsx");
}
function findRepoRoot(): string {
let dir = __dirname;
for (let i = 0; i < 12; i++) {
if (
existsSync(join(dir, "package.json")) &&
existsSync(join(dir, "packages", "coding-agent", "src", "cli.ts"))
) {
return dir;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return process.cwd();
}
function getAgentDir(repoRoot: string): string {
return process.env.PI_CODING_AGENT_DIR || join(repoRoot, ".pi", "agent");
}
function readPidFile(): number | null {
if (!existsSync(PID_FILE)) return null;
try {
const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
return Number.isFinite(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
}
function writePidFile(pid: number): void {
try {
writeFileSync(PID_FILE, String(pid), "utf8");
} catch {
// 忽略PID 文件只是辅助信息
}
}
function clearPidFile(): void {
try {
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
} catch {
// 忽略
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForStartupByProbe(port: number, timeoutMs = 10000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if ((await probePort(port)) === "running") return;
await new Promise((r) => setTimeout(r, 200));
}
throw new Error("webui 启动超时");
}
function waitForStartup(port: number, child: ChildProcess): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false;
let timeout: NodeJS.Timeout | null = null;
const finish = (err?: Error) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
if (err) reject(err);
else resolve();
};
const check = () => {
if (child.exitCode !== null || child.signalCode !== null) {
finish(new Error(`webui 子进程已退出code=${child.exitCode ?? "null"}`));
return;
}
const http = require("node:http");
const req = http.get(
{ hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1500 },
(res: any) => {
res.resume();
if (res.statusCode === 200) finish();
else setTimeout(check, 200);
},
);
req.on("error", () => setTimeout(check, 200));
req.on("timeout", () => {
req.destroy();
setTimeout(check, 200);
});
};
timeout = setTimeout(() => finish(new Error("webui 启动超时")), 10000);
setTimeout(check, 300);
});
}
function probePort(port: number): Promise<"free" | "running" | "occupied"> {
return new Promise((resolve) => {
const http = require("node:http");
const req = http.get(
{ hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1200 },
(res: any) => {
res.resume();
resolve(res.statusCode === 200 ? "running" : "occupied");
},
);
req.on("error", () => resolve("free"));
req.on("timeout", () => {
req.destroy();
resolve("free");
});
});
}
function findNpm(): string {
const local = join(dirname(process.execPath), "npm");
if (existsSync(local)) return local;
let dir = __dirname;
for (let i = 0; i < 10; i++) {
const candidate = join(dir, "node_modules", ".bin", "npm");
if (existsSync(candidate)) return candidate;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return "npm";
}
function runNpm(
args: string[],
cwd: string,
): { ok: boolean; status: number | null; error?: string } {
const result = spawnSync(findNpm(), args, {
cwd,
stdio: "inherit",
env: process.env,
});
if (result.error) {
return { ok: false, status: result.status, error: result.error.message };
}
if (result.status !== 0) {
return { ok: false, status: result.status };
}
return { ok: true, status: 0 };
}
async function ensureBuilt(ctx: { ui: { notify: (msg: string, kind: string) => void } }): Promise<boolean> {
const indexHtml = join(DIST_DIR, "index.html");
if (existsSync(indexHtml)) return true;
ctx.ui.notify("正在构建 WebUI 前端build:web...", "info");
if (!existsSync(join(FRONTEND_DIR, "node_modules"))) {
const install = runNpm(["install"], FRONTEND_DIR);
if (!install.ok) {
const detail = install.error ?? (install.status != null ? `exit ${install.status}` : "unknown");
ctx.ui.notify(`WebUI 前端依赖安装失败: ${detail}`, "error");
return false;
}
}
const build = runNpm(["run", "build:web"], FRONTEND_DIR);
if (!build.ok || !existsSync(indexHtml)) {
const details: string[] = [];
if (build.error) details.push(build.error);
if (build.status != null && build.status !== 0) details.push(`exit ${build.status}`);
if (!existsSync(indexHtml)) details.push(`未生成 ${indexHtml}`);
ctx.ui.notify(`WebUI 前端构建失败${details.length ? `: ${details.join("; ")}` : ""}`, "error");
return false;
}
ctx.ui.notify("WebUI 前端构建完成", "info");
return true;
}
async function startServer(port: number, ctx: any): Promise<void> {
if (!isSystemdManaged() && serverProcess) {
ctx.ui.notify(`网页服务已在端口 ${serverPort} 运行`, "error");
return;
}
const oldPid = readPidFile();
if (oldPid && !isProcessAlive(oldPid)) {
clearPidFile();
}
const state = await probePort(port);
if (state === "running") {
serverPort = port;
const pids = findPidsByPort(port);
if (pids.length) writePidFile(pids[0]);
ctx.ui.notify(`网页服务已在端口 ${port} 运行`, "info");
return;
}
if (state === "occupied") {
ctx.ui.notify(`端口 ${port} 已被其他进程占用`, "error");
return;
}
if (!(await ensureBuilt(ctx))) {
return;
}
serverPort = port;
if (isSystemdManaged() && systemdConfig) {
syncSystemdPort(systemdConfig, port);
const result = systemdControl("start");
if (!result.ok) {
ctx.ui.notify(`systemctl start 失败: ${result.output}`, "error");
return;
}
try {
await waitForStartupByProbe(port);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
ctx.ui.notify(`网页服务启动失败: ${message}`, "error");
return;
}
ctx.ui.notify(`网页服务已启动systemd→ http://smallmengya:${port}`, "info");
notifyLanAddresses(ctx, port);
return;
}
const repoRoot = findRepoRoot();
const tsxBin = findTsx();
const serverFile = join(__dirname, "backend", "main.ts");
if (!existsSync(serverFile)) {
ctx.ui.notify(`服务器文件未找到: ${serverFile}`, "error");
return;
}
serverProcess = spawn(tsxBin, [serverFile, "--port", String(port)], {
cwd: repoRoot,
stdio: ["ignore", "inherit", "inherit"],
env: {
...process.env,
PI_CODING_AGENT_DIR: getAgentDir(repoRoot),
},
});
serverProcess.on("exit", () => {
serverProcess = null;
clearPidFile();
});
if (serverProcess.pid) {
writePidFile(serverProcess.pid);
}
try {
await waitForStartup(port, serverProcess);
} catch (err: unknown) {
clearPidFile();
serverProcess = null;
const message = err instanceof Error ? err.message : String(err);
ctx.ui.notify(`网页服务启动失败: ${message}`, "error");
return;
}
ctx.ui.notify(`网页服务已启动 → http://smallmengya:${port}`, "info");
notifyLanAddresses(ctx, port);
}
function notifyLanAddresses(ctx: any, port: number): void {
const { networkInterfaces } = require("node:os");
const nets = networkInterfaces();
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
if (net.family === "IPv4" && !net.internal) {
ctx.ui.notify(` 局域网: http://${net.address}:${port}`, "info");
}
}
}
}
function findPidsByPort(port: number): number[] {
try {
const out = execSync(`ss -tlnp 'sport = :${port}'`, { encoding: "utf8" });
const pids = new Set<number>();
for (const match of out.matchAll(/pid=(\d+)/g)) {
const pid = parseInt(match[1], 10);
if (Number.isFinite(pid) && pid > 0) pids.add(pid);
}
return [...pids];
} catch {
return [];
}
}
function killProcessTree(pid: number): void {
try {
process.kill(pid, "SIGTERM");
} catch {
/* ignore */
}
}
async function waitForPortFree(port: number, timeoutMs = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if ((await probePort(port)) === "free") return true;
await new Promise((r) => setTimeout(r, 200));
}
return (await probePort(port)) === "free";
}
async function stopByPort(port: number, ctx: any): Promise<boolean> {
const pids = findPidsByPort(port);
if (!pids.length) return false;
for (const pid of pids) {
killProcessTree(pid);
}
await waitForPortFree(port);
if ((await probePort(port)) === "free") {
clearPidFile();
ctx.ui.notify(`网页服务已停止(端口 ${port}PID ${pids.join(", ")}`, "info");
return true;
}
ctx.ui.notify(`端口 ${port} 上的网页服务未能完全停止`, "error");
return false;
}
async function stopServer(ctx: any, port = serverPort || DEFAULT_WEBUI_PORT): Promise<void> {
if (isSystemdManaged()) {
const state = await probePort(port);
if (state === "free") {
clearPidFile();
ctx.ui.notify("网页服务未运行", "info");
return;
}
const result = systemdControl("stop");
if (!result.ok) {
ctx.ui.notify(`systemctl stop 失败: ${result.output}`, "error");
return;
}
await waitForPortFree(port);
clearPidFile();
serverPort = 0;
ctx.ui.notify(`网页服务已停止systemd端口 ${port}`, "info");
return;
}
if (serverProcess) {
serverProcess.kill("SIGTERM");
serverProcess = null;
serverPort = 0;
clearPidFile();
ctx.ui.notify("网页服务已停止", "info");
return;
}
const pid = readPidFile();
if (pid && isProcessAlive(pid)) {
try {
process.kill(pid, "SIGTERM");
await waitForPortFree(port);
clearPidFile();
ctx.ui.notify(`网页服务已停止PID ${pid}`, "info");
return;
} catch {
clearPidFile();
}
}
const state = await probePort(port);
if (state === "running" || state === "occupied") {
if (await stopByPort(port, ctx)) return;
ctx.ui.notify(`端口 ${port} 仍被占用,请手动检查进程`, "error");
return;
}
clearPidFile();
ctx.ui.notify("网页服务未运行", "info");
}
async function reloadServer(ctx: any, port: number): Promise<void> {
if (isSystemdManaged() && systemdConfig) {
if (!(await ensureBuilt(ctx))) return;
syncSystemdPort(systemdConfig, port);
const result = systemdControl("restart");
if (!result.ok) {
ctx.ui.notify(`systemctl restart 失败: ${result.output}`, "error");
return;
}
serverPort = port;
try {
await waitForStartupByProbe(port);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
ctx.ui.notify(`网页服务重载失败: ${message}`, "error");
return;
}
ctx.ui.notify(`网页服务已重载systemd端口 ${port}`, "info");
notifyLanAddresses(ctx, port);
return;
}
await stopServer(ctx, port);
await startServer(port, ctx);
}
export default function (pi: ExtensionAPI) {
const repoRoot = findRepoRoot();
systemdConfig = {
extensionDir: __dirname,
repoRoot,
agentDir: getAgentDir(repoRoot),
nodeBin: process.execPath,
tsxCli: join(repoRoot, "node_modules", "tsx", "dist", "cli.mjs"),
};
if (ensureSystemdService(systemdConfig, serverPort || DEFAULT_WEBUI_PORT)) {
console.log(`[webui] 已注册 systemd 保活服务: ${WEBUI_SERVICE_NAME}`);
} else {
const reason = getSystemdDisabledReason();
if (reason) {
console.warn(`[webui] systemd 保活不可用,回退进程内管理: ${reason}`);
}
}
pi.registerCommand("webui", {
description: "通过 /webui on 启动、/webui off|down 停止、/webui reload 重载 Web 聊天界面",
handler: async (args, ctx) => {
const trimmed = args.trim();
const [command = "", value = ""] = trimmed.split(/\s+/, 2);
if (command === "off" || command === "stop" || command === "down" || command === "0") {
const stopPort = value ? parseInt(value, 10) : serverPort || DEFAULT_WEBUI_PORT;
await stopServer(ctx, Number.isFinite(stopPort) ? stopPort : DEFAULT_WEBUI_PORT);
return;
}
if (command === "reload" || command === "restart") {
const portArg = value ? parseInt(value, 10) : NaN;
const reloadPort =
Number.isFinite(portArg) && portArg >= 1 && portArg <= 65535
? portArg
: serverPort || DEFAULT_WEBUI_PORT;
ctx.ui.notify(`正在重载 WebUI端口 ${reloadPort})…`, "info");
await reloadServer(ctx, reloadPort);
return;
}
const portInput = command === "on" ? value : command;
const port = portInput ? parseInt(portInput, 10) : DEFAULT_WEBUI_PORT;
if (isNaN(port) || port < 1 || port > 65535) {
ctx.ui.notify(`无效端口: ${trimmed || "(空)"},使用 ${DEFAULT_WEBUI_PORT}`, "error");
return;
}
await startServer(port, ctx);
},
});
}