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

6
.gitignore vendored
View File

@@ -38,4 +38,10 @@ todo.md
plans/
.pi/hf-sessions/
.pi/hf-sessions-backup/
.pi/agent/
.pi/sessions/
.pi/extensions/webui/.webui.pid
.pi/extensions/webui/.webui.log
tmp/
bun.lock
collect.sh

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}`);
});

108
README.md
View File

@@ -1,77 +1,57 @@
<p align="center">
<a href="https://pi.dev">
<img alt="pi logo" src="https://pi.dev/logo-auto.svg" width="128">
</a>
</p>
<p align="center">
<a href="https://discord.com/invite/3cU7Bz4UPx"><img alt="Discord" src="https://img.shields.io/badge/discord-community-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
</p>
<p align="center">
<a href="https://pi.dev">pi.dev</a> domain graciously donated by
<br /><br />
<a href="https://exe.dev"><img src="packages/coding-agent/docs/images/exy.png" alt="Exy mascot" width="48" /><br />exe.dev</a>
</p>
# sproutclaw
> New issues and PRs from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. See [CONTRIBUTING.md](CONTRIBUTING.md).
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 pi-mono 改造重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
---
## 定位
# Pi Agent Harness Mono Repo
- 服务器运维助手:协助 SSH 登录、服务状态检查、日志分析、配置调整和部署排障。
- 开发协作助手:理解本地项目结构,执行代码修改、测试验证和 Git 工作流。
- Docker 部署助手:按项目目录组织 `docker compose` 服务,关注数据持久化、端口规划和资源限制。
- 内网服务助手:适配 smallmengya、bigmengya、alycd 等内网服务器使用习惯。
- WebUI/TUI 双入口:保留控制台 TUI同时扩展网页前端方便在浏览器里管理会话和模型。
This is the home of the pi agent harness project including our self extensible coding agent.
## 项目结构
* **[@earendil-works/pi-coding-agent](packages/coding-agent)**: Interactive coding agent CLI
* **[@earendil-works/pi-agent-core](packages/agent)**: Agent runtime with tool calling and state management
* **[@earendil-works/pi-ai](packages/ai)**: Unified multi-provider LLM API (OpenAI, Anthropic, Google, …)
| 路径 | 说明 |
| --- | --- |
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体 |
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
| `packages/tui` | 终端 UI 渲染库 |
| `packages/web-ui` | Web UI 组件 |
| `.pi/extensions/webui` | 本地扩展的网页对话入口 |
To learn more about pi:
* [Visit pi.dev](https://pi.dev), the project website with demos
* [Read the documentation](https://pi.dev/docs/latest), but you can also ask the agent to explain itself
## Share your OSS coding agent sessions
If you use pi or other coding agents for open source work, please share your sessions.
Public OSS session data helps improve coding agents with real-world tasks, tool use, failures, and fixes instead of toy benchmarks.
For the full explanation, see [this post on X](https://x.com/badlogicgames/status/2037811643774652911).
To publish sessions, use [`badlogic/pi-share-hf`](https://github.com/badlogic/pi-share-hf). Read its README.md for setup instructions. All you need is a Hugging Face account, the Hugging Face CLI, and `pi-share-hf`.
You can also watch [this video](https://x.com/badlogicgames/status/2041151967695634619), where I show how I publish my `pi-mono` sessions.
I regularly publish my own `pi-mono` work sessions here:
- [badlogicgames/pi-mono on Hugging Face](https://huggingface.co/datasets/badlogicgames/pi-mono)
## All Packages
| Package | Description |
|---------|-------------|
| **[@earendil-works/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) |
| **[@earendil-works/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management |
| **[@earendil-works/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI |
| **[@earendil-works/pi-tui](packages/tui)** | Terminal UI library with differential rendering |
| **[@earendil-works/pi-web-ui](packages/web-ui)** | Web components for AI chat interfaces |
For Slack/chat automation and workflows see [earendil-works/pi-chat](https://github.com/earendil-works/pi-chat).
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.md](AGENTS.md) for project-specific rules (for both humans and agents).
## Development
## 常用命令
```bash
npm install # Install all dependencies
npm run build # Build all packages
npm run check # Lint, format, and type check
./test.sh # Run tests (skips LLM-dependent tests without API keys)
./pi-test.sh # Run pi from sources (can be run from any directory)
npm install
npm run build
npm run check
./test.sh
./pi-test.sh
```
> **Note:** `npm run check` requires `npm run build` to be run first. The web-ui package uses `tsc` which needs compiled `.d.ts` files from dependencies.
运行本地源码版 Agent
```bash
./pi-test.sh
```
启动 WebUI 时,在 TUI 中执行:
```text
/webui on 19133
```
关闭 WebUI
```text
/webui off
```
## 说明
这个仓库是自用分支,默认会围绕树萌芽的服务器环境、部署规范和日常开发习惯进行调整。上游能力来自 pi-mono后续会继续精简、增强并沉淀适合服务器运维开发场景的 Agent 工作流。
## License

View File

@@ -90,7 +90,7 @@ function hasVertexAdcCredentials(): boolean {
function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
if (provider === "github-copilot") {
return ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
return ["COPILOT_GITHUB_TOKEN"];
}
// ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY

View File

@@ -876,6 +876,14 @@ export class AgentSession {
return this.sessionManager.getSessionName();
}
/**
* Monotonic counter emitted on each completed turn ({@link TurnEndEvent.turnIndex}); resets on agent_start.
* Mirrors the value shown next to ✓ Turn N complete-style UI (N equals this counter after idle).
*/
get turnIndex(): number {
return this._turnIndex;
}
/** Scoped models for cycling (from --models flag) */
get scopedModels(): ReadonlyArray<{ model: Model<any>; thinkingLevel?: ThinkingLevel }> {
return this._scopedModels;

View File

@@ -13,7 +13,7 @@ import * as _bundledPiAi from "@earendil-works/pi-ai";
import * as _bundledPiAiOauth from "@earendil-works/pi-ai/oauth";
import type { KeyId } from "@earendil-works/pi-tui";
import * as _bundledPiTui from "@earendil-works/pi-tui";
import { createJiti } from "jiti/static";
import { createJiti } from "jiti";
// Static imports of packages that extensions may use.
// These MUST be static so Bun bundles them into the compiled binary.
// The virtualModules option then makes them available to extensions.

View File

@@ -25,6 +25,7 @@ import { type Theme, theme } from "../interactive/theme/theme.js";
import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js";
import type {
RpcCommand,
RpcExtensionInfo,
RpcExtensionUIRequest,
RpcExtensionUIResponse,
RpcResponse,
@@ -442,10 +443,18 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
autoCompactionEnabled: session.autoCompactionEnabled,
messageCount: session.messages.length,
pendingMessageCount: session.pendingMessageCount,
turnIndex: session.turnIndex,
stats: session.getSessionStats(),
};
return success(id, "get_state", state);
}
case "reload": {
await session.reload();
await rebindSession();
return success(id, "reload");
}
// =================================================================
// Model
// =================================================================
@@ -561,7 +570,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
}
case "switch_session": {
const result = await runtimeHost.switchSession(command.sessionPath);
const result = await runtimeHost.switchSession(command.sessionPath, { cwdOverride: command.cwdOverride });
if (!result.cancelled) {
await rebindSession();
}
@@ -652,6 +661,26 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
return success(id, "get_commands", { commands });
}
case "get_extensions": {
const extensionsResult = session.resourceLoader.getExtensions();
const extensions: RpcExtensionInfo[] = extensionsResult.extensions.map((extension) => {
const name = extension.path.split(/[\\/]/).filter(Boolean).pop() || extension.path;
return {
name,
path: extension.path,
resolvedPath: extension.resolvedPath,
sourceInfo: extension.sourceInfo,
commands: Array.from(extension.commands.keys()).sort(),
tools: Array.from(extension.tools.keys()).sort(),
flags: Array.from(extension.flags.keys()).sort(),
shortcuts: Array.from(extension.shortcuts.keys()).map(String).sort(),
handlers: Array.from(extension.handlers.keys()).sort(),
};
});
return success(id, "get_extensions", { extensions, errors: extensionsResult.errors });
}
default: {
const unknownCommand = command as { type: string };
return error(undefined, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);

View File

@@ -26,6 +26,7 @@ export type RpcCommand =
// State
| { id?: string; type: "get_state" }
| { id?: string; type: "reload" }
// Model
| { id?: string; type: "set_model"; provider: string; modelId: string }
@@ -55,7 +56,7 @@ export type RpcCommand =
// Session
| { id?: string; type: "get_session_stats" }
| { id?: string; type: "export_html"; outputPath?: string }
| { id?: string; type: "switch_session"; sessionPath: string }
| { id?: string; type: "switch_session"; sessionPath: string; cwdOverride?: string }
| { id?: string; type: "fork"; entryId: string }
| { id?: string; type: "clone" }
| { id?: string; type: "get_fork_messages" }
@@ -66,7 +67,8 @@ export type RpcCommand =
| { id?: string; type: "get_messages" }
// Commands (available for invocation via prompt)
| { id?: string; type: "get_commands" };
| { id?: string; type: "get_commands" }
| { id?: string; type: "get_extensions" };
// ============================================================================
// RPC Slash Command (for get_commands response)
@@ -84,6 +86,19 @@ export interface RpcSlashCommand {
sourceInfo: SourceInfo;
}
/** A loaded extension with basic inventory metadata */
export interface RpcExtensionInfo {
name: string;
path: string;
resolvedPath: string;
sourceInfo: SourceInfo;
commands: string[];
tools: string[];
flags: string[];
shortcuts: string[];
handlers: string[];
}
// ============================================================================
// RPC State
// ============================================================================
@@ -101,6 +116,9 @@ export interface RpcSessionState {
autoCompactionEnabled: boolean;
messageCount: number;
pendingMessageCount: number;
/** Mirrors {@link AgentSession.turnIndex} (ticks every turn_end, resets agent_start). */
turnIndex: number;
stats: SessionStats;
}
// ============================================================================
@@ -118,6 +136,7 @@ export type RpcResponse =
// State
| { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
| { id?: string; type: "response"; command: "reload"; success: true }
// Model
| {
@@ -201,6 +220,13 @@ export type RpcResponse =
success: true;
data: { commands: RpcSlashCommand[] };
}
| {
id?: string;
type: "response";
command: "get_extensions";
success: true;
data: { extensions: RpcExtensionInfo[]; errors: Array<{ path: string; error: string }> };
}
// Error response (any command can fail)
| { id?: string; type: "response"; command: string; success: false; error: string };

View File

@@ -3,6 +3,11 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Default to the repository-local pi config so this source checkout uses the
# copied learning config instead of ~/.pi.
export PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$SCRIPT_DIR/.pi/agent}"
export PI_CODING_AGENT_SESSION_DIR="${PI_CODING_AGENT_SESSION_DIR:-$SCRIPT_DIR/.pi/sessions}"
# Check for --no-env flag
NO_ENV=false
ARGS=()