553 lines
19 KiB
JavaScript
553 lines
19 KiB
JavaScript
#!/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}`);
|
|
});
|