feat(sproutclaw): modularize webui, extensions, and agent config layout
Some checks failed
CI / build-check-test (push) Has been cancelled
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>
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
* 用法:
|
||||
* /webui on → 启动网页服务(默认端口 19133)
|
||||
* /webui off → 停止网页服务
|
||||
* /webui down → 同 off
|
||||
* /webui reload → 重载网页服务(等同 off + on,保留当前端口)
|
||||
* /webui on 8080 → 指定端口启动
|
||||
*/
|
||||
|
||||
@@ -15,14 +17,25 @@ 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, "dist");
|
||||
const DIST_DIR = join(__dirname, "frontend", "dist");
|
||||
const FRONTEND_DIR = join(__dirname, "frontend");
|
||||
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
let serverPort = 19133;
|
||||
let serverPort = DEFAULT_WEBUI_PORT;
|
||||
let systemdConfig: SystemdServiceConfig | null = null;
|
||||
|
||||
function findTsx(): string {
|
||||
// 从扩展所在目录向上找 repo 根目录
|
||||
@@ -93,6 +106,15 @@ function isProcessAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -151,23 +173,62 @@ function probePort(port: number): Promise<"free" | "running" | "occupied"> {
|
||||
});
|
||||
}
|
||||
|
||||
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 前端...", "info");
|
||||
ctx.ui.notify("正在构建 WebUI 前端(build:web)...", "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");
|
||||
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 = spawnSync("npm", ["run", "build"], { cwd: FRONTEND_DIR, stdio: "inherit" });
|
||||
if (build.status !== 0 || !existsSync(indexHtml)) {
|
||||
ctx.ui.notify("WebUI 前端构建失败", "error");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -176,7 +237,7 @@ async function ensureBuilt(ctx: { ui: { notify: (msg: string, kind: string) => v
|
||||
}
|
||||
|
||||
async function startServer(port: number, ctx: any): Promise<void> {
|
||||
if (serverProcess) {
|
||||
if (!isSystemdManaged() && serverProcess) {
|
||||
ctx.ui.notify(`网页服务已在端口 ${serverPort} 运行`, "error");
|
||||
return;
|
||||
}
|
||||
@@ -199,20 +260,40 @@ async function startServer(port: number, ctx: any): Promise<void> {
|
||||
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, "server.ts");
|
||||
const serverFile = join(__dirname, "backend", "main.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"],
|
||||
@@ -222,7 +303,7 @@ async function startServer(port: number, ctx: any): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
serverProcess.on("exit", () => {
|
||||
serverProcess = null;
|
||||
clearPidFile();
|
||||
});
|
||||
@@ -233,16 +314,19 @@ async function startServer(port: number, ctx: any): Promise<void> {
|
||||
|
||||
try {
|
||||
await waitForStartup(port, serverProcess);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
clearPidFile();
|
||||
serverProcess = null;
|
||||
ctx.ui.notify(`网页服务启动失败: ${err.message}`, "error");
|
||||
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)) {
|
||||
@@ -304,7 +388,26 @@ async function stopByPort(port: number, ctx: any): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function stopServer(ctx: any, port = serverPort || 19133): Promise<void> {
|
||||
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;
|
||||
@@ -338,23 +441,78 @@ async function stopServer(ctx: any, port = serverPort || 19133): Promise<void> {
|
||||
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 停止 Web 聊天界面",
|
||||
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 === "0") {
|
||||
const stopPort = value ? parseInt(value, 10) : serverPort || 19133;
|
||||
await stopServer(ctx, Number.isFinite(stopPort) ? stopPort : 19133);
|
||||
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) : 19133;
|
||||
const port = portInput ? parseInt(portInput, 10) : DEFAULT_WEBUI_PORT;
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
ctx.ui.notify(`无效端口: ${trimmed || "(空)"},使用 19133`, "error");
|
||||
ctx.ui.notify(`无效端口: ${trimmed || "(空)"},使用 ${DEFAULT_WEBUI_PORT}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user