/** * WebUI 扩展 —— 在浏览器中通过网页与 pi 对话 * * 安装: * 将本文件放入 .pi/extensions/webui/index.ts * * 用法: * /webui on → 启动网页服务(默认端口 19133) * /webui off → 停止网页服务 * /webui on 8080 → 指定端口启动 */ import { spawn, 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"); 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 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 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; ctx.ui.notify(`网页服务已在端口 ${port} 运行`, "info"); return; } if (state === "occupied") { ctx.ui.notify(`端口 ${port} 已被其他进程占用`, "error"); return; } const tsxBin = findTsx(); const serverFile = join(__dirname, "server.ts"); if (!existsSync(serverFile)) { ctx.ui.notify(`服务器文件未找到: ${serverFile}`, "error"); return; } serverPort = port; serverProcess = spawn(tsxBin, [serverFile, "--port", String(port)], { cwd: join(__dirname, "..", "..", ".."), // repo 根目录 stdio: ["ignore", "inherit", "inherit"], env: { ...process.env }, }); 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"); } } } } async function stopServer(ctx: any): Promise { if (!serverProcess) { const pid = readPidFile(); if (!pid) { const state = await probePort(serverPort || 19133); if (state === "running") { ctx.ui.notify( `网页服务正在端口 ${serverPort || 19133} 运行,但当前没有 PID 记录,无法直接停止`, "error", ); return; } ctx.ui.notify("网页服务未运行", "info"); return; } try { if (isProcessAlive(pid)) { process.kill(pid, "SIGTERM"); ctx.ui.notify(`网页服务已停止(PID ${pid})`, "info"); } else { ctx.ui.notify("网页服务未运行,已清理旧 PID", "info"); } clearPidFile(); } catch { clearPidFile(); ctx.ui.notify("网页服务未运行", "info"); } return; } serverProcess.kill("SIGTERM"); serverProcess = null; serverPort = 0; 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") { await stopServer(ctx); 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); }, }); }