Files
sproutclaw/.pi/agent/extensions/webui/server.ts
root 0a91cc99d0
Some checks failed
CI / build-check-test (push) Has been cancelled
chore: add sproutclaw git workflow and track local extensions
Document main/upstream-sync/feature branch strategy, add sync/push
scripts, track .pi/agent extensions and webui in git, and disable
startup changelog via showChangelogOnStartup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:38:20 +08:00

929 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.js";
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<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 [];
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<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)) {
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<string>();
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<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 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<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)));
}
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<string, unknown> {
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<string, unknown>[] {
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<Record<string, unknown>>,
pinnedPaths: string[],
): Array<Record<string, unknown>> {
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<Record<string, unknown>>,
pinnedPaths: string[],
): Array<Record<string, unknown>> {
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<Record<string, unknown>>;
const pinnedPaths = prunePinnedSessionPaths(summaries.map((summary) => String(summary.path)));
const sorted = sortSessionSummaries(summaries, pinnedPaths);
return {
sessions: annotatePinnedSessions(sorted, pinnedPaths),
pinnedPaths,
};
}
// ─── 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",
".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<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, 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<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(entries as Record<string, unknown>)) {
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}`);
});