feat: add sproutclaw webui
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -38,4 +38,10 @@ todo.md
|
|||||||
plans/
|
plans/
|
||||||
.pi/hf-sessions/
|
.pi/hf-sessions/
|
||||||
.pi/hf-sessions-backup/
|
.pi/hf-sessions-backup/
|
||||||
|
.pi/agent/
|
||||||
|
.pi/sessions/
|
||||||
|
.pi/extensions/webui/.webui.pid
|
||||||
|
.pi/extensions/webui/.webui.log
|
||||||
|
tmp/
|
||||||
|
bun.lock
|
||||||
collect.sh
|
collect.sh
|
||||||
|
|||||||
63
.pi/extensions/sproutclaw-setup.ts
Normal file
63
.pi/extensions/sproutclaw-setup.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* sproutclaw / mengya 命令安装扩展
|
||||||
|
*
|
||||||
|
* 启动后自动在 /usr/local/bin/ 下创建 mengya 和 sproutclaw 命令,
|
||||||
|
* 方便快速启动 sproutclaw(cd 到项目目录并执行 ./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");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
83
.pi/extensions/startup-chinese.ts
Normal file
83
.pi/extensions/startup-chinese.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
262
.pi/extensions/webui/index.ts
Normal file
262
.pi/extensions/webui/index.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
1115
.pi/extensions/webui/public/app.js
Normal file
1115
.pi/extensions/webui/public/app.js
Normal file
File diff suppressed because it is too large
Load Diff
125
.pi/extensions/webui/public/index.html
Normal file
125
.pi/extensions/webui/public/index.html
Normal 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&family=Noto+Sans+SC:wght@400;500;600;700&family=Source+Code+Pro:wght@400;600;700&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>
|
||||||
BIN
.pi/extensions/webui/public/logo.png
Normal file
BIN
.pi/extensions/webui/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 615 KiB |
2213
.pi/extensions/webui/public/marked.umd.js
Normal file
2213
.pi/extensions/webui/public/marked.umd.js
Normal file
File diff suppressed because it is too large
Load Diff
86
.pi/extensions/webui/public/settings.html
Normal file
86
.pi/extensions/webui/public/settings.html
Normal 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&family=Noto+Sans+SC:wght@400;500;600;700&family=Source+Code+Pro:wght@400;600;700&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>
|
||||||
177
.pi/extensions/webui/public/settings.js
Normal file
177
.pi/extensions/webui/public/settings.js
Normal 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();
|
||||||
1500
.pi/extensions/webui/public/style.css
Normal file
1500
.pi/extensions/webui/public/style.css
Normal file
File diff suppressed because it is too large
Load Diff
552
.pi/extensions/webui/server.ts
Normal file
552
.pi/extensions/webui/server.ts
Normal 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
108
README.md
@@ -1,77 +1,57 @@
|
|||||||
<p align="center">
|
# sproutclaw
|
||||||
<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>
|
|
||||||
|
|
||||||
> 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
|
```bash
|
||||||
npm install # Install all dependencies
|
npm install
|
||||||
npm run build # Build all packages
|
npm run build
|
||||||
npm run check # Lint, format, and type check
|
npm run check
|
||||||
./test.sh # Run tests (skips LLM-dependent tests without API keys)
|
./test.sh
|
||||||
./pi-test.sh # Run pi from sources (can be run from any directory)
|
./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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function hasVertexAdcCredentials(): boolean {
|
|||||||
|
|
||||||
function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
|
function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
|
||||||
if (provider === "github-copilot") {
|
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
|
// ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY
|
||||||
|
|||||||
@@ -876,6 +876,14 @@ export class AgentSession {
|
|||||||
return this.sessionManager.getSessionName();
|
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) */
|
/** Scoped models for cycling (from --models flag) */
|
||||||
get scopedModels(): ReadonlyArray<{ model: Model<any>; thinkingLevel?: ThinkingLevel }> {
|
get scopedModels(): ReadonlyArray<{ model: Model<any>; thinkingLevel?: ThinkingLevel }> {
|
||||||
return this._scopedModels;
|
return this._scopedModels;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import * as _bundledPiAi from "@earendil-works/pi-ai";
|
|||||||
import * as _bundledPiAiOauth from "@earendil-works/pi-ai/oauth";
|
import * as _bundledPiAiOauth from "@earendil-works/pi-ai/oauth";
|
||||||
import type { KeyId } from "@earendil-works/pi-tui";
|
import type { KeyId } from "@earendil-works/pi-tui";
|
||||||
import * as _bundledPiTui 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.
|
// Static imports of packages that extensions may use.
|
||||||
// These MUST be static so Bun bundles them into the compiled binary.
|
// These MUST be static so Bun bundles them into the compiled binary.
|
||||||
// The virtualModules option then makes them available to extensions.
|
// The virtualModules option then makes them available to extensions.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { type Theme, theme } from "../interactive/theme/theme.js";
|
|||||||
import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js";
|
import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js";
|
||||||
import type {
|
import type {
|
||||||
RpcCommand,
|
RpcCommand,
|
||||||
|
RpcExtensionInfo,
|
||||||
RpcExtensionUIRequest,
|
RpcExtensionUIRequest,
|
||||||
RpcExtensionUIResponse,
|
RpcExtensionUIResponse,
|
||||||
RpcResponse,
|
RpcResponse,
|
||||||
@@ -442,10 +443,18 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
|||||||
autoCompactionEnabled: session.autoCompactionEnabled,
|
autoCompactionEnabled: session.autoCompactionEnabled,
|
||||||
messageCount: session.messages.length,
|
messageCount: session.messages.length,
|
||||||
pendingMessageCount: session.pendingMessageCount,
|
pendingMessageCount: session.pendingMessageCount,
|
||||||
|
turnIndex: session.turnIndex,
|
||||||
|
stats: session.getSessionStats(),
|
||||||
};
|
};
|
||||||
return success(id, "get_state", state);
|
return success(id, "get_state", state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "reload": {
|
||||||
|
await session.reload();
|
||||||
|
await rebindSession();
|
||||||
|
return success(id, "reload");
|
||||||
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// Model
|
// Model
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -561,7 +570,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "switch_session": {
|
case "switch_session": {
|
||||||
const result = await runtimeHost.switchSession(command.sessionPath);
|
const result = await runtimeHost.switchSession(command.sessionPath, { cwdOverride: command.cwdOverride });
|
||||||
if (!result.cancelled) {
|
if (!result.cancelled) {
|
||||||
await rebindSession();
|
await rebindSession();
|
||||||
}
|
}
|
||||||
@@ -652,6 +661,26 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
|||||||
return success(id, "get_commands", { commands });
|
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: {
|
default: {
|
||||||
const unknownCommand = command as { type: string };
|
const unknownCommand = command as { type: string };
|
||||||
return error(undefined, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
|
return error(undefined, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type RpcCommand =
|
|||||||
|
|
||||||
// State
|
// State
|
||||||
| { id?: string; type: "get_state" }
|
| { id?: string; type: "get_state" }
|
||||||
|
| { id?: string; type: "reload" }
|
||||||
|
|
||||||
// Model
|
// Model
|
||||||
| { id?: string; type: "set_model"; provider: string; modelId: string }
|
| { id?: string; type: "set_model"; provider: string; modelId: string }
|
||||||
@@ -55,7 +56,7 @@ export type RpcCommand =
|
|||||||
// Session
|
// Session
|
||||||
| { id?: string; type: "get_session_stats" }
|
| { id?: string; type: "get_session_stats" }
|
||||||
| { id?: string; type: "export_html"; outputPath?: string }
|
| { 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: "fork"; entryId: string }
|
||||||
| { id?: string; type: "clone" }
|
| { id?: string; type: "clone" }
|
||||||
| { id?: string; type: "get_fork_messages" }
|
| { id?: string; type: "get_fork_messages" }
|
||||||
@@ -66,7 +67,8 @@ export type RpcCommand =
|
|||||||
| { id?: string; type: "get_messages" }
|
| { id?: string; type: "get_messages" }
|
||||||
|
|
||||||
// Commands (available for invocation via prompt)
|
// 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)
|
// RPC Slash Command (for get_commands response)
|
||||||
@@ -84,6 +86,19 @@ export interface RpcSlashCommand {
|
|||||||
sourceInfo: SourceInfo;
|
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
|
// RPC State
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -101,6 +116,9 @@ export interface RpcSessionState {
|
|||||||
autoCompactionEnabled: boolean;
|
autoCompactionEnabled: boolean;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
pendingMessageCount: 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
|
// State
|
||||||
| { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
|
| { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
|
||||||
|
| { id?: string; type: "response"; command: "reload"; success: true }
|
||||||
|
|
||||||
// Model
|
// Model
|
||||||
| {
|
| {
|
||||||
@@ -201,6 +220,13 @@ export type RpcResponse =
|
|||||||
success: true;
|
success: true;
|
||||||
data: { commands: RpcSlashCommand[] };
|
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)
|
// Error response (any command can fail)
|
||||||
| { id?: string; type: "response"; command: string; success: false; error: string };
|
| { id?: string; type: "response"; command: string; success: false; error: string };
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ set -euo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
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
|
# Check for --no-env flag
|
||||||
NO_ENV=false
|
NO_ENV=false
|
||||||
ARGS=()
|
ARGS=()
|
||||||
|
|||||||
Reference in New Issue
Block a user