feat(sproutclaw): modularize webui, extensions, and agent config layout
Some checks failed
CI / build-check-test (push) Has been cancelled

Restructure local extensions into per-feature directories, split WebUI
into backend modules with slash commands and systemd support, and track
prompts/skills under .pi/agent for portable Gitea deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-06-10 16:57:08 +08:00
parent 11c3a3a399
commit cf5edd6394
132 changed files with 9288 additions and 1971 deletions

View File

@@ -0,0 +1,9 @@
export function parsePort(argv: string[] = process.argv.slice(2)): number {
const idx = argv.indexOf("--port");
const raw = idx !== -1 ? argv[idx + 1] : "19133";
const port = parseInt(raw || "19133", 10);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
throw new Error(`无效端口: ${raw}`);
}
return port;
}

View File

@@ -0,0 +1,56 @@
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const configDir = dirname(fileURLToPath(import.meta.url));
const backendDir = dirname(configDir);
const extensionRoot = resolve(backendDir, "..");
export interface WebUiPaths {
extensionRoot: string;
repoRoot: string;
agentDir: string;
publicDir: string;
pidFile: string;
sessionsDir: string;
agentExtensionsDir: string;
agentNpmNodeModules: string;
agentSettingsFile: string;
systemPromptFile: string;
modelsConfigFile: string;
mcpCacheFile: string;
mcpConfigFile: string;
piCliDist: string;
}
export function createWebUiPaths(): WebUiPaths {
const repoRoot = process.cwd();
const agentDir = resolve(process.env.PI_CODING_AGENT_DIR || join(repoRoot, ".pi", "agent"));
return {
extensionRoot,
repoRoot,
agentDir,
publicDir: join(extensionRoot, "frontend", "dist"),
pidFile: join(extensionRoot, ".webui.pid"),
sessionsDir: join(agentDir, "sessions"),
agentExtensionsDir: join(agentDir, "extensions"),
agentNpmNodeModules: join(agentDir, "npm", "node_modules"),
agentSettingsFile: join(agentDir, "settings.json"),
systemPromptFile: join(agentDir, "AGENTS.md"),
modelsConfigFile: join(agentDir, "models.json"),
mcpCacheFile: join(agentDir, "mcp-cache.json"),
mcpConfigFile: join(agentDir, "mcp.json"),
piCliDist: join(repoRoot, "packages", "coding-agent", "dist", "cli.js"),
};
}
export function resolvePiRpcLaunch(paths: WebUiPaths): { command: string; args: string[]; mode: "dist" } {
const rpcArgs = ["--mode", "rpc"];
if (!existsSync(paths.piCliDist)) {
throw new Error(
`[webui] 构建版 sproutclaw 未找到: ${paths.piCliDist}。请先运行: sproutclaw build`,
);
}
return { command: process.execPath, args: [paths.piCliDist, ...rpcArgs], mode: "dist" };
}

View File

@@ -0,0 +1,224 @@
/**
* WebUI 专有 SQLite 配置存储
*
* 数据库文件位于扩展目录 data/webui.db与 pi-Agent 配置完全隔离。
*/
import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
const SCHEMA_VERSION = 1;
export interface WebuiDbInfo {
dataDir: string;
dbPath: string;
}
export interface WebuiAvatarSettings {
userAvatarUrl: string;
agentAvatarUrl: string;
}
let db: DatabaseSync | null = null;
function requireDb(): DatabaseSync {
if (!db?.isOpen) {
throw new Error("WebUI 数据库未初始化");
}
return db;
}
function getMeta(key: string): string | null {
const row = requireDb()
.prepare("SELECT value FROM webui_meta WHERE key = ?")
.get(key) as { value: string } | undefined;
return row?.value ?? null;
}
function setMeta(key: string, value: string): void {
requireDb()
.prepare(
`INSERT INTO webui_meta (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
)
.run(key, value);
}
function migrateLegacyJsonIfNeeded(extensionDir: string): void {
if (getMeta("legacy_json_migrated") === "1") return;
const legacyFile = join(extensionDir, "webui-settings.json");
if (!existsSync(legacyFile)) {
setMeta("legacy_json_migrated", "1");
return;
}
try {
const raw = JSON.parse(readFileSync(legacyFile, "utf8")) as Partial<WebuiAvatarSettings>;
if (typeof raw.userAvatarUrl === "string" && raw.userAvatarUrl && !getWebuiConfig("userAvatarUrl")) {
setWebuiConfig("userAvatarUrl", raw.userAvatarUrl);
}
if (typeof raw.agentAvatarUrl === "string" && raw.agentAvatarUrl && !getWebuiConfig("agentAvatarUrl")) {
setWebuiConfig("agentAvatarUrl", raw.agentAvatarUrl);
}
const migratedPath = `${legacyFile}.migrated`;
if (!existsSync(migratedPath)) {
renameSync(legacyFile, migratedPath);
}
console.log("[webui] 已从 webui-settings.json 迁移配置到 SQLite");
} catch (err) {
console.warn("[webui] 迁移 webui-settings.json 失败:", err);
}
setMeta("legacy_json_migrated", "1");
}
export function initWebuiDatabase(extensionDir: string): WebuiDbInfo {
const dataDir = join(extensionDir, "data");
const dbPath = join(dataDir, "webui.db");
mkdirSync(dataDir, { recursive: true });
db = new DatabaseSync(dbPath);
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS webui_meta (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS webui_config (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
if (!getMeta("schema_version")) {
setMeta("schema_version", String(SCHEMA_VERSION));
}
migrateLegacyJsonIfNeeded(extensionDir);
return { dataDir, dbPath };
}
export function getWebuiConfig(key: string): string | null {
const row = requireDb()
.prepare("SELECT value FROM webui_config WHERE key = ?")
.get(key) as { value: string } | undefined;
return row?.value ?? null;
}
export function setWebuiConfig(key: string, value: string): void {
const now = new Date().toISOString();
requireDb()
.prepare(
`INSERT INTO webui_config (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
.run(key, value, now);
}
export function setWebuiConfigMany(entries: Record<string, string>): void {
const stmt = requireDb().prepare(
`INSERT INTO webui_config (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
);
const now = new Date().toISOString();
for (const [key, value] of Object.entries(entries)) {
stmt.run(key, value, now);
}
}
export function getAllWebuiConfig(): Record<string, string> {
const rows = requireDb()
.prepare("SELECT key, value FROM webui_config ORDER BY key")
.all() as Array<{ key: string; value: string }>;
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}
export function deleteWebuiConfig(key: string): boolean {
const result = requireDb().prepare("DELETE FROM webui_config WHERE key = ?").run(key);
return result.changes > 0;
}
export function readWebuiAvatarSettings(): WebuiAvatarSettings {
return {
userAvatarUrl: getWebuiConfig("userAvatarUrl") ?? "",
agentAvatarUrl: getWebuiConfig("agentAvatarUrl") ?? "",
};
}
export function writeWebuiAvatarSettings(settings: WebuiAvatarSettings): void {
setWebuiConfigMany({
userAvatarUrl: settings.userAvatarUrl,
agentAvatarUrl: settings.agentAvatarUrl,
});
}
export function closeWebuiDatabase(): void {
if (db?.isOpen) {
db.close();
db = null;
}
}
export function getWebuiDatabasePath(): string | null {
return db?.location() ?? null;
}
const PINNED_SESSIONS_CONFIG_KEY = "pinnedSessionPaths";
export function readPinnedSessionPaths(): string[] {
const raw = getWebuiConfig(PINNED_SESSIONS_CONFIG_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
} catch {
return [];
}
}
function writePinnedSessionPaths(paths: string[]): void {
const unique: string[] = [];
for (const path of paths) {
if (!unique.includes(path)) unique.push(path);
}
setWebuiConfig(PINNED_SESSIONS_CONFIG_KEY, JSON.stringify(unique));
}
export function prunePinnedSessionPaths(validPaths: Iterable<string>): string[] {
const valid = new Set(validPaths);
const current = readPinnedSessionPaths();
const pruned = current.filter((path) => valid.has(path));
if (pruned.length !== current.length) {
writePinnedSessionPaths(pruned);
}
return pruned;
}
export function pinSessionPath(path: string): string[] {
const current = readPinnedSessionPaths();
if (current.includes(path)) return current;
writePinnedSessionPaths([path, ...current]);
return readPinnedSessionPaths();
}
export function unpinSessionPath(path: string): string[] {
const next = readPinnedSessionPaths().filter((entry) => entry !== path);
writePinnedSessionPaths(next);
return next;
}
export function removePinnedSessionPath(path: string): void {
if (readPinnedSessionPaths().includes(path)) {
unpinSessionPath(path);
}
}
export function setSessionPinned(path: string, pinned: boolean): string[] {
return pinned ? pinSessionPath(path) : unpinSessionPath(path);
}

View File

@@ -0,0 +1,16 @@
import type { IncomingMessage, ServerResponse } from "node:http";
/** CORS for desktop / cross-origin clients (e.g. frontend/dist-desketop loading local static files). */
export function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
const origin = typeof req.headers.origin === "string" ? req.headers.origin : "";
res.setHeader("Access-Control-Allow-Origin", origin || "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "86400");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return true;
}
return false;
}

View File

@@ -0,0 +1,23 @@
import type { IncomingMessage, ServerResponse } from "node:http";
export function json(res: ServerResponse, data: unknown, status = 200): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
export function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on("end", () => {
try {
resolve(JSON.parse(body));
} catch {
reject(new Error("无效 JSON"));
}
});
req.on("error", reject);
});
}

View File

@@ -0,0 +1,36 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { applyCorsHeaders } from "./cors.ts";
import { serveStatic } from "./static.ts";
import { handleChatRoute } from "../routes/chat.ts";
import { handleCommandsRoute } from "../routes/commands.ts";
import { handleModelsRoute } from "../routes/models.ts";
import { handleSessionsRoute } from "../routes/sessions.ts";
import { handleSettingsRoute } from "../routes/settings.ts";
import { handleWebuiConfigRoute } from "../routes/webui-config.ts";
import type { WebUiContext } from "../types/context.ts";
export function createWebUiServer(ctx: WebUiContext) {
return createServer((req: IncomingMessage, res: ServerResponse) => {
const url = new URL(req.url!, `http://localhost:${ctx.config.port}`);
const pathname = url.pathname;
if (pathname.startsWith("/api/") && applyCorsHeaders(req, res)) {
return;
}
if (req.method === "GET" && !pathname.startsWith("/api/")) {
serveStatic(ctx.config.paths.publicDir, pathname, req, res, true);
return;
}
if (handleChatRoute(req, res, ctx, pathname)) return;
if (handleSessionsRoute(req, res, ctx, pathname)) return;
if (handleModelsRoute(req, res, ctx, pathname)) return;
if (handleWebuiConfigRoute(req, res, ctx, pathname, url)) return;
if (handleSettingsRoute(req, res, ctx, pathname)) return;
if (handleCommandsRoute(req, res, ctx, pathname)) return;
res.writeHead(404);
res.end("Not found");
});
}

View File

@@ -0,0 +1,83 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import type { ServerResponse, IncomingMessage } from "node: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",
};
const GZIPABLE = new Set([".html", ".js", ".css", ".json", ".webmanifest"]);
// Vite build outputs hashed filenames like index-DeT2iZAf.js
const HASHED_FILE_RE = /-[A-Za-z0-9_-]{6,}\.\w+$/;
function getCacheControl(file: string): string | undefined {
if (file === "/index.html") {
return "no-cache";
}
if (HASHED_FILE_RE.test(file) || file.endsWith(".woff2") || file.endsWith(".woff")) {
return "public, max-age=31536000, immutable";
}
return undefined;
}
export function serveStatic(
publicDir: string,
urlPath: string,
req: IncomingMessage,
res: ServerResponse,
spaFallback = false,
): void {
const file = urlPath === "/" ? "/index.html" : urlPath;
const full = join(publicDir, file);
if (!full.startsWith(publicDir)) {
res.writeHead(403);
res.end("Forbidden");
return;
}
if (!existsSync(full)) {
if (spaFallback && !urlPath.includes(".")) {
serveStatic(publicDir, "/index.html", req, res, false);
return;
}
res.writeHead(404);
res.end("Not found");
return;
}
const ext = file.match(/\.\w+$/)?.[0] || ".html";
const mimeType = MIME[ext] || "application/octet-stream";
const content = readFileSync(full);
const headers: Record<string, string> = {
"Content-Type": mimeType,
};
const cacheControl = getCacheControl(file);
if (cacheControl) {
headers["Cache-Control"] = cacheControl;
}
const acceptEncoding = req.headers["accept-encoding"] || "";
const shouldGzip = GZIPABLE.has(ext) && acceptEncoding.includes("gzip");
if (shouldGzip) {
const compressed = gzipSync(content);
headers["Content-Encoding"] = "gzip";
res.writeHead(200, headers);
res.end(compressed);
return;
}
res.writeHead(200, headers);
res.end(content);
}

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* pi-mono WebUI Server
*
* 被 webui 扩展启动,作为独立子进程运行。
* 内嵌 HTTP 服务 + pi RPC 子进程,提供浏览器聊天界面。
*
* 用法(由扩展自动调用):
* tsx backend/main.ts --port 19133
*
* 前端静态文件位于 frontend/dist/ 下Vite 构建产物)。
*/
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
import { parsePort } from "./config/cli.ts";
import { createWebUiPaths } from "./config/paths.ts";
import { closeWebuiDatabase, initWebuiDatabase } from "./db/index.ts";
import { createWebUiServer } from "./http/server.ts";
import { createPiClient } from "./rpc/pi-client.ts";
import type { WebUiContext } from "./types/context.ts";
const paths = createWebUiPaths();
const port = parsePort();
function writePidFile(): void {
try {
writeFileSync(paths.pidFile, String(process.pid), "utf8");
} catch {
/* ignore */
}
}
function clearPidFile(): void {
try {
if (existsSync(paths.pidFile)) unlinkSync(paths.pidFile);
} catch {
/* ignore */
}
}
function shutdown(exitCode = 0): never {
closeWebuiDatabase();
clearPidFile();
process.exit(exitCode);
}
const webuiDb = initWebuiDatabase(paths.extensionRoot);
console.log(`[webui] 配置数据库: ${webuiDb.dbPath}`);
process.on("SIGTERM", () => shutdown(0));
process.on("SIGINT", () => shutdown(0));
writePidFile();
const piClient = createPiClient(paths, () => shutdown(1));
const ctx: WebUiContext = {
config: { paths, port },
rpc: {
sendCmd: piClient.sendCmd,
submitPrompt: piClient.submitPrompt,
getRunSnapshot: piClient.getRunSnapshot,
connectSseClient: piClient.connectSseClient,
removeSseClient: piClient.removeSseClient,
},
db: webuiDb,
};
const server = createWebUiServer(ctx);
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}`);
});

View File

@@ -0,0 +1,76 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { dispatchSlashCommand } from "../slash/dispatch.ts";
import { normalizeChatImages } from "../services/chat-images.ts";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";
export function handleChatRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
): boolean {
const { sendCmd, submitPrompt } = ctx.rpc;
if (req.method === "POST" && pathname === "/api/chat") {
void readBody(req)
.then(async ({ 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`);
if (!imgs?.length && msg.trim().startsWith("/")) {
const slash = await dispatchSlashCommand(msg, sendCmd);
if (slash?.handled) {
return json(res, { ok: true, slash: true, ...slash });
}
}
submitPrompt({ message: msg, images: imgs });
return json(res, { ok: true, accepted: true }, 202);
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/bash") {
void readBody(req)
.then(async ({ command }) => {
const cmd = typeof command === "string" ? command.trim() : "";
if (!cmd) throw new Error("命令不能为空");
const result = await sendCmd({ type: "bash", command: cmd });
if (!result.success) throw new Error(result.error || "命令执行失败");
json(res, result.data);
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "GET" && pathname === "/api/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
ctx.rpc.connectSseClient(res);
req.on("close", () => ctx.rpc.removeSseClient(res));
return true;
}
if (req.method === "POST" && pathname === "/api/messages") {
void 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));
return true;
}
if (req.method === "POST" && pathname === "/api/abort") {
void sendCmd({ type: "abort" })
.then(() => json(res, { ok: true }))
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
return false;
}

View File

@@ -0,0 +1,19 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { listSlashCommands } from "../services/slash-commands.ts";
import type { WebUiContext } from "../types/context.ts";
import { json } from "../http/request.ts";
export function handleCommandsRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
): boolean {
if (req.method === "GET" && pathname === "/api/commands") {
void listSlashCommands(ctx.rpc.sendCmd)
.then((commands) => json(res, { commands }))
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
return false;
}

View File

@@ -0,0 +1,43 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";
export function handleModelsRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
): boolean {
const { sendCmd } = ctx.rpc;
if (req.method === "GET" && pathname === "/api/models") {
void 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));
return true;
}
if (req.method === "POST" && pathname === "/api/model") {
void 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));
return true;
}
if (req.method === "POST" && pathname === "/api/thinking") {
void 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));
return true;
}
return false;
}

View File

@@ -0,0 +1,124 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import {
appendSessionName,
buildSessionListResponse,
deleteSessionFile,
pinSession,
readSessionMessages,
readSessionSummary,
resolveSessionFile,
} from "../services/sessions.ts";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";
export function handleSessionsRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
): boolean {
const { paths } = ctx.config;
const { sendCmd } = ctx.rpc;
if (req.method === "POST" && pathname === "/api/new-session") {
void sendCmd({ type: "new_session" })
.then(async (r: any) => {
if (!r.success) return json(res, { error: r.error }, 500);
if (r.data?.cancelled) return json(res, r.data);
const state = await sendCmd({ type: "get_state" });
const sessionFile = state.success ? state.data?.sessionFile : undefined;
return json(res, { ...r.data, sessionFile });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "GET" && pathname === "/api/sessions") {
json(res, buildSessionListResponse(paths));
return true;
}
if (req.method === "POST" && pathname === "/api/sessions/history") {
void readBody(req)
.then(({ path: sp }) => {
const messages = readSessionMessages(sp as string);
const summary = readSessionSummary(paths, sp as string);
json(res, { messages, session: summary });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/sessions/delete") {
void readBody(req)
.then(({ path: sp }) => {
deleteSessionFile(paths, sp as string);
json(res, { ok: true });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/sessions/pin") {
void readBody(req)
.then(({ path: sp, pinned }) => {
const result = pinSession(paths, sp as string, Boolean(pinned));
json(res, { ok: true, ...result });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/sessions/load") {
void readBody(req)
.then(async ({ path: sp }) => {
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: paths.repoRoot });
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(paths, sp as string);
json(res, { messages: mr.data.messages, session: summary });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/sessions/activate") {
void readBody(req)
.then(async ({ path: sp }) => {
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: paths.repoRoot });
if (!sw.success) throw new Error(sw.error);
const state = await sendCmd({ type: "get_state" });
if (!state.success) throw new Error(state.error);
json(res, { ok: true, state: state.data });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/sessions/name") {
void readBody(req)
.then(async ({ path: sp, name }) => {
if (typeof sp !== "string" || !sp.trim()) throw new Error("无效会话路径");
const savedName = appendSessionName(paths, sp, typeof name === "string" ? name : "");
const sessionPath = resolveSessionFile(paths, 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));
return true;
}
if (req.method === "GET" && pathname === "/api/session-state") {
void 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));
return true;
}
return false;
}

View File

@@ -0,0 +1,206 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { readWebuiAvatarSettings, writeWebuiAvatarSettings } from "../db/index.ts";
import { setExtensionEnabled } from "../settings/extension-settings.ts";
import { listMcpSettings, setMcpServerEnabled, setMcpToolEnabled } from "../settings/mcp-settings.ts";
import { listSkillSettings, setSkillEnabled } from "../settings/skills-settings.ts";
import { normalizeAvatarUrl } from "../services/avatars.ts";
import {
listExtensionsForSettings,
listLoadedExtensionsByPath,
mergeExtensionToggleResponse,
} from "../services/extensions-display.ts";
import { readModelsConfig, writeModelsConfig } from "../services/models-config.ts";
import { readSystemPrompt, writeSystemPrompt } from "../services/system-prompt.ts";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";
function listMcpTools(ctx: WebUiContext): Record<string, unknown>[] {
return listMcpSettings(ctx.config.paths.mcpConfigFile, ctx.config.paths.mcpCacheFile);
}
export function handleSettingsRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
): boolean {
const { paths } = ctx.config;
const { sendCmd } = ctx.rpc;
if (req.method === "GET" && pathname === "/api/settings") {
void listSkillSettings(paths.repoRoot, paths.agentDir)
.then((skills) =>
listExtensionsForSettings(paths, sendCmd).then((extensions) =>
json(res, {
...readWebuiAvatarSettings(),
webuiDbPath: ctx.db.dbPath,
systemPrompt: readSystemPrompt(paths),
systemPromptPath: paths.systemPromptFile,
modelsConfig: readModelsConfig(paths),
modelsConfigPath: paths.modelsConfigFile,
extensionsPath: paths.agentExtensionsDir,
mcpCachePath: paths.mcpCacheFile,
mcpConfigPath: paths.mcpConfigFile,
skills,
extensions,
mcpTools: listMcpTools(ctx),
}),
),
)
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/skills/toggle") {
void readBody(req)
.then(async ({ path: skillPath, enabled }) => {
if (typeof skillPath !== "string" || !skillPath.trim()) {
throw new Error("skill path 无效");
}
if (typeof enabled !== "boolean") {
throw new Error("enabled 必须是 boolean");
}
const skill = await setSkillEnabled(paths.repoRoot, paths.agentDir, skillPath.trim(), enabled);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
json(res, { ok: true, skill });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/extensions/toggle") {
void readBody(req)
.then(async ({ path: extensionPath, enabled }) => {
if (typeof extensionPath !== "string" || !extensionPath.trim()) {
throw new Error("extension path 无效");
}
if (typeof enabled !== "boolean") {
throw new Error("enabled 必须是 boolean");
}
const extension = await setExtensionEnabled(
paths.repoRoot,
paths.agentDir,
extensionPath.trim(),
enabled,
);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
let loadedByPath = new Map<string, any>();
try {
loadedByPath = await listLoadedExtensionsByPath(paths, sendCmd);
} catch {
/* ignore */
}
json(res, {
ok: true,
extension: mergeExtensionToggleResponse(paths, extension, loadedByPath),
});
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/mcp/server/toggle") {
void readBody(req)
.then(async ({ server, enabled }) => {
if (typeof server !== "string" || !server.trim()) {
throw new Error("server 名称无效");
}
if (typeof enabled !== "boolean") {
throw new Error("enabled 必须是 boolean");
}
const entry = setMcpServerEnabled(
paths.mcpConfigFile,
paths.mcpCacheFile,
server.trim(),
enabled,
);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
json(res, { ok: true, server: entry });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/mcp/tool/toggle") {
void readBody(req)
.then(async ({ server, tool, enabled }) => {
if (typeof server !== "string" || !server.trim()) {
throw new Error("server 名称无效");
}
if (typeof tool !== "string" || !tool.trim()) {
throw new Error("tool 名称无效");
}
if (typeof enabled !== "boolean") {
throw new Error("enabled 必须是 boolean");
}
const entry = setMcpToolEnabled(
paths.mcpConfigFile,
paths.mcpCacheFile,
server.trim(),
tool.trim(),
enabled,
);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
json(res, { ok: true, tool: entry });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/reload") {
void 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));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/models-config") {
void readBody(req)
.then(async ({ modelsConfig }) => {
if (typeof modelsConfig !== "string") {
throw new Error("modelsConfig 必须是字符串");
}
writeModelsConfig(paths, modelsConfig);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
json(res, { ok: true, modelsConfigPath: paths.modelsConfigFile });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/system-prompt") {
void readBody(req)
.then(async ({ systemPrompt }) => {
if (typeof systemPrompt !== "string") {
throw new Error("systemPrompt 必须是字符串");
}
writeSystemPrompt(paths, systemPrompt);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
json(res, { ok: true, systemPromptPath: paths.systemPromptFile });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/settings/avatars") {
void 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));
return true;
}
return false;
}

View File

@@ -0,0 +1,70 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import {
deleteWebuiConfig,
getAllWebuiConfig,
getWebuiConfig,
readWebuiAvatarSettings,
setWebuiConfig,
setWebuiConfigMany,
} from "../db/index.ts";
import { normalizeConfigKey, normalizeConfigValue } from "../services/avatars.ts";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";
export function handleWebuiConfigRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
url: URL,
): boolean {
if (req.method === "GET" && pathname === "/api/avatars") {
json(res, readWebuiAvatarSettings());
return true;
}
if (req.method === "GET" && pathname === "/api/webui/config") {
const key = url.searchParams.get("key");
if (key) {
const normalized = normalizeConfigKey(key);
json(res, { key: normalized, value: getWebuiConfig(normalized) });
return true;
}
json(res, { config: getAllWebuiConfig(), dbPath: ctx.db.dbPath });
return true;
}
if (req.method === "POST" && pathname === "/api/webui/config") {
void 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));
return true;
}
if (req.method === "POST" && pathname === "/api/webui/config/delete") {
void 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));
return true;
}
return false;
}

View File

@@ -0,0 +1,203 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { ServerResponse } from "node:http";
import { resolvePiRpcLaunch, type WebUiPaths } from "../config/paths.ts";
import type { RunSnapshot, SendCmd, SubmitPromptOptions } from "../types/context.ts";
export interface PiClient {
sendCmd: SendCmd;
submitPrompt: (options: SubmitPromptOptions) => void;
getRunSnapshot: () => RunSnapshot;
connectSseClient: (res: ServerResponse) => void;
removeSseClient: (res: ServerResponse) => void;
child: ChildProcessWithoutNullStreams;
}
const BUFFERED_EVENT_TYPES = new Set([
"agent_start",
"agent_end",
"message_start",
"message_update",
"message_end",
"tool_execution_start",
"tool_execution_update",
"tool_execution_end",
"compaction_start",
"compaction_end",
]);
const DEFAULT_CMD_TIMEOUT_MS = 60_000;
const PROMPT_PREFLIGHT_TIMEOUT_MS = 30_000;
export function createPiClient(paths: WebUiPaths, onExit: (code: number | null) => void): PiClient {
const piLaunch = resolvePiRpcLaunch(paths);
console.log(`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${piLaunch.args.join(" ")}`);
const pi = spawn(piLaunch.command, piLaunch.args, {
cwd: paths.repoRoot,
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
PI_CODING_AGENT_DIR: paths.agentDir,
},
});
pi.stderr.on("data", (data) => process.stderr.write(`[pi] ${data}`));
pi.on("exit", (code) => {
console.log(`[webui] pi 退出, code=${code}`);
onExit(code);
});
let buffer = "";
const pending = new Map<string, { resolve: (value: any) => void; reject: (error: Error) => void }>();
const sseClients = new Set<ServerResponse>();
let reqId = 0;
let isStreaming = false;
let sessionFile: string | undefined;
let turnEventBuffer: Record<string, unknown>[] = [];
function getRunSnapshot(): RunSnapshot {
return {
isStreaming,
sessionFile,
replay: [...turnEventBuffer],
};
}
function broadcastSse(msg: Record<string, unknown>): void {
const data = `data: ${JSON.stringify(msg)}\n\n`;
for (const res of sseClients) res.write(data);
}
function trackAgentEvent(msg: Record<string, unknown>): void {
const type = msg.type;
if (typeof type !== "string") return;
if (type === "agent_start") {
isStreaming = true;
turnEventBuffer = [msg];
return;
}
if (type === "agent_end") {
turnEventBuffer.push(msg);
isStreaming = false;
turnEventBuffer = [];
return;
}
if (isStreaming && BUFFERED_EVENT_TYPES.has(type)) {
turnEventBuffer.push(msg);
}
}
function registerPending(
id: string,
handlers: { resolve: (value: any) => void; reject: (error: Error) => void },
timeoutMs: number,
timeoutLabel: string,
): void {
pending.set(id, handlers);
setTimeout(() => {
if (!pending.has(id)) return;
pending.delete(id);
handlers.reject(new Error(`命令超时: ${timeoutLabel}`));
}, timeoutMs);
}
function onLine(line: string): void {
if (!line.trim()) return;
try {
const msg = JSON.parse(line) as Record<string, unknown>;
if (msg.type === "response" && msg.id && pending.has(String(msg.id))) {
const id = String(msg.id);
const p = pending.get(id)!;
pending.delete(id);
if (msg.command === "prompt" && msg.success === false) {
broadcastSse({
type: "prompt_rejected",
error: String(msg.error || "prompt rejected"),
});
}
if (msg.command === "get_state" && msg.success === true) {
const data = msg.data as { sessionFile?: string } | undefined;
if (data?.sessionFile) {
sessionFile = data.sessionFile;
}
}
p.resolve(msg);
return;
}
trackAgentEvent(msg);
broadcastSse(msg);
} catch {
/* ignore non-JSON lines */
}
}
pi.stdout.on("data", (chunk: Buffer) => {
buffer += chunk.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) onLine(line);
});
const sendCmd: SendCmd = (command) =>
new Promise((resolvePromise, reject) => {
const id = `req_${++reqId}`;
const line = JSON.stringify({ ...command, id }) + "\n";
registerPending(
id,
{ resolve: resolvePromise, reject },
DEFAULT_CMD_TIMEOUT_MS,
String(command.type),
);
pi.stdin.write(line);
});
function submitPrompt(options: SubmitPromptOptions): void {
const id = `req_${++reqId}`;
const line =
JSON.stringify({
type: "prompt",
message: options.message,
images: options.images,
id,
}) + "\n";
registerPending(
id,
{
resolve: () => {
/* preflight success: agent events arrive via SSE */
},
reject: (err) => {
broadcastSse({ type: "prompt_rejected", error: err.message });
},
},
PROMPT_PREFLIGHT_TIMEOUT_MS,
"prompt",
);
pi.stdin.write(line);
}
function connectSseClient(res: ServerResponse): void {
const snapshot = getRunSnapshot();
res.write(
`data: ${JSON.stringify({
type: "connected",
isStreaming: snapshot.isStreaming,
replay: snapshot.replay,
})}\n\n`,
);
sseClients.add(res);
}
return {
sendCmd,
submitPrompt,
getRunSnapshot,
connectSseClient,
removeSseClient: (res) => sseClients.delete(res),
child: pi,
};
}

View File

@@ -0,0 +1,32 @@
export 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();
}
export 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;
}
export function normalizeConfigValue(value: unknown): string {
if (typeof value !== "string") throw new Error("配置值必须是字符串");
if (value.length > 65536) throw new Error("配置值过长");
return value;
}

View File

@@ -0,0 +1,24 @@
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;
export 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 { mimeType?: string }).mimeType || "");
const data = String((raw as { data?: string }).data || "");
if ((raw as { type?: string }).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;
}

View File

@@ -0,0 +1,196 @@
import { basename, resolve } from "node:path";
import type { WebUiPaths } from "../config/paths.ts";
import type { SendCmd } from "../types/context.ts";
import { listExtensionSettings, type ExtensionSettingsEntry } from "../settings/extension-settings.ts";
import {
getExtensionCategoryFromPath,
readConfiguredNpmPackageNames,
readNpmPackageVersion,
resolveNpmSource,
} from "../settings/extensions-paths.ts";
function getConfiguredNpmPackages(paths: WebUiPaths): Set<string> {
return readConfiguredNpmPackageNames(paths.agentSettingsFile);
}
function getExtensionPath(extension: any): string {
return String(extension.path || extension.resolvedPath || "");
}
function classifyExtension(
paths: WebUiPaths,
extension: any,
configuredPackages: Set<string>,
): "local" | "npm" | null {
const pathValue = getExtensionPath(extension);
return getExtensionCategoryFromPath(
pathValue,
paths.agentExtensionsDir,
paths.agentNpmNodeModules,
configuredPackages,
);
}
function cleanExtensionName(name: string): string {
return basename(name).replace(/\.[cm]?[tj]s$/i, "");
}
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 displayExtensionKind(
scope: string,
source: string,
category: "local" | "npm",
version?: string,
): string {
if (category === "npm") {
const packageName = source.startsWith("npm:") ? source.slice(4) : source;
const base = packageName ? `npm · ${packageName}` : "npm";
return version ? `${base} · ${version}` : base;
}
const scopeText = scope === "project" ? "项目" : scope === "user" ? "用户" : scope || "";
if (source === "auto" || source === "local") {
return scopeText ? `${scopeText}本地` : "本地";
}
return source ? `${scopeText || "未知"} · ${source}` : scopeText || "本地";
}
function displayExtensionLocation(repoRoot: string, extensionPath: string, resolvedPath: string): string {
const pathValue = extensionPath || resolvedPath;
if (!pathValue) return "";
return pathValue.replace(repoRoot, ".");
}
export function normalizeExtension(
paths: WebUiPaths,
extension: any,
configuredPackages: Set<string>,
): Record<string, unknown> {
const sourceInfo = extension.sourceInfo || {};
const pathValue = getExtensionPath(extension);
const category = classifyExtension(paths, extension, configuredPackages);
const source =
category === "npm"
? resolveNpmSource(pathValue, paths.agentNpmNodeModules, String(sourceInfo.source || ""))
: String(sourceInfo.source || "");
const resolvedPath = String(extension.resolvedPath || extension.path || pathValue);
const npmCategory = category === "npm" ? "npm" : "local";
const version =
category === "npm"
? readNpmPackageVersion(paths.agentNpmNodeModules, pathValue, source)
: undefined;
return {
name: displayExtensionName(pathValue, source),
rawName: extension.name || "",
path: pathValue,
resolvedPath,
scope: sourceInfo.scope || extension.scope || "",
source,
sourcePath: sourceInfo.path || "",
location: displayExtensionLocation(paths.repoRoot, pathValue, resolvedPath),
version,
kind: displayExtensionKind(sourceInfo.scope || extension.scope, source, npmCategory, version),
category: category || "local",
enabled: extension.enabled !== false,
commands: extension.commands || [],
tools: extension.tools || [],
flags: extension.flags || [],
shortcuts: extension.shortcuts || [],
handlers: extension.handlers || [],
};
}
export async function listLoadedExtensionsByPath(
paths: WebUiPaths,
sendCmd: SendCmd,
): Promise<Map<string, any>> {
const configuredPackages = getConfiguredNpmPackages(paths);
const response = await sendCmd({ type: "get_extensions" });
if (!response.success) throw new Error(response.error || "读取扩展失败");
const loadedByPath = new Map<string, any>();
for (const extension of response.data?.extensions || []) {
if (classifyExtension(paths, extension, configuredPackages) === null) continue;
const pathValue = getExtensionPath(extension);
loadedByPath.set(resolve(pathValue), extension);
if (extension.resolvedPath) {
loadedByPath.set(resolve(String(extension.resolvedPath)), extension);
}
}
return loadedByPath;
}
export async function listExtensionsForSettings(
paths: WebUiPaths,
sendCmd: SendCmd,
): Promise<Record<string, unknown>[]> {
const configuredPackages = getConfiguredNpmPackages(paths);
const resolved = await listExtensionSettings(paths.repoRoot, paths.agentDir);
let loadedByPath = new Map<string, any>();
try {
loadedByPath = await listLoadedExtensionsByPath(paths, sendCmd);
} catch {
/* show resolved extensions even if agent RPC is unavailable */
}
return resolved
.map((entry) => {
const loaded =
loadedByPath.get(resolve(entry.path)) ||
loadedByPath.get(resolve(entry.resolvedPath || entry.path));
const merged = loaded
? { ...loaded, enabled: entry.enabled }
: {
path: entry.path,
resolvedPath: entry.resolvedPath,
sourceInfo: { scope: entry.scope, source: entry.source },
commands: [],
tools: [],
flags: [],
shortcuts: [],
handlers: [],
enabled: entry.enabled,
};
return normalizeExtension(paths, merged, configuredPackages);
})
.sort((a, b) => {
const categoryOrder = a.category === b.category ? 0 : a.category === "local" ? -1 : 1;
if (categoryOrder !== 0) return categoryOrder;
return String(a.name).localeCompare(String(b.name));
});
}
export function mergeExtensionToggleResponse(
paths: WebUiPaths,
extension: ExtensionSettingsEntry,
loadedByPath: Map<string, any>,
): Record<string, unknown> {
const configuredPackages = getConfiguredNpmPackages(paths);
const loaded =
loadedByPath.get(resolve(extension.path)) ||
loadedByPath.get(resolve(extension.resolvedPath || extension.path));
const merged = loaded
? { ...loaded, enabled: extension.enabled }
: {
path: extension.path,
resolvedPath: extension.resolvedPath,
sourceInfo: { scope: extension.scope, source: extension.source },
commands: [],
tools: [],
flags: [],
shortcuts: [],
handlers: [],
enabled: extension.enabled,
};
return normalizeExtension(paths, merged, configuredPackages);
}

View File

@@ -0,0 +1,24 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import type { WebUiPaths } from "../config/paths.ts";
export function readModelsConfig(paths: WebUiPaths): string {
if (!existsSync(paths.modelsConfigFile)) return "{\n \"providers\": {}\n}\n";
return readFileSync(paths.modelsConfigFile, "utf8");
}
export function writeModelsConfig(paths: WebUiPaths, content: string): void {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`models.json 不是有效的 JSON: ${message}`);
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("models.json 根节点必须是 JSON 对象");
}
const formatted = `${JSON.stringify(parsed, null, 2)}\n`;
mkdirSync(dirname(paths.modelsConfigFile), { recursive: true });
writeFileSync(paths.modelsConfigFile, formatted, "utf8");
}

View File

@@ -0,0 +1,250 @@
import { randomUUID } from "node:crypto";
import {
appendFileSync,
existsSync,
readFileSync,
readdirSync,
statSync,
unlinkSync,
} from "node:fs";
import { join, resolve } from "node:path";
import type { WebUiPaths } from "../config/paths.ts";
import { prunePinnedSessionPaths, removePinnedSessionPath, setSessionPinned } from "../db/index.ts";
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 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 "";
}
export function listSessionFiles(paths: WebUiPaths): string[] {
if (!existsSync(paths.sessionsDir)) 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(paths.sessionsDir);
return files.sort().reverse();
}
export function readSessionSummary(paths: WebUiPaths, 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;
}
}
export function readSessionMessages(filePath: string): unknown[] {
try {
const content = readFileSync(filePath, "utf8");
return content
.trim()
.split("\n")
.map((line) => {
try {
const entry = JSON.parse(line);
return entry.type === "message" ? entry.message : null;
} catch {
return null;
}
})
.filter(Boolean);
} catch {
return [];
}
}
export function resolveSessionFile(paths: WebUiPaths, filePath: string): string {
const resolved = resolve(filePath);
const sessionsRoot = resolve(paths.sessionsDir);
if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) {
throw new Error("无效会话路径");
}
return resolved;
}
export function appendSessionName(paths: WebUiPaths, filePath: string, name: string): string {
const sessionPath = resolveSessionFile(paths, 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 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)),
}));
}
export function buildSessionListResponse(paths: WebUiPaths) {
const summaries = listSessionFiles(paths)
.map((filePath) => readSessionSummary(paths, filePath))
.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,
};
}
export function deleteSessionFile(paths: WebUiPaths, sessionPathInput: string): void {
const sessionPath = resolveSessionFile(paths, sessionPathInput);
if (!existsSync(sessionPath)) throw new Error("会话不存在");
unlinkSync(sessionPath);
removePinnedSessionPath(sessionPath);
}
export function pinSession(paths: WebUiPaths, sessionPathInput: string, pinned: boolean): {
path: string;
pinned: boolean;
pinnedPaths: string[];
} {
const sessionPath = resolveSessionFile(paths, sessionPathInput);
if (!existsSync(sessionPath)) throw new Error("会话不存在");
const pinnedPaths = setSessionPinned(sessionPath, pinned);
return { path: sessionPath, pinned, pinnedPaths };
}

View File

@@ -0,0 +1,51 @@
import { BUILTIN_SLASH_COMMANDS } from "../../../../../../packages/coding-agent/src/core/slash-commands.ts";
import { filterWebUiSlashCommands, type SlashCommandEntry } from "../slash/dispatch.ts";
import type { SendCmd } from "../types/context.ts";
export async function listSlashCommands(sendCmd: SendCmd): Promise<SlashCommandEntry[]> {
const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
const commands: SlashCommandEntry[] = BUILTIN_SLASH_COMMANDS.map((command) => ({
name: command.name,
description: command.description,
source: "builtin",
}));
const response = await sendCmd({ type: "get_commands" });
if (!response.success) throw new Error(response.error || "读取命令失败");
for (const command of response.data?.commands || []) {
const name = String(command?.name || "");
if (!name) continue;
const source = String(command?.source || "");
if (source === "extension" && builtinNames.has(name)) continue;
if (source === "prompt") {
commands.push({
name,
description: String(command.description || ""),
source: "prompt",
});
continue;
}
if (source === "skill") {
commands.push({
name,
description: String(command.description || ""),
source: "skill",
});
continue;
}
if (source === "extension") {
commands.push({
name,
description: String(command.description || ""),
source: "extension",
});
}
}
return filterWebUiSlashCommands(commands).sort((a, b) => a.name.localeCompare(b.name));
}

View File

@@ -0,0 +1,13 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import type { WebUiPaths } from "../config/paths.ts";
export function readSystemPrompt(paths: WebUiPaths): string {
if (!existsSync(paths.systemPromptFile)) return "";
return readFileSync(paths.systemPromptFile, "utf8");
}
export function writeSystemPrompt(paths: WebUiPaths, content: string): void {
mkdirSync(dirname(paths.systemPromptFile), { recursive: true });
writeFileSync(paths.systemPromptFile, content, "utf8");
}

View File

@@ -0,0 +1,203 @@
import { basename, dirname, join, relative, resolve } from "node:path";
import {
DefaultPackageManager,
type PathMetadata,
type ResolvedResource,
} from "../../../../../../packages/coding-agent/src/core/package-manager.ts";
import { SettingsManager, type PackageSource } from "../../../../../../packages/coding-agent/src/core/settings-manager.ts";
export interface ExtensionSettingsEntry {
path: string;
resolvedPath: string;
enabled: boolean;
name: string;
scope: string;
source: string;
}
interface ExtensionResourceItem {
path: string;
enabled: boolean;
metadata: PathMetadata;
}
function createManagers(repoRoot: string, agentDir: string) {
const settingsManager = SettingsManager.create(repoRoot, agentDir);
const packageManager = new DefaultPackageManager({
cwd: repoRoot,
agentDir,
settingsManager,
});
return { settingsManager, packageManager };
}
function normalizeExtensionPath(pathValue: string): string {
return resolve(pathValue);
}
function readExtensionDisplayName(pathValue: string): string {
const normalized = pathValue.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
const file = parts[parts.length - 1] || normalized;
if (/^index\.[cm]?[tj]s$/i.test(file) && parts.length >= 2) {
return parts[parts.length - 2].replace(/\.[cm]?[tj]s$/i, "");
}
return basename(file).replace(/\.[cm]?[tj]s$/i, "");
}
function toResourceItem(resource: ResolvedResource): ExtensionResourceItem {
return {
path: resource.path,
enabled: resource.enabled,
metadata: resource.metadata,
};
}
function getTopLevelBaseDir(scope: "user" | "project", repoRoot: string, agentDir: string): string {
return scope === "project" ? join(repoRoot, ".pi") : agentDir;
}
function getResourcePattern(item: ExtensionResourceItem, repoRoot: string, agentDir: string): string {
if (item.metadata.origin === "package") {
const baseDir = item.metadata.baseDir ?? dirname(item.path);
return relative(baseDir, item.path);
}
const scope = item.metadata.scope as "user" | "project";
const baseDir = getTopLevelBaseDir(scope, repoRoot, agentDir);
return relative(baseDir, item.path);
}
function applyPatternUpdate(current: string[], pattern: string, enabled: boolean): string[] {
const disablePattern = `-${pattern}`;
const enablePattern = `+${pattern}`;
const updated = current.filter((entry) => {
const stripped = entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
return stripped !== pattern;
});
updated.push(enabled ? enablePattern : disablePattern);
return updated;
}
function toggleTopLevelResource(
item: ExtensionResourceItem,
enabled: boolean,
settingsManager: SettingsManager,
repoRoot: string,
agentDir: string,
): void {
const scope = item.metadata.scope as "user" | "project";
const settings =
scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
const current = [...(settings.extensions ?? [])];
const pattern = getResourcePattern(item, repoRoot, agentDir);
const updated = applyPatternUpdate(current, pattern, enabled);
if (scope === "project") {
settingsManager.setProjectExtensionPaths(updated);
} else {
settingsManager.setExtensionPaths(updated);
}
}
function togglePackageResource(
item: ExtensionResourceItem,
enabled: boolean,
settingsManager: SettingsManager,
repoRoot: string,
agentDir: string,
): void {
const scope = item.metadata.scope as "user" | "project";
const settings =
scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
const packages = [...(settings.packages ?? [])] as PackageSource[];
const pkgIndex = packages.findIndex((pkg) => {
const source = typeof pkg === "string" ? pkg : pkg.source;
return source === item.metadata.source;
});
if (pkgIndex === -1) return;
let pkg = packages[pkgIndex];
if (typeof pkg === "string") {
pkg = { source: pkg };
packages[pkgIndex] = pkg;
}
const current = [...((pkg.extensions ?? []) as string[])];
const pattern = getResourcePattern(item, repoRoot, agentDir);
const updated = applyPatternUpdate(current, pattern, enabled);
(pkg as Record<string, unknown>).extensions = updated.length > 0 ? updated : undefined;
if (!pkg.skills && !pkg.extensions && !pkg.prompts && !pkg.themes) {
packages.splice(pkgIndex, 1);
}
if (scope === "project") {
settingsManager.setProjectPackages(packages);
} else {
settingsManager.setPackages(packages);
}
}
function toggleExtensionResource(
item: ExtensionResourceItem,
enabled: boolean,
settingsManager: SettingsManager,
repoRoot: string,
agentDir: string,
): void {
if (item.metadata.origin === "top-level") {
toggleTopLevelResource(item, enabled, settingsManager, repoRoot, agentDir);
} else {
togglePackageResource(item, enabled, settingsManager, repoRoot, agentDir);
}
}
function pathsMatch(a: string, b: string): boolean {
const left = normalizeExtensionPath(a);
const right = normalizeExtensionPath(b);
if (left === right) return true;
return resolve(a) === resolve(b);
}
function mapExtensionEntry(resource: ResolvedResource): ExtensionSettingsEntry {
return {
path: normalizeExtensionPath(resource.path),
resolvedPath: normalizeExtensionPath(resource.path),
enabled: resource.enabled,
name: readExtensionDisplayName(resource.path),
scope: resource.metadata.scope || "",
source: resource.metadata.source || "",
};
}
export async function listExtensionSettings(
repoRoot: string,
agentDir: string,
): Promise<ExtensionSettingsEntry[]> {
const { packageManager } = createManagers(repoRoot, agentDir);
const resolved = await packageManager.resolve(async () => "skip");
return resolved.extensions.map(mapExtensionEntry).sort((a, b) => a.name.localeCompare(b.name));
}
export async function setExtensionEnabled(
repoRoot: string,
agentDir: string,
pathValue: string,
enabled: boolean,
): Promise<ExtensionSettingsEntry> {
const { settingsManager, packageManager } = createManagers(repoRoot, agentDir);
const resolved = await packageManager.resolve(async () => "skip");
const match = resolved.extensions.find((resource) => pathsMatch(resource.path, pathValue));
if (!match) {
throw new Error("未找到对应扩展");
}
toggleExtensionResource(toResourceItem(match), enabled, settingsManager, repoRoot, agentDir);
const refreshed = await packageManager.resolve(async () => "skip");
const updated = refreshed.extensions.find((resource) => pathsMatch(resource.path, pathValue));
if (!updated) {
throw new Error("更新扩展状态后未能重新解析");
}
return mapExtensionEntry(updated);
}

View File

@@ -0,0 +1,102 @@
import { existsSync, readFileSync } from "node:fs";
import { join, relative, resolve, sep } from "node:path";
export function readConfiguredNpmPackageNames(settingsPath: string): Set<string> {
const names = new Set<string>();
if (!existsSync(settingsPath)) return names;
try {
const raw = JSON.parse(readFileSync(settingsPath, "utf8"));
const packages = raw?.packages;
if (!Array.isArray(packages)) return names;
for (const entry of packages) {
const source = typeof entry === "string" ? entry : entry?.source;
if (typeof source === "string" && source.startsWith("npm:")) {
const name = source.slice("npm:".length).trim();
if (name) names.add(name);
}
}
} catch {
/* ignore invalid settings */
}
return names;
}
export function extractNpmPackageNameFromPath(nodeModulesRoot: string, pathValue: string): string | null {
if (!pathValue) return null;
const root = resolve(nodeModulesRoot);
const normalized = resolve(pathValue);
if (normalized !== root && !normalized.startsWith(`${root}${sep}`)) {
return null;
}
const rel = relative(root, normalized);
const parts = rel.split(sep).filter(Boolean);
if (parts.length === 0) return null;
if (parts[0].startsWith("@") && parts.length >= 2) {
return `${parts[0]}/${parts[1]}`;
}
return parts[0] || null;
}
export function isLocalAgentExtension(pathValue: string, agentExtensionsDir: string): boolean {
if (!pathValue) return false;
const resolved = resolve(pathValue);
const root = resolve(agentExtensionsDir);
return resolved === root || resolved.startsWith(`${root}${sep}`);
}
export function isConfiguredNpmExtension(
pathValue: string,
nodeModulesRoot: string,
configuredPackages: Set<string>,
): boolean {
const packageName = extractNpmPackageNameFromPath(nodeModulesRoot, pathValue);
return packageName !== null && configuredPackages.has(packageName);
}
export function getExtensionCategoryFromPath(
pathValue: string,
agentExtensionsDir: string,
nodeModulesRoot: string,
configuredPackages: Set<string>,
): "local" | "npm" | null {
if (isConfiguredNpmExtension(pathValue, nodeModulesRoot, configuredPackages)) {
return "npm";
}
if (isLocalAgentExtension(pathValue, agentExtensionsDir)) {
return "local";
}
return null;
}
export function resolveNpmSource(pathValue: string, nodeModulesRoot: string, fallbackSource: string): string {
if (fallbackSource.startsWith("npm:")) return fallbackSource;
const packageName = extractNpmPackageNameFromPath(nodeModulesRoot, pathValue);
return packageName ? `npm:${packageName}` : fallbackSource;
}
export function readNpmPackageVersion(
nodeModulesRoot: string,
pathValue: string,
source: string,
): string | undefined {
let packageName = extractNpmPackageNameFromPath(nodeModulesRoot, pathValue);
if (!packageName && source.startsWith("npm:")) {
packageName = source.slice("npm:".length).trim() || null;
}
if (!packageName) return undefined;
const pkgJsonPath = join(nodeModulesRoot, packageName, "package.json");
if (!existsSync(pkgJsonPath)) return undefined;
try {
const raw = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as { version?: unknown };
return typeof raw.version === "string" && raw.version.trim() ? raw.version.trim() : undefined;
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,245 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
const DISABLED_SERVERS_KEY = "mcpServersDisabled";
type McpServerEntry = Record<string, unknown>;
export interface McpToolSettingsEntry {
server: string;
name: string;
description: string;
parameters: string[];
required: string[];
enabled: boolean;
}
export interface McpServerSettingsEntry {
name: string;
configured: boolean;
enabled: boolean;
cached: boolean;
toolCount: number;
enabledToolCount: number;
resourceCount: number;
cachedAt: string;
tools: McpToolSettingsEntry[];
}
function readRawConfig(filePath: string): Record<string, unknown> {
if (!existsSync(filePath)) return { mcpServers: {} };
try {
const raw = JSON.parse(readFileSync(filePath, "utf8"));
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : { mcpServers: {} };
} catch {
return { mcpServers: {} };
}
}
function writeRawConfig(filePath: string, raw: Record<string, unknown>): void {
mkdirSync(dirname(filePath), { recursive: true });
const tmpPath = `${filePath}.${process.pid}.tmp`;
writeFileSync(tmpPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
renameSync(tmpPath, filePath);
}
function getServersObject(raw: Record<string, unknown>): Record<string, McpServerEntry> {
const existing = raw.mcpServers ?? raw["mcp-servers"];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
return {};
}
return existing as Record<string, McpServerEntry>;
}
function getDisabledServersObject(raw: Record<string, unknown>): Record<string, McpServerEntry> {
const existing = raw[DISABLED_SERVERS_KEY];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
return {};
}
return existing as Record<string, McpServerEntry>;
}
function setServersObject(raw: Record<string, unknown>, servers: Record<string, McpServerEntry>): void {
delete raw["mcp-servers"];
raw.mcpServers = servers;
}
function setDisabledServersObject(raw: Record<string, unknown>, servers: Record<string, McpServerEntry>): void {
if (Object.keys(servers).length === 0) {
delete raw[DISABLED_SERVERS_KEY];
return;
}
raw[DISABLED_SERVERS_KEY] = servers;
}
function normalizeToolName(value: string): string {
return value.replace(/-/g, "_");
}
function isToolExcluded(toolName: string, serverName: string, excludeTools: unknown): boolean {
if (!Array.isArray(excludeTools) || excludeTools.length === 0) return false;
const candidates = new Set<string>([
normalizeToolName(toolName),
normalizeToolName(`${serverName}_${toolName}`),
normalizeToolName(`${serverName.replace(/-/g, "_")}_${toolName}`),
]);
for (const excluded of excludeTools) {
if (typeof excluded !== "string") continue;
if (candidates.has(normalizeToolName(excluded))) {
return true;
}
}
return false;
}
function removeToolFromExcludeList(excludeTools: string[], toolName: string, serverName: string): string[] {
return excludeTools.filter((entry) => !isToolExcluded(toolName, serverName, [entry]));
}
function readJsonFile(filePath: string): any | null {
if (!existsSync(filePath)) return null;
try {
return JSON.parse(readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
function normalizeMcpTool(serverName: string, tool: any, serverEntry: McpServerEntry): McpToolSettingsEntry {
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) : [];
const name = String(tool?.name || "");
return {
server: serverName,
name,
description: String(tool?.description || ""),
parameters: properties,
required,
enabled: name ? !isToolExcluded(name, serverName, serverEntry.excludeTools) : false,
};
}
export function listMcpSettings(configPath: string, cachePath: string): McpServerSettingsEntry[] {
const raw = readRawConfig(configPath);
const activeServers = getServersObject(raw);
const disabledServers = getDisabledServersObject(raw);
const cache = readJsonFile(cachePath);
const cachedServers = cache?.servers && typeof cache.servers === "object" ? cache.servers : {};
const serverNames = Array.from(
new Set([...Object.keys(activeServers), ...Object.keys(disabledServers), ...Object.keys(cachedServers)]),
).sort();
return serverNames.map((serverName) => {
const enabled = Object.prototype.hasOwnProperty.call(activeServers, serverName);
const serverEntry = (enabled ? activeServers[serverName] : disabledServers[serverName]) || {};
const entry = cachedServers[serverName] || {};
const tools = Array.isArray(entry.tools)
? entry.tools
.map((tool: any) => normalizeMcpTool(serverName, tool, serverEntry))
.filter((tool: McpToolSettingsEntry) => tool.name)
.sort((a: McpToolSettingsEntry, b: McpToolSettingsEntry) => a.name.localeCompare(b.name))
: [];
const enabledTools = enabled ? tools.filter((tool) => tool.enabled) : [];
const resources = Array.isArray(entry.resources) ? entry.resources : [];
return {
name: serverName,
configured: enabled || Object.prototype.hasOwnProperty.call(disabledServers, serverName),
enabled,
cached: Boolean(cachedServers[serverName]),
toolCount: tools.length,
enabledToolCount: enabledTools.length,
resourceCount: resources.length,
cachedAt: entry.cachedAt ? new Date(entry.cachedAt).toISOString() : "",
tools,
};
});
}
export function setMcpServerEnabled(
configPath: string,
cachePath: string,
serverName: string,
enabled: boolean,
): McpServerSettingsEntry {
const raw = readRawConfig(configPath);
const activeServers = getServersObject(raw);
const disabledServers = getDisabledServersObject(raw);
if (enabled) {
const entry = disabledServers[serverName];
if (!entry) {
const current = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);
if (current?.enabled) return current;
throw new Error(`未找到已禁用的 MCP Server${serverName}`);
}
activeServers[serverName] = entry;
delete disabledServers[serverName];
} else {
const entry = activeServers[serverName];
if (!entry) {
const current = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);
if (current && !current.enabled) return current;
throw new Error(`未找到 MCP Server${serverName}`);
}
disabledServers[serverName] = entry;
delete activeServers[serverName];
}
setServersObject(raw, activeServers);
setDisabledServersObject(raw, disabledServers);
writeRawConfig(configPath, raw);
const updated = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);
if (!updated) {
throw new Error(`更新 MCP Server 状态失败:${serverName}`);
}
return updated;
}
export function setMcpToolEnabled(
configPath: string,
cachePath: string,
serverName: string,
toolName: string,
enabled: boolean,
): McpToolSettingsEntry {
const raw = readRawConfig(configPath);
const activeServers = getServersObject(raw);
const serverEntry = activeServers[serverName];
if (!serverEntry) {
throw new Error(`MCP Server 未启用:${serverName}`);
}
const excludeTools = Array.isArray(serverEntry.excludeTools)
? serverEntry.excludeTools.filter((value): value is string => typeof value === "string")
: [];
let nextExclude = excludeTools;
if (enabled) {
nextExclude = removeToolFromExcludeList(excludeTools, toolName, serverName);
} else if (!isToolExcluded(toolName, serverName, excludeTools)) {
nextExclude = [...excludeTools, toolName];
}
if (nextExclude.length > 0) {
serverEntry.excludeTools = nextExclude;
} else {
delete serverEntry.excludeTools;
}
activeServers[serverName] = serverEntry;
setServersObject(raw, activeServers);
writeRawConfig(configPath, raw);
const server = listMcpSettings(configPath, cachePath).find((item) => item.name === serverName);
const tool = server?.tools.find((item) => item.name === toolName);
if (!tool) {
throw new Error(`未找到 MCP工具${serverName}/${toolName}`);
}
return tool;
}

View File

@@ -0,0 +1,452 @@
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import {
DefaultPackageManager,
type PathMetadata,
type ResolvedResource,
} from "../../../../../../packages/coding-agent/src/core/package-manager.ts";
import { SettingsManager, type PackageSource } from "../../../../../../packages/coding-agent/src/core/settings-manager.ts";
import { parseFrontmatter } from "../../../../../../packages/coding-agent/src/utils/frontmatter.ts";
const SKILLS_DIR = "skills";
const SKILLS_DISABLED_DIR = "skills-disabled";
export interface SkillSettingsEntry {
path: string;
enabled: boolean;
toggleable: boolean;
name: string;
description: string;
scope: string;
source: string;
}
interface MovableSkillLocation {
baseDir: string;
fromPath: string;
isDirectory: boolean;
currentlyDisabled: boolean;
}
interface SkillScanTarget {
baseDir: string;
scope: string;
}
function createManagers(repoRoot: string, agentDir: string) {
const settingsManager = SettingsManager.create(repoRoot, agentDir);
const packageManager = new DefaultPackageManager({
cwd: repoRoot,
agentDir,
settingsManager,
});
return { settingsManager, packageManager };
}
function resolveSkillFilePath(pathValue: string): string {
const resolved = resolve(pathValue);
if (resolved.endsWith("SKILL.md")) return resolved;
const skillFile = join(resolved, "SKILL.md");
return existsSync(skillFile) ? skillFile : resolved;
}
function normalizeSkillPath(pathValue: string): string {
return resolveSkillFilePath(pathValue);
}
function readSkillMeta(pathValue: string): { name: string; description: string } {
const skillFile = resolveSkillFilePath(pathValue);
const fallbackName = basename(dirname(skillFile));
if (!existsSync(skillFile)) {
return { name: fallbackName, description: "" };
}
try {
const content = readFileSync(skillFile, "utf8");
const { frontmatter } = parseFrontmatter<{ name?: string; description?: string }>(content);
return {
name: String(frontmatter.name || fallbackName),
description: String(frontmatter.description || ""),
};
} catch {
return { name: fallbackName, description: "" };
}
}
function isSkillToggleable(metadata: PathMetadata, skillPath: string): boolean {
if (metadata.origin === "package") return false;
if (metadata.source?.startsWith("npm:")) return false;
if (skillPath.includes("/node_modules/") || skillPath.includes("\\node_modules\\")) return false;
return metadata.origin === "top-level" && metadata.source === "auto";
}
function getSkillScanTargets(repoRoot: string, agentDir: string): SkillScanTarget[] {
const targets: SkillScanTarget[] = [
{ baseDir: agentDir, scope: "user" },
{ baseDir: join(repoRoot, ".pi"), scope: "project" },
{ baseDir: join(homedir(), ".agents"), scope: "user" },
];
const userAgents = join(homedir(), ".agents");
let dir = resolve(repoRoot);
const gitRoot = (() => {
let current = dir;
while (true) {
if (existsSync(join(current, ".git"))) return current;
const parent = dirname(current);
if (parent === current) return null;
current = parent;
}
})();
dir = resolve(repoRoot);
while (true) {
const agentsBase = join(dir, ".agents");
if (resolve(agentsBase) !== resolve(userAgents)) {
targets.push({ baseDir: agentsBase, scope: "project" });
}
if (gitRoot && dir === gitRoot) break;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
const seen = new Set<string>();
return targets.filter((t) => {
const key = resolve(t.baseDir);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function collectSkillFilesInDir(dir: string, piRootMarkdown = false): string[] {
const entries: string[] = [];
if (!existsSync(dir)) return entries;
const walk = (currentDir: string, isRoot: boolean): void => {
let dirEntries: ReturnType<typeof readdirSync>;
try {
dirEntries = readdirSync(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of dirEntries) {
if (entry.name === "SKILL.md") {
const fullPath = join(currentDir, entry.name);
try {
if (statSync(fullPath).isFile()) entries.push(fullPath);
} catch {
/* ignore */
}
return;
}
}
for (const entry of dirEntries) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fullPath = join(currentDir, entry.name);
let isDir = entry.isDirectory();
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
const stats = statSync(fullPath);
isDir = stats.isDirectory();
isFile = stats.isFile();
} catch {
continue;
}
}
if (piRootMarkdown && isRoot && isFile && entry.name.endsWith(".md")) {
entries.push(fullPath);
continue;
}
if (isDir) walk(fullPath, false);
}
};
walk(dir, true);
return entries;
}
function resolveMovableSkill(skillFilePath: string, baseDir: string): MovableSkillLocation | null {
const skillFile = resolve(skillFilePath);
const activeRoot = join(baseDir, SKILLS_DIR);
const disabledRoot = join(baseDir, SKILLS_DISABLED_DIR);
for (const [root, currentlyDisabled] of [
[activeRoot, false],
[disabledRoot, true],
] as const) {
const rel = relative(root, skillFile);
if (rel.startsWith("..") || isAbsolute(rel)) continue;
const parentRel = relative(root, dirname(skillFile));
if (parentRel === ".") {
return { baseDir, fromPath: skillFile, isDirectory: false, currentlyDisabled };
}
const topSegment = rel.split(/[/\\]/)[0];
if (!topSegment) continue;
const fromPath = join(root, topSegment);
if (!existsSync(fromPath)) continue;
return {
baseDir,
fromPath,
isDirectory: statSync(fromPath).isDirectory(),
currentlyDisabled,
};
}
return null;
}
function findMovableSkill(pathValue: string, repoRoot: string, agentDir: string): MovableSkillLocation | null {
const skillFile = resolveSkillFilePath(pathValue);
for (const { baseDir } of getSkillScanTargets(repoRoot, agentDir)) {
const found = resolveMovableSkill(skillFile, baseDir);
if (found) return found;
}
return null;
}
function moveSkillBetweenDirs(location: MovableSkillLocation, enabled: boolean): string {
const activeRoot = join(location.baseDir, SKILLS_DIR);
const disabledRoot = join(location.baseDir, SKILLS_DISABLED_DIR);
const destRoot = enabled ? activeRoot : disabledRoot;
mkdirSync(destRoot, { recursive: true });
const name = basename(location.fromPath);
const destPath = join(destRoot, name);
if (resolve(location.fromPath) === resolve(destPath)) {
return location.isDirectory ? join(destPath, "SKILL.md") : destPath;
}
if (existsSync(destPath)) {
throw new Error(`目标已存在: ${destPath}`);
}
movePath(location.fromPath, destPath);
return location.isDirectory ? join(destPath, "SKILL.md") : destPath;
}
function movePath(fromPath: string, destPath: string): void {
try {
renameSync(fromPath, destPath);
} catch (err) {
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
if (code !== "EXDEV") throw err;
cpSync(fromPath, destPath, { recursive: true });
rmSync(fromPath, { recursive: true, force: true });
}
}
function stripSkillPatternPrefix(entry: string): string {
if (entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-")) {
return entry.slice(1);
}
return entry;
}
function entryMatchesSkillPatterns(entry: string, patterns: Set<string>): boolean {
const stripped = stripSkillPatternPrefix(entry);
for (const pattern of patterns) {
if (stripped === pattern) return true;
if (stripped.endsWith(`/${pattern}`)) return true;
if (pattern.endsWith(stripped)) return true;
}
return false;
}
function patternsForSkillFile(skillFile: string, baseDir: string): Set<string> {
const patterns = new Set<string>();
for (const dirName of [SKILLS_DIR, SKILLS_DISABLED_DIR]) {
const root = join(baseDir, dirName);
const rel = relative(root, skillFile).replace(/\\/g, "/");
if (rel.startsWith("..") || isAbsolute(rel)) continue;
patterns.add(`${SKILLS_DIR}/${rel}`);
const top = rel.split("/")[0];
if (top && top !== rel) patterns.add(`${SKILLS_DIR}/${top}`);
if (rel.endsWith("/SKILL.md")) {
patterns.add(`${SKILLS_DIR}/${dirname(rel)}`);
}
}
return patterns;
}
function removeLegacySkillPatterns(
settingsManager: SettingsManager,
skillFilePath: string,
repoRoot: string,
agentDir: string,
): void {
const skillFile = normalizeSkillPath(skillFilePath);
const patterns = new Set<string>();
for (const { baseDir } of getSkillScanTargets(repoRoot, agentDir)) {
for (const p of patternsForSkillFile(skillFile, baseDir)) {
patterns.add(p);
}
}
const cleanList = (entries: string[]): string[] =>
entries.filter((entry) => !entryMatchesSkillPatterns(entry, patterns));
const globalSettings = settingsManager.getGlobalSettings();
const cleanedGlobal = cleanList([...(globalSettings.skills ?? [])]);
if (cleanedGlobal.length !== (globalSettings.skills ?? []).length) {
settingsManager.setSkillPaths(cleanedGlobal);
}
const projectSettings = settingsManager.getProjectSettings();
const cleanedProject = cleanList([...(projectSettings.skills ?? [])]);
if (cleanedProject.length !== (projectSettings.skills ?? []).length) {
settingsManager.setProjectSkillPaths(cleanedProject);
}
const cleanPackages = (packages: PackageSource[], setter: (pkgs: PackageSource[]) => void): void => {
let changed = false;
const updated = packages.map((pkg) => {
if (typeof pkg === "string") return pkg;
if (!pkg.skills?.length) return pkg;
const cleaned = cleanList([...pkg.skills]);
if (cleaned.length === pkg.skills.length) return pkg;
changed = true;
const next = { ...pkg, skills: cleaned.length > 0 ? cleaned : undefined };
if (!next.skills && !next.extensions && !next.prompts && !next.themes) {
return pkg.source;
}
return next;
});
if (changed) setter(updated);
};
cleanPackages([...(globalSettings.packages ?? [])], (pkgs) => settingsManager.setPackages(pkgs));
cleanPackages([...(projectSettings.packages ?? [])], (pkgs) => settingsManager.setProjectPackages(pkgs));
}
function toggleSkillByMove(
pathValue: string,
enabled: boolean,
repoRoot: string,
agentDir: string,
settingsManager: SettingsManager,
): string {
const location = findMovableSkill(pathValue, repoRoot, agentDir);
if (!location) {
throw new Error("仅支持切换 skills 目录下的 skill");
}
let newPath = normalizeSkillPath(pathValue);
if (enabled && location.currentlyDisabled) {
newPath = normalizeSkillPath(moveSkillBetweenDirs(location, true));
} else if (!enabled && !location.currentlyDisabled) {
newPath = normalizeSkillPath(moveSkillBetweenDirs(location, false));
}
removeLegacySkillPatterns(settingsManager, newPath, repoRoot, agentDir);
return newPath;
}
function mapSkillEntry(resource: ResolvedResource): SkillSettingsEntry {
const meta = readSkillMeta(resource.path);
const toggleable = isSkillToggleable(resource.metadata, resource.path);
return {
path: normalizeSkillPath(resource.path),
enabled: resource.enabled,
toggleable,
name: meta.name,
description: meta.description,
scope: resource.metadata.scope || "",
source: resource.metadata.source || "",
};
}
function mapDisabledSkillEntry(skillPath: string, scope: string): SkillSettingsEntry {
const meta = readSkillMeta(skillPath);
return {
path: normalizeSkillPath(skillPath),
enabled: false,
toggleable: true,
name: meta.name,
description: meta.description,
scope,
source: "auto",
};
}
function collectDisabledSkillEntries(repoRoot: string, agentDir: string): SkillSettingsEntry[] {
const entries: SkillSettingsEntry[] = [];
for (const { baseDir, scope } of getSkillScanTargets(repoRoot, agentDir)) {
const disabledDir = join(baseDir, SKILLS_DISABLED_DIR);
const piRootMarkdown =
resolve(baseDir) === resolve(agentDir) || resolve(baseDir) === resolve(join(repoRoot, ".pi"));
for (const skillPath of collectSkillFilesInDir(disabledDir, piRootMarkdown)) {
entries.push(mapDisabledSkillEntry(skillPath, scope));
}
}
return entries;
}
function pathsMatch(a: string, b: string): boolean {
const left = normalizeSkillPath(a);
const right = normalizeSkillPath(b);
if (left === right) return true;
return resolve(a) === resolve(b);
}
function mergeSkillEntries(resolved: SkillSettingsEntry[], disabled: SkillSettingsEntry[]): SkillSettingsEntry[] {
const merged = [...resolved];
for (const entry of disabled) {
if (!merged.some((item) => pathsMatch(item.path, entry.path))) {
merged.push(entry);
}
}
return merged.sort((a, b) => a.name.localeCompare(b.name));
}
export async function listSkillSettings(repoRoot: string, agentDir: string): Promise<SkillSettingsEntry[]> {
const { packageManager } = createManagers(repoRoot, agentDir);
const resolved = await packageManager.resolve(async () => "skip");
const active = resolved.skills.map(mapSkillEntry);
const disabled = collectDisabledSkillEntries(repoRoot, agentDir);
return mergeSkillEntries(active, disabled);
}
export async function setSkillEnabled(
repoRoot: string,
agentDir: string,
pathValue: string,
enabled: boolean,
): Promise<SkillSettingsEntry> {
const { settingsManager, packageManager } = createManagers(repoRoot, agentDir);
const resolved = await packageManager.resolve(async () => "skip");
const match = resolved.skills.find((resource) => pathsMatch(resource.path, pathValue));
const disabledOnly = collectDisabledSkillEntries(repoRoot, agentDir).find((entry) =>
pathsMatch(entry.path, pathValue),
);
if (match && !isSkillToggleable(match.metadata, match.path)) {
throw new Error("npm 包内的 skill 由包管理器控制,无法在此禁用");
}
if (!match && !disabledOnly) {
throw new Error("未找到对应 skill");
}
if (!findMovableSkill(pathValue, repoRoot, agentDir)) {
throw new Error("仅支持切换 skills 目录下的 skill");
}
const newPath = toggleSkillByMove(pathValue, enabled, repoRoot, agentDir, settingsManager);
const meta = readSkillMeta(newPath);
const scope = match?.metadata.scope || disabledOnly?.scope || "";
const source = match?.metadata.source || disabledOnly?.source || "auto";
return {
path: newPath,
enabled,
toggleable: true,
name: meta.name,
description: meta.description,
scope,
source,
};
}

View File

@@ -0,0 +1,246 @@
import { BUILTIN_SLASH_COMMANDS } from "../../../../../../packages/coding-agent/src/core/slash-commands.ts";
const BUILTIN_NAMES = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
/** Built-in slash commands that WebUI executes (not TUI-only hints). */
export const WEBUI_BUILTIN_SLASH_NAMES = new Set([
"compact",
"new",
"reload",
"clone",
"name",
"model",
"session",
"export",
"copy",
]);
export function isWebUiSlashCommand(source: "builtin" | "extension" | "prompt" | "skill", name: string): boolean {
if (source === "extension" || source === "prompt" || source === "skill") {
return true;
}
return WEBUI_BUILTIN_SLASH_NAMES.has(name);
}
export interface SlashCommandEntry {
name: string;
description: string;
source: "builtin" | "extension" | "prompt" | "skill";
}
export function filterWebUiSlashCommands(commands: SlashCommandEntry[]): SlashCommandEntry[] {
return commands.filter((command) => isWebUiSlashCommand(command.source, command.name));
}
export interface SlashDispatchResult {
handled: boolean;
message?: string;
action?: "new_session" | "reload_messages" | "reload_sessions" | "copy";
sessionFile?: string;
}
type SendCmd = (command: Record<string, unknown>) => Promise<any>;
interface ParsedSlash {
name: string;
args: string;
}
function parseSlashInput(text: string): ParsedSlash | null {
const trimmed = text.trim();
if (!trimmed.startsWith("/")) return null;
const body = trimmed.slice(1);
const spaceIndex = body.indexOf(" ");
if (spaceIndex === -1) {
return { name: body, args: "" };
}
return {
name: body.slice(0, spaceIndex),
args: body.slice(spaceIndex + 1).trim(),
};
}
function webuiOnlyMessage(command: string): SlashDispatchResult {
const hints: Record<string, string> = {
settings: "请使用 WebUI 顶栏或设置页修改配置。",
"scoped-models": "该命令仅在终端交互模式可用。",
changelog: "该命令仅在终端交互模式可用。",
hotkeys: "该命令仅在终端交互模式可用。",
fork: "该命令仅在终端交互模式可用(需选择消息节点)。",
tree: "该命令仅在终端交互模式可用。",
login: "请在 pi 终端模式中执行 /login 配置认证。",
logout: "请在 pi 终端模式中执行 /logout 移除认证。",
resume: "请使用左侧会话列表切换会话。",
import: "请使用终端模式 /import或通过会话列表管理历史会话。",
share: "该命令仅在终端交互模式可用。",
quit: "WebUI 不会退出 pi 进程。",
};
return {
handled: true,
message: hints[command] || "该命令在 WebUI 中不可用,请在终端模式使用。",
};
}
async function findModelMatch(sendCmd: SendCmd, searchTerm: string): Promise<{ provider: string; modelId: string } | null> {
const response = await sendCmd({ type: "get_available_models" });
if (!response.success) return null;
const models = response.data?.models || [];
const term = searchTerm.trim().toLowerCase();
if (!term) return null;
if (term.includes("/")) {
const slashIndex = term.indexOf("/");
const provider = term.slice(0, slashIndex);
const modelId = term.slice(slashIndex + 1);
const match = models.find(
(model: any) =>
String(model.provider || "").toLowerCase() === provider &&
String(model.id || "").toLowerCase() === modelId,
);
if (match) {
return { provider: match.provider, modelId: match.id };
}
}
const byId = models.filter((model: any) => String(model.id || "").toLowerCase() === term);
if (byId.length === 1) {
return { provider: byId[0].provider, modelId: byId[0].id };
}
return null;
}
export function isBuiltinSlashCommand(text: string): boolean {
const parsed = parseSlashInput(text);
return parsed ? BUILTIN_NAMES.has(parsed.name) : false;
}
export async function dispatchSlashCommand(text: string, sendCmd: SendCmd): Promise<SlashDispatchResult | null> {
const parsed = parseSlashInput(text);
if (!parsed) return null;
if (!BUILTIN_NAMES.has(parsed.name)) return null;
const { name, args } = parsed;
switch (name) {
case "compact": {
const response = await sendCmd({ type: "compact", customInstructions: args || undefined });
if (!response.success) throw new Error(response.error || "压缩失败");
return { handled: true, action: "reload_messages" };
}
case "new": {
const response = await sendCmd({ type: "new_session" });
if (!response.success) throw new Error(response.error || "新建会话失败");
if (response.data?.cancelled) {
return { handled: true, message: "已取消新建会话" };
}
const state = await sendCmd({ type: "get_state" });
if (!state.success) throw new Error(state.error || "读取会话状态失败");
return {
handled: true,
action: "new_session",
sessionFile: state.data?.sessionFile,
message: "已开始新会话",
};
}
case "reload": {
const response = await sendCmd({ type: "reload" });
if (!response.success) throw new Error(response.error || "重新加载失败");
return { handled: true, action: "reload_sessions", message: "已重新加载配置与扩展" };
}
case "clone": {
const response = await sendCmd({ type: "clone" });
if (!response.success) throw new Error(response.error || "克隆会话失败");
const state = await sendCmd({ type: "get_state" });
return {
handled: true,
action: "new_session",
sessionFile: state.success ? state.data?.sessionFile : undefined,
message: "已克隆当前会话",
};
}
case "name": {
if (!args) {
const state = await sendCmd({ type: "get_state" });
if (!state.success) throw new Error(state.error || "读取会话状态失败");
const currentName = state.data?.sessionName;
return {
handled: true,
message: currentName ? `当前会话名称:${currentName}` : "用法:/name <名称>",
};
}
const response = await sendCmd({ type: "set_session_name", name: args });
if (!response.success) throw new Error(response.error || "设置会话名称失败");
return { handled: true, message: `会话名称已设为:${args}` };
}
case "model": {
if (!args) {
return { handled: true, message: "请使用顶栏模型选择器切换模型,或输入 /model provider/modelId。" };
}
const match = await findModelMatch(sendCmd, args);
if (!match) {
throw new Error(`未找到模型:${args}`);
}
const response = await sendCmd({ type: "set_model", provider: match.provider, modelId: match.modelId });
if (!response.success) throw new Error(response.error || "切换模型失败");
return { handled: true, message: `已切换模型:${match.provider}/${match.modelId}` };
}
case "session": {
const response = await sendCmd({ type: "get_session_stats" });
if (!response.success) throw new Error(response.error || "读取会话信息失败");
const stats = response.data || {};
const state = await sendCmd({ type: "get_state" });
const sessionName = state.success ? state.data?.sessionName : undefined;
const lines = [
sessionName ? `名称:${sessionName}` : null,
stats.sessionFile ? `文件:${stats.sessionFile}` : null,
stats.sessionId ? `ID${stats.sessionId}` : null,
`用户消息:${stats.userMessages ?? 0}`,
`助手消息:${stats.assistantMessages ?? 0}`,
`工具调用:${stats.toolCalls ?? 0}`,
`总计:${stats.totalMessages ?? 0}`,
].filter(Boolean);
return { handled: true, message: lines.join("\n") };
}
case "export": {
const response = await sendCmd({ type: "export_html", outputPath: args || undefined });
if (!response.success) throw new Error(response.error || "导出失败");
const outputPath = response.data?.path || response.data?.filePath || args || "默认路径";
return { handled: true, message: `会话已导出:${outputPath}` };
}
case "copy": {
const response = await sendCmd({ type: "get_last_assistant_text" });
if (!response.success) throw new Error(response.error || "读取助手消息失败");
const copyText = String(response.data?.text || "").trim();
if (!copyText) {
return { handled: true, message: "暂无可复制的助手消息。" };
}
return { handled: true, action: "copy", message: copyText };
}
case "settings":
case "scoped-models":
case "changelog":
case "hotkeys":
case "fork":
case "tree":
case "login":
case "logout":
case "resume":
case "import":
case "share":
case "quit":
return webuiOnlyMessage(name);
default:
return null;
}
}

View File

@@ -0,0 +1,31 @@
import type { ServerResponse } from "node:http";
import type { WebUiPaths } from "../config/paths.ts";
import type { WebuiDbInfo } from "../db/index.ts";
export type SendCmd = (command: Record<string, unknown>) => Promise<any>;
export interface SubmitPromptOptions {
message: string;
images?: Array<{ type: "image"; data: string; mimeType: string }>;
}
export interface RunSnapshot {
isStreaming: boolean;
sessionFile?: string;
replay: Record<string, unknown>[];
}
export interface WebUiContext {
config: {
paths: WebUiPaths;
port: number;
};
rpc: {
sendCmd: SendCmd;
submitPrompt: (options: SubmitPromptOptions) => void;
getRunSnapshot: () => RunSnapshot;
connectSseClient: (res: ServerResponse) => void;
removeSseClient: (res: ServerResponse) => void;
};
db: WebuiDbInfo;
}