Some checks failed
CI / build-check-test (push) Has been cancelled
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>
929 lines
33 KiB
JavaScript
929 lines
33 KiB
JavaScript
#!/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}`);
|
||
});
|