feat: add sproutclaw webui

This commit is contained in:
root
2026-05-10 14:47:51 +08:00
parent e25415dd5f
commit c5295773a1
19 changed files with 6299 additions and 69 deletions

View File

@@ -0,0 +1,63 @@
/**
* sproutclaw / mengya 命令安装扩展
*
* 启动后自动在 /usr/local/bin/ 下创建 mengya 和 sproutclaw 命令,
* 方便快速启动 sproutclawcd 到项目目录并执行 ./pi-test.sh
*
* 命令 /install-commands 可随时手动重装。
*/
import { existsSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const SPROUTCLAW_DIR = "/shumengya/project/agent/sproutclaw";
const BIN_DIR = "/usr/local/bin";
const COMMANDS = ["mengya", "sproutclaw"];
/** 创建或修复命令脚本 */
function installCommand(name: string): boolean {
const path = `${BIN_DIR}/${name}`;
const script = `#!/usr/bin/env bash
set -euo pipefail
cd ${SPROUTCLAW_DIR}
exec ./pi-test.sh "$@"
`;
if (existsSync(path)) {
try {
const current = readFileSync(path, "utf-8");
if (current === script) return false;
} catch {
// Rewrite unreadable or invalid command files below.
}
}
writeFileSync(path, script, "utf-8");
chmodSync(path, 0o755);
return true;
}
export default function (pi: ExtensionAPI) {
// 启动时自动安装
for (const name of COMMANDS) {
const created = installCommand(name);
if (created) {
console.log(`[sproutclaw-setup] Created /usr/local/bin/${name}`);
}
}
// 命令:手动重装
pi.registerCommand("install-commands", {
description: "Install mengya and sproutclaw commands to /usr/local/bin",
handler: async (_args, ctx) => {
let count = 0;
for (const name of COMMANDS) {
if (installCommand(name)) count++;
}
if (count > 0) {
ctx.ui.notify(`Installed ${count} command(s): mengya, sproutclaw`, "success");
} else {
ctx.ui.notify("Both commands already exist", "info");
}
},
});
}

View File

@@ -0,0 +1,83 @@
/**
* 启动界面中文翻译扩展
*
* 将 pi 的初始启动界面中的介绍和引导文字翻译为中文,
* 快捷键和命令提示保持原样。
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { keyHint, keyText, rawKeyHint, VERSION } from "@mariozechner/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
if (!ctx.hasUI) return;
ctx.ui.setHeader((_tui, theme) => {
const expandedInstructions = [
keyHint("app.interrupt", "to interrupt"),
keyHint("app.clear", "to clear"),
rawKeyHint(`${keyText("app.clear")} twice`, "to exit"),
keyHint("app.exit", "to exit (empty)"),
keyHint("app.suspend", "to suspend"),
keyHint("tui.editor.deleteToLineEnd" as any, "to delete to end"),
keyHint("app.thinking.cycle", "to cycle thinking level"),
rawKeyHint(
`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`,
"to cycle models",
),
keyHint("app.model.select", "to select model"),
keyHint("app.tools.expand", "to expand tools"),
keyHint("app.thinking.toggle", "to expand thinking"),
keyHint("app.editor.external", "for external editor"),
rawKeyHint("/", "for commands"),
rawKeyHint("!", "to run bash"),
rawKeyHint("!!", "to run bash (no context)"),
keyHint("app.message.followUp", "to queue follow-up"),
keyHint("app.message.dequeue", "to edit all queued messages"),
keyHint("app.clipboard.pasteImage", "to paste image"),
rawKeyHint("drop files", "to attach"),
].join("\n");
const compactInstructions = [
keyHint("app.interrupt", "interrupt"),
rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"),
rawKeyHint("/", "commands"),
rawKeyHint("!", "bash"),
keyHint("app.tools.expand", "more"),
].join(theme.fg("muted", " · "));
const compactOnboarding = theme.fg(
"dim",
`${keyText("app.tools.expand")} 查看完整帮助和已加载资源。`,
);
const onboarding = theme.fg(
"dim",
"Pi 可以解释自身功能并查阅文档。询问它如何使用或扩展 Pi。",
);
let expanded = false;
return {
render(_width: number): string[] {
const logo =
theme.bold(theme.fg("accent", "pi")) + theme.fg("dim", ` v${VERSION}`);
const lines: string[] = [logo];
if (expanded) {
lines.push(expandedInstructions);
lines.push("");
lines.push(onboarding);
} else {
lines.push(compactInstructions);
lines.push(compactOnboarding);
lines.push("");
lines.push(onboarding);
}
return lines;
},
invalidate() {},
setExpanded(v: boolean) {
expanded = v;
},
};
});
});
}

View File

@@ -0,0 +1,262 @@
/**
* 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);
},
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
<title>萌小芽</title>
<link rel="icon" href="logo.png" type="image/png" sizes="any">
<link rel="apple-touch-icon" href="logo.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;family=Noto+Sans+SC:wght@400;500;600;700&amp;family=Source+Code+Pro:wght@400;600;700&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" crossorigin>
<link rel="stylesheet" href="style.css">
<script src="marked.umd.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" crossorigin></script>
</head>
<body>
<div id="app">
<!-- 侧边栏 -->
<aside id="sidebar">
<div class="sidebar-header">
<div class="sidebar-brand">
<img class="sidebar-logo" src="logo.png" width="36" height="36" alt="" decoding="async">
<h2 class="sidebar-title">萌小芽</h2>
</div>
</div>
<!-- 会话列表 -->
<div class="sidebar-section">
<div class="section-header">
<span>历史会话</span>
<button class="section-refresh" onclick="loadSessions()" title="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 4v6h6M23 20v-6h-6"/><path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15"/></svg>
</button>
</div>
<div id="sessionList" class="session-list">
<div class="session-empty">加载中...</div>
</div>
</div>
<div class="sidebar-actions">
<button onclick="newSession()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
新会话
</button>
<button onclick="location.href='settings.html'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 00.3 1.9l.1.1a2 2 0 01-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.9-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 01-4 0v-.1a1.7 1.7 0 00-1-1.5 1.7 1.7 0 00-1.9.3l-.1.1A2 2 0 014.2 17l.1-.1A1.7 1.7 0 004.6 15a1.7 1.7 0 00-1.5-1H3a2 2 0 010-4h.1a1.7 1.7 0 001.5-1 1.7 1.7 0 00-.3-1.9L4.2 7A2 2 0 017 4.2l.1.1A1.7 1.7 0 009 4.6a1.7 1.7 0 001-1.5V3a2 2 0 014 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.9-.3l.1-.1A2 2 0 0119.8 7l-.1.1a1.7 1.7 0 00-.3 1.9 1.7 1.7 0 001.5 1h.1a2 2 0 010 4h-.1a1.7 1.7 0 00-1.5 1z"/></svg>
设置
</button>
</div>
<div class="sidebar-status">
<div class="status-row">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">就绪</span>
</div>
<div class="status-model" id="statusModel"></div>
</div>
</aside>
<!-- 主区域 -->
<main id="main">
<!-- 顶部栏 -->
<header id="header">
<button id="menuBtn" class="menu-btn" onclick="toggleSidebar()" aria-label="切换菜单">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
</button>
<div class="header-info">
<h1 id="headerTitle">聊天</h1>
<span class="header-meta" id="headerMeta"></span>
</div>
<div class="header-trailing">
<div class="model-controls" id="modelControls">
<select id="modelSelect" class="model-select" aria-label="选择模型" title="选择模型">
<option value="">加载模型...</option>
</select>
<select id="thinkingSelect" class="thinking-select" aria-label="选择推理强度" title="推理强度">
<option value="off">off</option>
<option value="minimal">minimal</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
</select>
</div>
<div id="sessionContextBar" class="session-context-bar session-context-in-header" hidden>
<div class="session-context-primary-wrap" id="sessionContextPrimary"></div>
<div id="sessionContextSecondary"></div>
</div>
<button class="header-btn" onclick="abortStream()" id="abortBtn" title="中止回复" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
中止
</button>
</div>
</header>
<!-- 聊天消息 -->
<div id="chat">
<div class="empty-state">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
</div>
<p>发送一条消息开始对话</p>
<p class="empty-hint">按 Enter 发送 · Shift+Enter 换行</p>
</div>
</div>
<!-- 输入区域 -->
<div id="inputArea">
<div class="input-wrapper">
<textarea id="input" rows="1" placeholder="输入消息..." aria-label="消息输入框" enterkeyhint="send" inputmode="text"></textarea>
<button id="sendBtn" type="button" onclick="send()" aria-label="发送">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
</button>
</div>
</div>
</main>
</div>
<div id="sidebarOverlay" class="sidebar-overlay" onclick="toggleSidebar()"></div>
<script src="app.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
<title>设置 - 萌小芽</title>
<link rel="icon" href="logo.png" type="image/png" sizes="any">
<link rel="apple-touch-icon" href="logo.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;family=Noto+Sans+SC:wght@400;500;600;700&amp;family=Source+Code+Pro:wght@400;600;700&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body class="settings-page">
<main class="settings-page-main">
<header class="settings-page-header">
<div class="settings-page-title">
<img class="sidebar-logo" src="logo.png" width="34" height="34" alt="" decoding="async">
<div>
<h1>设置</h1>
<p>萌小芽 Agent</p>
</div>
</div>
<a class="settings-secondary-btn settings-link-btn" href="index.html">返回聊天</a>
</header>
<div class="settings-page-shell">
<div class="settings-layout">
<nav class="settings-sidebar" role="tablist" aria-label="设置分区">
<button type="button" id="tab-prompt" class="settings-nav-btn is-active" role="tab" aria-selected="true" aria-controls="pane-prompt" data-pane="prompt">
<span class="settings-nav-btn-label">系统提示词</span>
</button>
<button type="button" id="tab-skills" class="settings-nav-btn" role="tab" aria-selected="false" aria-controls="pane-skills" data-pane="skills">
<span class="settings-nav-btn-label">Skills</span>
</button>
<button type="button" id="tab-extensions" class="settings-nav-btn" role="tab" aria-selected="false" aria-controls="pane-extensions" data-pane="extensions">
<span class="settings-nav-btn-label">插件扩展</span>
</button>
</nav>
<div class="settings-panes">
<section id="pane-prompt" class="settings-panel settings-pane is-active" role="tabpanel" aria-labelledby="tab-prompt">
<div class="settings-panel-header">
<div>
<h2>系统提示词</h2>
<p id="systemPromptPath"></p>
</div>
<button id="saveSystemPromptBtn" class="settings-primary-btn" type="button">保存</button>
</div>
<textarea id="systemPromptInput" class="settings-textarea" spellcheck="false" aria-label="系统提示词"></textarea>
<div id="settingsStatus" class="settings-status"></div>
</section>
<section id="pane-skills" class="settings-panel settings-pane" role="tabpanel" aria-labelledby="tab-skills">
<div class="settings-panel-header">
<div>
<h2>Skills</h2>
<p id="skillsCount"></p>
</div>
<button id="refreshSettingsBtn" class="settings-secondary-btn" type="button">刷新</button>
</div>
<div id="skillsList" class="skills-list">
<div class="settings-empty">加载中...</div>
</div>
</section>
<section id="pane-extensions" class="settings-panel settings-pane" role="tabpanel" aria-labelledby="tab-extensions">
<div class="settings-panel-header">
<div>
<h2>插件扩展</h2>
<p id="extensionsMeta"></p>
</div>
<button id="refreshExtensionsBtn" class="settings-secondary-btn" type="button">刷新</button>
</div>
<div id="extensionsList" class="extensions-list">
<div class="settings-empty">加载中...</div>
</div>
</section>
</div>
</div>
</div>
</main>
<script src="settings.js"></script>
</body>
</html>

View File

@@ -0,0 +1,177 @@
"use strict";
const systemPromptInput = document.getElementById("systemPromptInput");
const systemPromptPath = document.getElementById("systemPromptPath");
const saveSystemPromptBtn = document.getElementById("saveSystemPromptBtn");
const refreshSettingsBtn = document.getElementById("refreshSettingsBtn");
const settingsStatus = document.getElementById("settingsStatus");
const skillsList = document.getElementById("skillsList");
const skillsCount = document.getElementById("skillsCount");
const extensionsList = document.getElementById("extensionsList");
const extensionsMeta = document.getElementById("extensionsMeta");
const settingsNavBtns = document.querySelectorAll(".settings-nav-btn");
const settingsPanes = document.querySelectorAll(".settings-pane");
const SETTINGS_PANE_KEY = "sproutclaw-settings-pane";
function escHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
function setSettingsStatus(text, kind = "") {
if (!settingsStatus) return;
settingsStatus.textContent = text || "";
settingsStatus.className = `settings-status${kind ? ` ${kind}` : ""}`;
}
function showSettingsPane(paneId) {
const id = ["prompt", "skills", "extensions"].includes(paneId) ? paneId : "prompt";
settingsNavBtns.forEach((btn) => {
const active = btn.dataset.pane === id;
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-selected", active ? "true" : "false");
});
settingsPanes.forEach((pane) => {
const active = pane.id === `pane-${id}`;
pane.classList.toggle("is-active", active);
pane.toggleAttribute("hidden", !active);
});
try {
sessionStorage.setItem(SETTINGS_PANE_KEY, id);
} catch {
/* ignore */
}
}
function initSettingsNav() {
settingsNavBtns.forEach((btn) => {
btn.addEventListener("click", () => showSettingsPane(btn.dataset.pane));
});
let initial = "prompt";
try {
const saved = sessionStorage.getItem(SETTINGS_PANE_KEY);
if (["prompt", "skills", "extensions"].includes(saved)) initial = saved;
} catch {
/* ignore */
}
showSettingsPane(initial);
}
async function loadSettings() {
if (skillsList) skillsList.innerHTML = '<div class="settings-empty">加载中...</div>';
if (extensionsList) extensionsList.innerHTML = '<div class="settings-empty">加载中...</div>';
setSettingsStatus("");
try {
const res = await fetch("/api/settings");
const data = await res.json().catch(() => ({}));
if (!res.ok || data.error) throw new Error(data.error || res.statusText);
if (systemPromptInput) systemPromptInput.value = data.systemPrompt || "";
if (systemPromptPath) systemPromptPath.textContent = data.systemPromptPath || "";
renderSkills(data.skills || []);
renderExtensions(data.extensions || [], data.extensionsPath || "");
} catch (err) {
if (skillsList) skillsList.innerHTML = `<div class="settings-empty">加载失败: ${escHtml(err.message)}</div>`;
if (extensionsList) extensionsList.innerHTML = `<div class="settings-empty">加载失败: ${escHtml(err.message)}</div>`;
setSettingsStatus(`加载设置失败: ${err.message}`, "error");
}
}
function renderSkills(skills) {
if (!skillsList) return;
if (skillsCount) skillsCount.textContent = `${skills.length} 个已加载`;
if (!skills.length) {
skillsList.innerHTML = '<div class="settings-empty">暂无已加载 skills</div>';
return;
}
skillsList.innerHTML = skills.map((skill) => {
const meta = [skill.scope, skill.source].filter(Boolean).join(" · ");
const path = skill.path || "";
return `<div class="skill-item">
<div class="skill-title-row">
<div class="skill-name">${escHtml(skill.name || skill.command || "未命名")}</div>
${meta ? `<div class="skill-meta">${escHtml(meta)}</div>` : ""}
</div>
${skill.description ? `<div class="skill-desc">${escHtml(skill.description)}</div>` : ""}
${path ? `<div class="skill-path">${escHtml(path)}</div>` : ""}
</div>`;
}).join("");
}
function renderExtensions(extensions, extensionsPath) {
if (!extensionsList) return;
if (extensionsMeta) {
extensionsMeta.textContent = extensionsPath
? `${extensions.length} 个已加载 · ${extensionsPath}`
: `${extensions.length} 个已加载`;
}
if (!extensions.length) {
extensionsList.innerHTML = '<div class="settings-empty">暂无已加载插件扩展</div>';
return;
}
extensionsList.innerHTML = extensions.map((extension) => {
const meta = extension.kind || [extension.scope, extension.source].filter(Boolean).join(" · ");
const counts = [
[`命令`, extension.commands?.length || 0],
[`工具`, extension.tools?.length || 0],
[`事件`, extension.handlers?.length || 0],
[`Flags`, extension.flags?.length || 0],
[`快捷键`, extension.shortcuts?.length || 0],
].filter(([, count]) => count > 0);
const hasDetails = ["commands", "tools", "handlers", "flags", "shortcuts"].some((key) => extension[key]?.length);
return `<div class="extension-item">
<div class="extension-title-row">
<div class="extension-name">${escHtml(extension.name || extension.path || "未命名")}</div>
${meta ? `<div class="extension-meta">${escHtml(meta)}</div>` : ""}
</div>
${counts.length ? `<div class="extension-counts">${counts.map(([label, count]) => `<span>${escHtml(label)} ${count}</span>`).join("")}</div>` : ""}
${hasDetails ? `<details class="extension-details">
<summary>查看注册项</summary>
${renderNameList("命令", extension.commands)}
${renderNameList("工具", extension.tools)}
${renderNameList("事件", extension.handlers)}
${renderNameList("Flags", extension.flags)}
${renderNameList("快捷键", extension.shortcuts)}
</details>` : ""}
${extension.location ? `<div class="extension-path">${escHtml(extension.location)}</div>` : ""}
</div>`;
}).join("");
}
function renderNameList(label, items) {
if (!Array.isArray(items) || !items.length) return "";
return `<div class="extension-name-list">
<span>${escHtml(label)}</span>
<div>${items.map((item) => `<code>${escHtml(item)}</code>`).join("")}</div>
</div>`;
}
async function saveSystemPrompt() {
if (!systemPromptInput || !saveSystemPromptBtn) return;
saveSystemPromptBtn.disabled = true;
setSettingsStatus("保存中...");
try {
const res = await fetch("/api/settings/system-prompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ systemPrompt: systemPromptInput.value }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.error) throw new Error(data.error || res.statusText);
if (systemPromptPath && data.systemPromptPath) systemPromptPath.textContent = data.systemPromptPath;
setSettingsStatus("已保存Agent 已重新加载", "ok");
} catch (err) {
setSettingsStatus(`保存失败: ${err.message}`, "error");
} finally {
saveSystemPromptBtn.disabled = false;
}
}
initSettingsNav();
saveSystemPromptBtn?.addEventListener("click", saveSystemPrompt);
refreshSettingsBtn?.addEventListener("click", loadSettings);
document.getElementById("refreshExtensionsBtn")?.addEventListener("click", loadSettings);
loadSettings();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,552 @@
#!/usr/bin/env node
/**
* pi-mono WebUI Server
*
* 被 webui 扩展启动,作为独立子进程运行。
* 内嵌 HTTP 服务 + pi RPC 子进程,提供浏览器聊天界面。
*
* 用法(由扩展自动调用):
* tsx server.ts --port 19133
*
* 前端静态文件位于同目录 public/ 下。
*/
import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { readFileSync, readdirSync, statSync, existsSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname, resolve, basename } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = join(__dirname, "public");
// 从 CWD 确定 repo 根目录(启动时 CWD 为 repo 根目录)
const REPO_ROOT = process.cwd();
const SESSIONS_DIR = join(REPO_ROOT, ".pi", "sessions");
const PROJECT_EXTENSIONS_DIR = join(REPO_ROOT, ".pi", "extensions");
const SYSTEM_PROMPT_FILE = join(REPO_ROOT, ".pi", "agent", "AGENTS.md");
const TSX_BIN = join(REPO_ROOT, "node_modules", ".bin", "tsx");
// ─── 解析 CLI 参数 ─────────────────────────────────────────────────
const args = process.argv.slice(2);
function getArg(flag: string): string | undefined {
const idx = args.indexOf(flag);
return idx !== -1 ? args[idx + 1] : undefined;
}
const PORT = parseInt(getArg("--port") || "19133", 10);
// ─── 启动 pi RPC 子进程 ────────────────────────────────────────────
const piArgs = [
join(REPO_ROOT, "packages", "coding-agent", "src", "cli.ts"),
"--mode", "rpc",
];
console.log(`[webui] 启动 pi RPC: ${TSX_BIN} ${piArgs.join(" ")}`);
const pi = spawn(TSX_BIN, piArgs, {
cwd: REPO_ROOT,
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
PI_CODING_AGENT_DIR: join(REPO_ROOT, ".pi", "agent"),
},
});
pi.stderr.on("data", (data) => process.stderr.write(`[pi] ${data}`));
pi.on("exit", (code) => {
console.log(`[webui] pi 退出, code=${code}`);
process.exit(1);
});
// ─── JSONL 行协议 ──────────────────────────────────────────────────
let buffer = "";
const pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }>();
const sseClients = new Set<any>();
let reqId = 0;
function onLine(line: string) {
if (!line.trim()) return;
try {
const msg = JSON.parse(line);
if (msg.type === "response" && msg.id && pending.has(msg.id)) {
const p = pending.get(msg.id)!;
pending.delete(msg.id);
p.resolve(msg);
return;
}
const data = `data: ${JSON.stringify(msg)}\n\n`;
for (const res of sseClients) res.write(data);
} catch {
// 忽略非 JSON 行
}
}
pi.stdout.on("data", (chunk: Buffer) => {
buffer += chunk.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) onLine(line);
});
function sendCmd(command: Record<string, unknown>): Promise<any> {
return new Promise((resolve, reject) => {
const id = `req_${++reqId}`;
const line = JSON.stringify({ ...command, id }) + "\n";
pending.set(id, { resolve, reject });
pi.stdin.write(line);
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`命令超时: ${command.type}`));
}
}, 60000);
});
}
// ─── 会话文件读取 ──────────────────────────────────────────────────
function listSessionFiles(): string[] {
if (!existsSync(SESSIONS_DIR)) return [];
return readdirSync(SESSIONS_DIR)
.filter((f) => f.endsWith(".jsonl"))
.sort()
.reverse()
.map((f) => join(SESSIONS_DIR, f));
}
function readSessionSummary(filePath: string): Record<string, unknown> | null {
try {
const content = readFileSync(filePath, "utf8");
const lines = content.trim().split("\n");
if (!lines.length) return null;
const header = JSON.parse(lines[0]);
if (header.type !== "session") return null;
let nameFromInfo = "";
let messageCount = 0;
let firstMessage = "";
const stats = statSync(filePath);
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.type === "session_info" && entry.name) {
const n = String(entry.name).trim();
if (n && !isMachineSessionLabel(n, header.id)) {
nameFromInfo = n;
}
}
if (entry.type === "message") {
messageCount++;
if (!firstMessage && entry.message?.role === "user") {
firstMessage = extractPreview(entry.message);
}
}
} catch { /* skip */ }
}
const fromFirstUser = titleFromFirstUserMessage(firstMessage);
let name = nameFromInfo;
if (!name || isMachineSessionLabel(name, header.id)) {
name = fromFirstUser || "";
}
return {
path: filePath,
id: header.id,
name,
created: header.timestamp,
modified: stats.mtime.toISOString(),
messageCount,
firstMessage: firstMessage || "(空)",
};
} catch {
return null;
}
}
function isMachineSessionLabel(text: string, sessionHeaderId: string): boolean {
const t = (text ?? "").trim();
if (!t) return true;
if (sessionHeaderId && t === sessionHeaderId) return true;
if (/^[0-9a-f]{8,}$/i.test(t)) return true;
if (/^[0-9]{10,}$/.test(t)) return true;
return false;
}
/** 侧边栏标题:优先用户首句(遇句号等截断),否则整段截取 */
function titleFromFirstUserMessage(text: string, maxChars = 56): string {
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
if (!cleaned) return "";
const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/);
let candidate =
sentenceMatch && sentenceMatch[1] ? sentenceMatch[1].trim() : cleaned;
if (candidate.length > maxChars) {
candidate = `${candidate.slice(0, maxChars).trimEnd()}`;
}
return candidate;
}
function readSessionMessages(filePath: string): unknown[] {
try {
const content = readFileSync(filePath, "utf8");
return content
.trim()
.split("\n")
.map((l) => { try { const e = JSON.parse(l); return e.type === "message" ? e.message : null; } catch { return null; } })
.filter(Boolean);
} catch {
return [];
}
}
function extractPreview(msg: any): string {
const c = msg.content;
if (!c) return "";
if (typeof c === "string") return c.slice(0, 200);
if (Array.isArray(c)) return c.filter((x: any) => x.type === "text").map((x: any) => x.text).join("").slice(0, 200);
return "";
}
function resolveSessionFile(filePath: string): string {
const resolved = resolve(filePath);
const sessionsRoot = resolve(SESSIONS_DIR);
if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) {
throw new Error("无效会话路径");
}
return resolved;
}
function readSystemPrompt(): string {
if (!existsSync(SYSTEM_PROMPT_FILE)) return "";
return readFileSync(SYSTEM_PROMPT_FILE, "utf8");
}
function writeSystemPrompt(content: string): void {
mkdirSync(dirname(SYSTEM_PROMPT_FILE), { recursive: true });
writeFileSync(SYSTEM_PROMPT_FILE, content, "utf8");
}
function normalizeSkillCommand(command: any): Record<string, unknown> {
const rawName = String(command.name || "");
const sourceInfo = command.sourceInfo || {};
return {
name: rawName.replace(/^skill:/, ""),
command: rawName,
description: command.description || "",
scope: sourceInfo.scope || "",
source: sourceInfo.source || "",
path: sourceInfo.path || "",
};
}
async function listLoadedSkills(): Promise<Record<string, unknown>[]> {
const response = await sendCmd({ type: "get_commands" });
if (!response.success) throw new Error(response.error || "读取 skills 失败");
const commands = response.data?.commands || [];
return commands
.filter((command: any) => command?.source === "skill")
.map(normalizeSkillCommand)
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
}
function normalizeExtension(extension: any): Record<string, unknown> {
const sourceInfo = extension.sourceInfo || {};
const pathValue = String(extension.path || "");
const source = String(sourceInfo.source || "");
const resolvedPath = String(extension.resolvedPath || "");
return {
name: displayExtensionName(pathValue, source),
rawName: extension.name || "",
path: pathValue,
resolvedPath,
scope: sourceInfo.scope || "",
source,
sourcePath: sourceInfo.path || "",
location: displayExtensionLocation(pathValue, resolvedPath),
kind: displayExtensionKind(sourceInfo.scope, source),
commands: extension.commands || [],
tools: extension.tools || [],
flags: extension.flags || [],
shortcuts: extension.shortcuts || [],
handlers: extension.handlers || [],
};
}
function isProjectExtension(extension: any): boolean {
const pathValue = String(extension.path || extension.resolvedPath || "");
if (!pathValue) return false;
const resolved = resolve(pathValue);
const projectExtensionsRoot = resolve(PROJECT_EXTENSIONS_DIR);
return resolved === projectExtensionsRoot || resolved.startsWith(`${projectExtensionsRoot}/`);
}
function displayExtensionName(extensionPath: string, source: string): string {
const sourceMatch = source.match(/^npm:(.+)$/);
if (sourceMatch?.[1]) return sourceMatch[1];
const normalized = extensionPath.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
const file = parts[parts.length - 1] || normalized;
if (/^index\.[tj]s$/i.test(file) && parts.length >= 2) {
return cleanExtensionName(parts[parts.length - 2]);
}
return cleanExtensionName(file);
}
function cleanExtensionName(name: string): string {
return basename(name).replace(/\.[cm]?[tj]s$/i, "");
}
function displayExtensionKind(scope: string, source: string): string {
const scopeText = scope === "project" ? "项目" : scope === "user" ? "用户" : scope || "未知";
if (source.startsWith("npm:")) return `${scopeText} NPM`;
if (source === "auto") return `${scopeText}本地`;
return source ? `${scopeText} · ${source}` : scopeText;
}
function displayExtensionLocation(extensionPath: string, resolvedPath: string): string {
const pathValue = extensionPath || resolvedPath;
if (!pathValue) return "";
return pathValue.replace(REPO_ROOT, ".");
}
async function listLoadedExtensions(): Promise<Record<string, unknown>[]> {
const response = await sendCmd({ type: "get_extensions" });
if (!response.success) throw new Error(response.error || "读取扩展失败");
return (response.data?.extensions || [])
.filter(isProjectExtension)
.map(normalizeExtension)
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
}
// ─── HTTP 服务 ─────────────────────────────────────────────────────
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
};
function serveStatic(urlPath: string, res: any): void {
const file = urlPath === "/" ? "/index.html" : urlPath;
const full = join(PUBLIC_DIR, file);
if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end("Forbidden"); return; }
if (!existsSync(full)) { res.writeHead(404); res.end("Not found"); return; }
const ext = file.match(/\.\w+$/)?.[0] || ".html";
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
res.end(readFileSync(full));
}
function json(res: any, data: unknown, status = 200): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
function readBody(req: any): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (c: Buffer) => (body += c.toString()));
req.on("end", () => { try { resolve(JSON.parse(body)); } catch { reject(new Error("无效 JSON")); } });
req.on("error", reject);
});
}
const server = createServer((req, res) => {
const url = new URL(req.url!, `http://localhost:${PORT}`);
// 静态文件
if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
return serveStatic(url.pathname, res);
}
// POST /api/chat
if (req.method === "POST" && url.pathname === "/api/chat") {
return readBody(req)
.then(({ message }) => sendCmd({ type: "prompt", message }).then(() => json(res, { ok: true })))
.catch((err) => json(res, { error: err.message }, 500));
}
// GET /api/events (SSE)
if (req.method === "GET" && url.pathname === "/api/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.write('data: {"type":"connected"}\n\n');
sseClients.add(res);
req.on("close", () => sseClients.delete(res));
return;
}
// POST /api/messages
if (req.method === "POST" && url.pathname === "/api/messages") {
return sendCmd({ type: "get_messages" })
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/new-session
if (req.method === "POST" && url.pathname === "/api/new-session") {
return sendCmd({ type: "new_session" })
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/abort
if (req.method === "POST" && url.pathname === "/api/abort") {
return sendCmd({ type: "abort" })
.then(() => json(res, { ok: true }))
.catch((err) => json(res, { error: err.message }, 500));
}
// GET /api/models
if (req.method === "GET" && url.pathname === "/api/models") {
return sendCmd({ type: "get_available_models" })
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/model
if (req.method === "POST" && url.pathname === "/api/model") {
return readBody(req)
.then(({ provider, modelId }) =>
sendCmd({ type: "set_model", provider, modelId }).then((r: any) =>
r.success ? json(res, { model: r.data }) : json(res, { error: r.error }, 500),
),
)
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/thinking
if (req.method === "POST" && url.pathname === "/api/thinking") {
return readBody(req)
.then(({ level }) =>
sendCmd({ type: "set_thinking_level", level }).then((r: any) =>
r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500),
),
)
.catch((err) => json(res, { error: err.message }, 500));
}
// GET /api/settings
if (req.method === "GET" && url.pathname === "/api/settings") {
return Promise.all([listLoadedSkills(), listLoadedExtensions()])
.then(([skills, extensions]) => json(res, {
systemPrompt: readSystemPrompt(),
systemPromptPath: SYSTEM_PROMPT_FILE,
extensionsPath: PROJECT_EXTENSIONS_DIR,
skills,
extensions,
}))
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/settings/system-prompt
if (req.method === "POST" && url.pathname === "/api/settings/system-prompt") {
return readBody(req)
.then(async ({ systemPrompt }) => {
if (typeof systemPrompt !== "string") {
throw new Error("systemPrompt 必须是字符串");
}
writeSystemPrompt(systemPrompt);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
json(res, { ok: true, systemPromptPath: SYSTEM_PROMPT_FILE });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// GET /api/sessions
if (req.method === "GET" && url.pathname === "/api/sessions") {
const summaries = listSessionFiles().map(readSessionSummary).filter(Boolean);
return json(res, { sessions: summaries });
}
// POST /api/sessions/history
if (req.method === "POST" && url.pathname === "/api/sessions/history") {
return readBody(req)
.then(({ path: sp }) => {
const messages = readSessionMessages(sp as string);
const summary = readSessionSummary(sp as string);
json(res, { messages, session: summary });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/sessions/delete
if (req.method === "POST" && url.pathname === "/api/sessions/delete") {
return readBody(req)
.then(({ path: sp }) => {
const sessionPath = resolveSessionFile(sp as string);
if (!existsSync(sessionPath)) throw new Error("会话不存在");
unlinkSync(sessionPath);
json(res, { ok: true });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/sessions/load
if (req.method === "POST" && url.pathname === "/api/sessions/load") {
return readBody(req)
.then(async ({ path: sp }) => {
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
if (!sw.success) throw new Error(sw.error);
const mr = await sendCmd({ type: "get_messages" });
if (!mr.success) throw new Error(mr.error);
const summary = readSessionSummary(sp as string);
json(res, { messages: mr.data.messages, session: summary });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/sessions/activate
if (req.method === "POST" && url.pathname === "/api/sessions/activate") {
return readBody(req)
.then(async ({ path: sp }) => {
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
if (!sw.success) throw new Error(sw.error);
json(res, { ok: true });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/sessions/name
if (req.method === "POST" && url.pathname === "/api/sessions/name") {
return readBody(req)
.then(({ name }) => sendCmd({ type: "set_session_name", name }).then(() => json(res, { ok: true })))
.catch((err) => json(res, { error: err.message }, 500));
}
// GET /api/session-state
if (req.method === "GET" && url.pathname === "/api/session-state") {
return sendCmd({ type: "get_state" })
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
}
res.writeHead(404);
res.end("Not found");
});
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
console.error(`[webui] 端口 ${PORT} 已被占用`);
} else {
console.error(`[webui] HTTP 服务启动失败: ${err.message}`);
}
process.exit(1);
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`[webui] HTTP 服务已启动: http://localhost:${PORT}`);
console.log(`[webui] 局域网访问: http://smallmengya:${PORT}`);
});