Some checks failed
CI / build-check-test (push) Has been cancelled
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>
523 lines
14 KiB
TypeScript
523 lines
14 KiB
TypeScript
/**
|
||
* 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);
|
||
},
|
||
});
|
||
}
|