#!/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 void; reject: (e: Error) => void }>(); const sseClients = new Set(); 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): Promise { 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 | 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 { 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[]> { 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 { 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[]> { 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 = { ".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> { 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}`); });