/** * 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"); const WEBUI_STARTUP_TIMEOUT_MS = process.platform === "win32" ? 20_000 : 10_000; let serverProcess: ChildProcess | null = null; let serverPort = DEFAULT_WEBUI_PORT; let systemdConfig: SystemdServiceConfig | null = null; function findTsx(): string { // On Linux/Mac, use the tsx shebang script directly 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; } return join(__dirname, "..", "..", "..", "node_modules", ".bin", "tsx"); } function findTsxMjs(): string { // On Windows, spawn node + tsx/dist/cli.mjs directly to avoid the cmd.exe wrapper // that shell:true introduces and which causes process lifecycle / probe timing issues let dir = __dirname; for (let i = 0; i < 10; i++) { const candidate = join(dir, "node_modules", "tsx", "dist", "cli.mjs"); if (existsSync(candidate)) return candidate; const parent = dirname(dir); if (parent === dir) break; dir = parent; } return join(__dirname, "..", "..", "..", "node_modules", "tsx", "dist", "cli.mjs"); } 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 = WEBUI_STARTUP_TIMEOUT_MS): Promise { 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 { 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 启动超时")), WEBUI_STARTUP_TIMEOUT_MS); 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 { // On Windows prefer npm.cmd; the bare 'npm' is a bash script that Windows can't execute const candidates = process.platform === "win32" ? ["npm.cmd", "npm"] : ["npm"]; for (const name of candidates) { const local = join(dirname(process.execPath), name); if (existsSync(local)) return local; } let dir = __dirname; for (let i = 0; i < 10; i++) { for (const name of candidates) { const candidate = join(dir, "node_modules", ".bin", name); if (existsSync(candidate)) return candidate; } const parent = dirname(dir); if (parent === dir) break; dir = parent; } return candidates[0]; } function runNpm( args: string[], cwd: string, ): { ok: boolean; status: number | null; error?: string } { const result = spawnSync(findNpm(), args, { cwd, stdio: "inherit", env: process.env, shell: process.platform === "win32", }); 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 { 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 { 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 serverFile = join(__dirname, "backend", "main.ts"); if (!existsSync(serverFile)) { ctx.ui.notify(`服务器文件未找到: ${serverFile}`, "error"); return; } // On Windows, spawn node + tsx/dist/cli.mjs directly instead of tsx.cmd via shell:true. // The cmd.exe wrapper (shell:true) makes serverProcess point to cmd.exe, not the real // server process, breaking exit-code detection and causing startup probe timeouts. const [spawnCmd, spawnArgs] = process.platform === "win32" ? [process.execPath, [findTsxMjs(), serverFile, "--port", String(port)]] : [findTsx(), [serverFile, "--port", String(port)]]; serverProcess = spawn(spawnCmd, spawnArgs, { 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) { // Kill the orphaned backend process so the port is freed for the next attempt if (serverProcess && serverProcess.exitCode === null) { killProcessTree(serverProcess.pid!); } 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.family === 4) && !net.internal) { ctx.ui.notify(` 局域网: http://${net.address}:${port}`, "info"); } } } } function findPidsByPort(port: number): number[] { try { if (process.platform === "win32") { const out = execSync(`netstat -ano`, { encoding: "utf8" }); const pids = new Set(); for (const line of out.split(/\r?\n/)) { if (!line.includes(`:${port} `)) continue; const match = line.trim().match(/(\d+)\s*$/); if (match) { const pid = parseInt(match[1], 10); if (Number.isFinite(pid) && pid > 0) pids.add(pid); } } return [...pids]; } const out = execSync(`ss -tlnp 'sport = :${port}'`, { encoding: "utf8" }); const pids = new Set(); 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 { if (process.platform === "win32") { spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], { encoding: "utf8" }); } else { process.kill(pid, "SIGTERM"); } } catch { /* ignore */ } } async function waitForPortFree(port: number, timeoutMs = 5000): Promise { 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 { 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 { 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?.pid) { killProcessTree(serverProcess.pid); serverProcess = null; serverPort = 0; clearPidFile(); await waitForPortFree(port); ctx.ui.notify("网页服务已停止", "info"); return; } const pid = readPidFile(); if (pid && isProcessAlive(pid)) { try { killProcessTree(pid); 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 { 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}`); } 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); }, }); }