feat: add sproutclaw webui
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
|
||||
|
||||
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">
|
||||
<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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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=()
|
||||
|
||||
Reference in New Issue
Block a user