feat(sproutclaw): modularize webui, extensions, and agent config layout
Some checks failed
CI / build-check-test (push) Has been cancelled
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:
9
.pi/agent/extensions/webui/backend/config/cli.ts
Normal file
9
.pi/agent/extensions/webui/backend/config/cli.ts
Normal 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;
|
||||
}
|
||||
56
.pi/agent/extensions/webui/backend/config/paths.ts
Normal file
56
.pi/agent/extensions/webui/backend/config/paths.ts
Normal 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" };
|
||||
}
|
||||
224
.pi/agent/extensions/webui/backend/db/index.ts
Normal file
224
.pi/agent/extensions/webui/backend/db/index.ts
Normal 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);
|
||||
}
|
||||
16
.pi/agent/extensions/webui/backend/http/cors.ts
Normal file
16
.pi/agent/extensions/webui/backend/http/cors.ts
Normal 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;
|
||||
}
|
||||
23
.pi/agent/extensions/webui/backend/http/request.ts
Normal file
23
.pi/agent/extensions/webui/backend/http/request.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
36
.pi/agent/extensions/webui/backend/http/server.ts
Normal file
36
.pi/agent/extensions/webui/backend/http/server.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
83
.pi/agent/extensions/webui/backend/http/static.ts
Normal file
83
.pi/agent/extensions/webui/backend/http/static.ts
Normal 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);
|
||||
}
|
||||
83
.pi/agent/extensions/webui/backend/main.ts
Normal file
83
.pi/agent/extensions/webui/backend/main.ts
Normal 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}`);
|
||||
});
|
||||
76
.pi/agent/extensions/webui/backend/routes/chat.ts
Normal file
76
.pi/agent/extensions/webui/backend/routes/chat.ts
Normal 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;
|
||||
}
|
||||
19
.pi/agent/extensions/webui/backend/routes/commands.ts
Normal file
19
.pi/agent/extensions/webui/backend/routes/commands.ts
Normal 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;
|
||||
}
|
||||
43
.pi/agent/extensions/webui/backend/routes/models.ts
Normal file
43
.pi/agent/extensions/webui/backend/routes/models.ts
Normal 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;
|
||||
}
|
||||
124
.pi/agent/extensions/webui/backend/routes/sessions.ts
Normal file
124
.pi/agent/extensions/webui/backend/routes/sessions.ts
Normal 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;
|
||||
}
|
||||
206
.pi/agent/extensions/webui/backend/routes/settings.ts
Normal file
206
.pi/agent/extensions/webui/backend/routes/settings.ts
Normal 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;
|
||||
}
|
||||
70
.pi/agent/extensions/webui/backend/routes/webui-config.ts
Normal file
70
.pi/agent/extensions/webui/backend/routes/webui-config.ts
Normal 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;
|
||||
}
|
||||
203
.pi/agent/extensions/webui/backend/rpc/pi-client.ts
Normal file
203
.pi/agent/extensions/webui/backend/rpc/pi-client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
32
.pi/agent/extensions/webui/backend/services/avatars.ts
Normal file
32
.pi/agent/extensions/webui/backend/services/avatars.ts
Normal 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;
|
||||
}
|
||||
24
.pi/agent/extensions/webui/backend/services/chat-images.ts
Normal file
24
.pi/agent/extensions/webui/backend/services/chat-images.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
24
.pi/agent/extensions/webui/backend/services/models-config.ts
Normal file
24
.pi/agent/extensions/webui/backend/services/models-config.ts
Normal 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");
|
||||
}
|
||||
250
.pi/agent/extensions/webui/backend/services/sessions.ts
Normal file
250
.pi/agent/extensions/webui/backend/services/sessions.ts
Normal 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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
13
.pi/agent/extensions/webui/backend/services/system-prompt.ts
Normal file
13
.pi/agent/extensions/webui/backend/services/system-prompt.ts
Normal 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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
102
.pi/agent/extensions/webui/backend/settings/extensions-paths.ts
Normal file
102
.pi/agent/extensions/webui/backend/settings/extensions-paths.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
245
.pi/agent/extensions/webui/backend/settings/mcp-settings.ts
Normal file
245
.pi/agent/extensions/webui/backend/settings/mcp-settings.ts
Normal 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;
|
||||
}
|
||||
452
.pi/agent/extensions/webui/backend/settings/skills-settings.ts
Normal file
452
.pi/agent/extensions/webui/backend/settings/skills-settings.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
246
.pi/agent/extensions/webui/backend/slash/dispatch.ts
Normal file
246
.pi/agent/extensions/webui/backend/slash/dispatch.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
.pi/agent/extensions/webui/backend/types/context.ts
Normal file
31
.pi/agent/extensions/webui/backend/types/context.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user