#!/usr/bin/env node /** * pi-mono WebUI Server * * 被 webui 扩展启动,作为独立子进程运行。 * 内嵌 HTTP 服务 + pi RPC 子进程,提供浏览器聊天界面。 * * 用法(由扩展自动调用): * tsx server.ts --port 19133 * * 前端静态文件位于同目录 dist/ 下(Vite 构建产物)。 */ import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { createServer } from "node:http"; import { appendFileSync, readFileSync, readdirSync, statSync, existsSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs"; import { join, dirname, resolve, basename } from "node:path"; import { fileURLToPath } from "node:url"; import { closeWebuiDatabase, deleteWebuiConfig, getAllWebuiConfig, getWebuiConfig, initWebuiDatabase, prunePinnedSessionPaths, readWebuiAvatarSettings, removePinnedSessionPath, setSessionPinned, setWebuiConfig, setWebuiConfigMany, writeWebuiAvatarSettings, } from "./db.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PUBLIC_DIR = join(__dirname, "dist"); const PID_FILE = join(__dirname, ".webui.pid"); // 从 CWD 确定 repo 根目录(启动时 CWD 为 repo 根目录) const REPO_ROOT = process.cwd(); const AGENT_DIR = resolve(process.env.PI_CODING_AGENT_DIR || join(REPO_ROOT, ".pi", "agent")); const SESSIONS_DIR = join(AGENT_DIR, "sessions"); const AGENT_EXTENSIONS_DIR = join(AGENT_DIR, "extensions"); const SYSTEM_PROMPT_FILE = join(AGENT_DIR, "AGENTS.md"); const MCP_CACHE_FILE = join(AGENT_DIR, "mcp-cache.json"); const MCP_CONFIG_FILE = join(AGENT_DIR, "mcp.json"); const TSX_BIN = join(REPO_ROOT, "node_modules", ".bin", "tsx"); const PI_CLI_DIST = join(REPO_ROOT, "packages", "coding-agent", "dist", "cli.js"); const PI_CLI_SRC = join(REPO_ROOT, "packages", "coding-agent", "src", "cli.ts"); function resolvePiRpcLaunch(): { command: string; args: string[]; mode: "dist" | "src" } { const rpcArgs = ["--mode", "rpc"]; if (existsSync(PI_CLI_DIST)) { return { command: process.execPath, args: [PI_CLI_DIST, ...rpcArgs], mode: "dist" }; } console.warn(`[webui] 未找到 ${PI_CLI_DIST},回退 tsx 源码启动(较慢)。请运行: npm run build --workspace=@earendil-works/pi-coding-agent`); return { command: TSX_BIN, args: [PI_CLI_SRC, ...rpcArgs], mode: "src" }; } // ─── 解析 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); function writePidFile(): void { try { writeFileSync(PID_FILE, String(process.pid), "utf8"); } catch { /* ignore */ } } function clearPidFile(): void { try { if (existsSync(PID_FILE)) unlinkSync(PID_FILE); } catch { /* ignore */ } } function shutdown(exitCode = 0): never { closeWebuiDatabase(); clearPidFile(); process.exit(exitCode); } const webuiDb = initWebuiDatabase(__dirname); console.log(`[webui] 配置数据库: ${webuiDb.dbPath}`); process.on("SIGTERM", () => shutdown(0)); process.on("SIGINT", () => shutdown(0)); writePidFile(); // ─── 启动 pi RPC 子进程 ──────────────────────────────────────────── const piLaunch = resolvePiRpcLaunch(); console.log(`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${piLaunch.args.join(" ")}`); const pi = spawn(piLaunch.command, piLaunch.args, { cwd: REPO_ROOT, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, PI_CODING_AGENT_DIR: AGENT_DIR, }, }); pi.stderr.on("data", (data) => process.stderr.write(`[pi] ${data}`)); pi.on("exit", (code) => { console.log(`[webui] pi 退出, code=${code}`); shutdown(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 []; const files: string[] = []; const visit = (dir: string) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { visit(fullPath); } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { files.push(fullPath); } } }; visit(SESSIONS_DIR); return files .sort() .reverse(); } 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)) { const text = c.filter((x: any) => x.type === "text").map((x: any) => x.text).join("").slice(0, 200); if (text) return text; const imageCount = c.filter((x: any) => x.type === "image").length; if (imageCount > 0) return `[${imageCount} 张图片]`; } return ""; } const ALLOWED_CHAT_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]); const MAX_CHAT_IMAGES = 8; const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024; function normalizeChatImages(images: unknown): Array<{ type: "image"; data: string; mimeType: string }> | undefined { if (!Array.isArray(images) || images.length === 0) return undefined; const out: Array<{ type: "image"; data: string; mimeType: string }> = []; for (const raw of images.slice(0, MAX_CHAT_IMAGES)) { if (!raw || typeof raw !== "object") continue; const mimeType = String((raw as any).mimeType || ""); const data = String((raw as any).data || ""); if ((raw as any).type !== "image" || !ALLOWED_CHAT_IMAGE_MIME.has(mimeType) || !data) { throw new Error(`不支持的图片类型: ${mimeType || "unknown"}`); } const size = Buffer.from(data, "base64").length; if (size > MAX_CHAT_IMAGE_BYTES) { throw new Error(`图片过大(最大 ${Math.round(MAX_CHAT_IMAGE_BYTES / 1024 / 1024)}MB)`); } out.push({ type: "image", mimeType, data }); } return out.length ? out : undefined; } 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 appendSessionName(filePath: string, name: string): string { const sessionPath = resolveSessionFile(filePath); if (!existsSync(sessionPath)) throw new Error("会话不存在"); const trimmed = name.trim(); if (!trimmed) throw new Error("会话名称不能为空"); const lines = readFileSync(sessionPath, "utf8").trim().split("\n"); const ids = new Set(); let leafId: string | null = null; for (const line of lines) { if (!line.trim()) continue; try { const entry = JSON.parse(line); if (typeof entry.id === "string") { ids.add(entry.id); leafId = entry.id; } } catch { /* skip */ } } if (!leafId) throw new Error("无效会话文件"); let id = randomUUID().slice(0, 8); for (let i = 0; i < 100 && ids.has(id); i++) { id = randomUUID().slice(0, 8); } const entry = { type: "session_info", id, parentId: leafId, timestamp: new Date().toISOString(), name: trimmed, }; appendFileSync(sessionPath, `\n${JSON.stringify(entry)}`, "utf8"); return trimmed; } 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 normalizeAvatarUrl(value: unknown): string { if (typeof value !== "string") return ""; const url = value.trim(); if (!url) return ""; let parsed: URL; try { parsed = new URL(url); } catch { throw new Error(`无效头像链接: ${url}`); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error("头像链接仅支持 http 或 https"); } return parsed.toString(); } function normalizeConfigKey(key: unknown): string { if (typeof key !== "string") throw new Error("配置键必须是字符串"); const trimmed = key.trim(); if (!trimmed) throw new Error("配置键不能为空"); if (trimmed.length > 128) throw new Error("配置键过长"); if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(trimmed)) { throw new Error("配置键格式无效"); } return trimmed; } function normalizeConfigValue(value: unknown): string { if (typeof value !== "string") throw new Error("配置值必须是字符串"); if (value.length > 65536) throw new Error("配置值过长"); return value; } 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 agentExtensionsRoot = resolve(AGENT_EXTENSIONS_DIR); return resolved === agentExtensionsRoot || resolved.startsWith(`${agentExtensionsRoot}/`); } 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))); } function readJsonFile(filePath: string): any | null { if (!existsSync(filePath)) return null; try { return JSON.parse(readFileSync(filePath, "utf8")); } catch { return null; } } function readMcpConfig(): any { const projectConfig = readJsonFile(MCP_CONFIG_FILE); if (projectConfig?.mcpServers && typeof projectConfig.mcpServers === "object") { return projectConfig; } return { mcpServers: {} }; } function normalizeMcpTool(serverName: string, tool: any): Record { const schema = tool?.inputSchema && typeof tool.inputSchema === "object" ? tool.inputSchema : {}; const properties = schema && typeof schema.properties === "object" ? Object.keys(schema.properties) : []; const required = Array.isArray(schema.required) ? schema.required.map(String) : []; return { server: serverName, name: String(tool?.name || ""), description: String(tool?.description || ""), parameters: properties, required, hasSchema: Object.keys(schema).length > 0, }; } function listMcpTools(): Record[] { const config = readMcpConfig(); const cache = readJsonFile(MCP_CACHE_FILE); const configuredServers = config?.mcpServers && typeof config.mcpServers === "object" ? Object.keys(config.mcpServers) : []; const cachedServers = cache?.servers && typeof cache.servers === "object" ? cache.servers : {}; const serverNames = Array.from(new Set([...configuredServers, ...Object.keys(cachedServers)])).sort(); return serverNames.map((serverName) => { const entry = cachedServers[serverName] || {}; const tools = Array.isArray(entry.tools) ? entry.tools.map((tool: any) => normalizeMcpTool(serverName, tool)).filter((tool: any) => tool.name) : []; const resources = Array.isArray(entry.resources) ? entry.resources : []; return { name: serverName, configured: configuredServers.includes(serverName), cached: Boolean(cachedServers[serverName]), toolCount: tools.length, resourceCount: resources.length, cachedAt: entry.cachedAt ? new Date(entry.cachedAt).toISOString() : "", tools: tools.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name))), }; }); } function sortSessionSummaries( summaries: Array>, pinnedPaths: string[], ): Array> { const pinnedOrder = new Map(pinnedPaths.map((path, index) => [path, index])); return [...summaries].sort((a, b) => { const aPath = String(a.path); const bPath = String(b.path); const aPin = pinnedOrder.get(aPath); const bPin = pinnedOrder.get(bPath); if (aPin !== undefined && bPin !== undefined) return aPin - bPin; if (aPin !== undefined) return -1; if (bPin !== undefined) return 1; return new Date(String(b.modified || b.created || 0)).getTime() - new Date(String(a.modified || a.created || 0)).getTime(); }); } function annotatePinnedSessions( summaries: Array>, pinnedPaths: string[], ): Array> { const pinnedSet = new Set(pinnedPaths); return summaries.map((summary) => ({ ...summary, pinned: pinnedSet.has(String(summary.path)), })); } function buildSessionListResponse() { const summaries = listSessionFiles() .map(readSessionSummary) .filter(Boolean) as Array>; const pinnedPaths = prunePinnedSessionPaths(summaries.map((summary) => String(summary.path))); const sorted = sortSessionSummaries(summaries, pinnedPaths); return { sessions: annotatePinnedSessions(sorted, pinnedPaths), pinnedPaths, }; } // ─── 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", ".woff2": "font/woff2", ".woff": "font/woff", ".ico": "image/x-icon", ".webmanifest": "application/manifest+json", ".json": "application/json", }; function serveStatic(urlPath: string, res: any, spaFallback = false): 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)) { if (spaFallback && !urlPath.includes(".")) { return serveStatic("/index.html", res, false); } 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, true); } // POST /api/chat if (req.method === "POST" && url.pathname === "/api/chat") { return readBody(req) .then(({ message, images }) => { const msg = typeof message === "string" ? message : ""; const imgs = normalizeChatImages(images); if (!msg.trim() && !imgs?.length) throw new Error("消息不能为空"); if (imgs?.length) console.log(`[webui] chat: ${imgs.length} image(s), message=${msg.length} chars`); return sendCmd({ type: "prompt", message: msg, images: imgs }).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/avatars if (req.method === "GET" && url.pathname === "/api/avatars") { return json(res, readWebuiAvatarSettings()); } // GET /api/webui/config if (req.method === "GET" && url.pathname === "/api/webui/config") { const key = url.searchParams.get("key"); if (key) { const normalized = normalizeConfigKey(key); return json(res, { key: normalized, value: getWebuiConfig(normalized) }); } return json(res, { config: getAllWebuiConfig(), dbPath: webuiDb.dbPath }); } // POST /api/webui/config if (req.method === "POST" && url.pathname === "/api/webui/config") { return readBody(req) .then(({ key, value, entries }) => { if (entries && typeof entries === "object" && !Array.isArray(entries)) { const normalized: Record = {}; for (const [rawKey, rawValue] of Object.entries(entries as Record)) { normalized[normalizeConfigKey(rawKey)] = normalizeConfigValue(rawValue); } setWebuiConfigMany(normalized); return json(res, { ok: true, config: getAllWebuiConfig() }); } if (typeof key !== "string") throw new Error("缺少配置键 key"); const normalizedKey = normalizeConfigKey(key); const normalizedValue = normalizeConfigValue(value); setWebuiConfig(normalizedKey, normalizedValue); return json(res, { ok: true, key: normalizedKey, value: normalizedValue }); }) .catch((err) => json(res, { error: err.message }, 500)); } // POST /api/webui/config/delete if (req.method === "POST" && url.pathname === "/api/webui/config/delete") { return readBody(req) .then(({ key }) => { const normalized = normalizeConfigKey(key); const deleted = deleteWebuiConfig(normalized); json(res, { ok: true, deleted, key: normalized }); }) .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, { ...readWebuiAvatarSettings(), webuiDbPath: webuiDb.dbPath, systemPrompt: readSystemPrompt(), systemPromptPath: SYSTEM_PROMPT_FILE, extensionsPath: AGENT_EXTENSIONS_DIR, mcpCachePath: MCP_CACHE_FILE, mcpConfigPath: MCP_CONFIG_FILE, skills, extensions, mcpTools: listMcpTools(), })) .catch((err) => json(res, { error: err.message }, 500)); } // POST /api/settings/reload if (req.method === "POST" && url.pathname === "/api/settings/reload") { return sendCmd({ type: "reload" }) .then((r: any) => (r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500))) .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)); } // POST /api/settings/avatars if (req.method === "POST" && url.pathname === "/api/settings/avatars") { return readBody(req) .then(({ userAvatarUrl, agentAvatarUrl }) => { const settings = { userAvatarUrl: normalizeAvatarUrl(userAvatarUrl), agentAvatarUrl: normalizeAvatarUrl(agentAvatarUrl), }; writeWebuiAvatarSettings(settings); json(res, { ok: true, ...settings }); }) .catch((err) => json(res, { error: err.message }, 500)); } // GET /api/sessions if (req.method === "GET" && url.pathname === "/api/sessions") { return json(res, buildSessionListResponse()); } // 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); removePinnedSessionPath(sessionPath); json(res, { ok: true }); }) .catch((err) => json(res, { error: err.message }, 500)); } // POST /api/sessions/pin if (req.method === "POST" && url.pathname === "/api/sessions/pin") { return readBody(req) .then(({ path: sp, pinned }) => { const sessionPath = resolveSessionFile(sp as string); if (!existsSync(sessionPath)) throw new Error("会话不存在"); const pinnedPaths = setSessionPinned(sessionPath, Boolean(pinned)); json(res, { ok: true, path: sessionPath, pinned: Boolean(pinned), pinnedPaths }); }) .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(async ({ path: sp, name }) => { if (typeof sp !== "string" || !sp.trim()) throw new Error("无效会话路径"); const savedName = appendSessionName(sp, typeof name === "string" ? name : ""); const sessionPath = resolveSessionFile(sp); const state = await sendCmd({ type: "get_state" }); if (state.success && state.data?.sessionFile === sessionPath) { const rename = await sendCmd({ type: "set_session_name", name: savedName }); if (!rename.success) throw new Error(rename.error || "同步当前会话名称失败"); } json(res, { ok: true, name: savedName }); }) .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}`); });