263 lines
6.6 KiB
TypeScript
263 lines
6.6 KiB
TypeScript
/**
|
||
* 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<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");
|
||
});
|
||
});
|
||
}
|
||
|
||
async function startServer(port: number, ctx: any): Promise<void> {
|
||
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<void> {
|
||
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);
|
||
},
|
||
});
|
||
}
|