/** * WebUI 扩展 —— 在浏览器中通过网页与 pi 对话 * * 安装: * 将本文件放入 .pi/extensions/webui/index.ts * * 用法: * /webui on → 启动网页服务(默认端口 19133) * /webui off → 停止网页服务 * /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"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PID_FILE = join(__dirname, ".webui.pid"); const DIST_DIR = join(__dirname, "dist"); const FRONTEND_DIR = join(__dirname, "frontend"); let serverProcess: ChildProcess | null = null; let serverPort = 19133; 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; } } 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 启动超时")), 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"); }); }); } 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 前端...", "info"); if (!existsSync(join(FRONTEND_DIR, "node_modules"))) { const install = spawnSync("npm", ["install"], { cwd: FRONTEND_DIR, stdio: "inherit" }); if (install.status !== 0) { ctx.ui.notify("WebUI 前端依赖安装失败", "error"); return false; } } const build = spawnSync("npm", ["run", "build"], { cwd: FRONTEND_DIR, stdio: "inherit" }); if (build.status !== 0 || !existsSync(indexHtml)) { ctx.ui.notify("WebUI 前端构建失败", "error"); return false; } ctx.ui.notify("WebUI 前端构建完成", "info"); return true; } async function startServer(port: number, ctx: any): Promise { if (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; } const repoRoot = findRepoRoot(); const tsxBin = findTsx(); const serverFile = join(__dirname, "server.ts"); if (!existsSync(serverFile)) { ctx.ui.notify(`服务器文件未找到: ${serverFile}`, "error"); return; } if (!(await ensureBuilt(ctx))) { return; } serverPort = port; 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", (code) => { serverProcess = null; clearPidFile(); }); if (serverProcess.pid) { writePidFile(serverProcess.pid); } try { await waitForStartup(port, serverProcess); } catch (err: any) { clearPidFile(); serverProcess = null; ctx.ui.notify(`网页服务启动失败: ${err.message}`, "error"); return; } ctx.ui.notify(`网页服务已启动 → http://smallmengya:${port}`, "info"); // 显示局域网地址 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(); 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 { 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 || 19133): Promise { 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"); } export default function (pi: ExtensionAPI) { pi.registerCommand("webui", { description: "通过 /webui on 启动、/webui off 停止 Web 聊天界面", handler: async (args, ctx) => { const trimmed = args.trim(); const [command = "", value = ""] = trimmed.split(/\s+/, 2); if (command === "off" || command === "stop" || command === "0") { const stopPort = value ? parseInt(value, 10) : serverPort || 19133; await stopServer(ctx, Number.isFinite(stopPort) ? stopPort : 19133); return; } const portInput = command === "on" ? value : command; const port = portInput ? parseInt(portInput, 10) : 19133; if (isNaN(port) || port < 1 || port > 65535) { ctx.ui.notify(`无效端口: ${trimmed || "(空)"},使用 19133`, "error"); return; } await startServer(port, ctx); }, }); }