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:
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* sproutclaw / mengya 命令安装扩展
|
||||
*
|
||||
* 启动后自动在 /usr/local/bin/ 下创建 mengya 和 sproutclaw 命令,
|
||||
* 方便快速启动 sproutclaw(cd 到项目目录并执行 ./pi-test.sh)。
|
||||
*
|
||||
* 命令 /install-commands 可随时手动重装。
|
||||
*/
|
||||
|
||||
import { existsSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const SPROUTCLAW_DIR = "/shumengya/project/agent/sproutclaw";
|
||||
const BIN_DIR = "/usr/local/bin";
|
||||
const COMMANDS = ["mengya", "sproutclaw"];
|
||||
|
||||
/** 创建或修复命令脚本 */
|
||||
function installCommand(name: string): boolean {
|
||||
const path = `${BIN_DIR}/${name}`;
|
||||
|
||||
const script = `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd ${SPROUTCLAW_DIR}
|
||||
exec ./pi-test.sh "$@"
|
||||
`;
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const current = readFileSync(path, "utf-8");
|
||||
if (current === script) return false;
|
||||
} catch {
|
||||
// Rewrite unreadable or invalid command files below.
|
||||
}
|
||||
}
|
||||
writeFileSync(path, script, "utf-8");
|
||||
chmodSync(path, 0o755);
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// 启动时自动安装
|
||||
for (const name of COMMANDS) {
|
||||
const created = installCommand(name);
|
||||
if (created) {
|
||||
console.log(`[sproutclaw-setup] Created /usr/local/bin/${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 命令:手动重装
|
||||
pi.registerCommand("install-commands", {
|
||||
description: "Install mengya and sproutclaw commands to /usr/local/bin",
|
||||
handler: async (_args, ctx) => {
|
||||
let count = 0;
|
||||
for (const name of COMMANDS) {
|
||||
if (installCommand(name)) count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
ctx.ui.notify(`Installed ${count} command(s): mengya, sproutclaw`, "success");
|
||||
} else {
|
||||
ctx.ui.notify("Both commands already exist", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
107
.pi/agent/extensions/sproutclaw-setup/index.ts
Normal file
107
.pi/agent/extensions/sproutclaw-setup/index.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* sproutclaw / mengya 命令安装扩展
|
||||
*
|
||||
* 启动后自动在 /usr/local/bin/ 下创建 mengya 和 sproutclaw 命令:
|
||||
* - mengya:源码版(./pi-test.sh,tsx + src)
|
||||
* - sproutclaw:构建版(./pi-built.sh,node dist/cli.js)
|
||||
*
|
||||
* 命令 /install-commands 可随时手动重装。
|
||||
*/
|
||||
|
||||
import { existsSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const SPROUTCLAW_DIR = "/shumengya/project/agent/sproutclaw";
|
||||
const BIN_DIR = "/usr/local/bin";
|
||||
|
||||
const COMMAND_LAUNCHERS: Record<string, string> = {
|
||||
mengya: "./pi-test.sh",
|
||||
};
|
||||
|
||||
function commandScript(launcher: string): string {
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd ${SPROUTCLAW_DIR}
|
||||
exec ${launcher} "$@"
|
||||
`;
|
||||
}
|
||||
|
||||
function sproutclawScript(): string {
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd ${SPROUTCLAW_DIR}
|
||||
|
||||
case "\${1:-}" in
|
||||
\tbuild)
|
||||
\t\tshift
|
||||
\t\texec npm run build "\$@"
|
||||
\t\t;;
|
||||
\trun)
|
||||
\t\tshift
|
||||
\t\texec ./pi-built.sh "\$@"
|
||||
\t\t;;
|
||||
\thelp|-h|--help)
|
||||
\t\tcat <<'EOF'
|
||||
sproutclaw - SproutClaw pi 构建版启动器
|
||||
|
||||
用法:
|
||||
sproutclaw 启动 pi(构建版)
|
||||
sproutclaw run [args] 同 sproutclaw
|
||||
sproutclaw build 从源码构建所有包
|
||||
|
||||
其余参数透传给 pi,例如: sproutclaw --version
|
||||
EOF
|
||||
\t\t;;
|
||||
\t*)
|
||||
\t\texec ./pi-built.sh "\$@"
|
||||
\t\t;;
|
||||
esac
|
||||
`;
|
||||
}
|
||||
|
||||
/** 创建或修复命令脚本 */
|
||||
function installCommand(name: string, script: string): boolean {
|
||||
const path = `${BIN_DIR}/${name}`;
|
||||
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const current = readFileSync(path, "utf-8");
|
||||
if (current === script) return false;
|
||||
} catch {
|
||||
// Rewrite unreadable or invalid command files below.
|
||||
}
|
||||
}
|
||||
writeFileSync(path, script, "utf-8");
|
||||
chmodSync(path, 0o755);
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
for (const [name, launcher] of Object.entries(COMMAND_LAUNCHERS)) {
|
||||
const created = installCommand(name, commandScript(launcher));
|
||||
if (created) {
|
||||
console.log(`[sproutclaw-setup] Created /usr/local/bin/${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sproutclawCreated = installCommand("sproutclaw", sproutclawScript());
|
||||
if (sproutclawCreated) {
|
||||
console.log("[sproutclaw-setup] Created /usr/local/bin/sproutclaw");
|
||||
}
|
||||
|
||||
pi.registerCommand("install-commands", {
|
||||
description: "Install mengya (source) and sproutclaw (built) commands to /usr/local/bin",
|
||||
handler: async (_args, ctx) => {
|
||||
let count = 0;
|
||||
for (const [name, launcher] of Object.entries(COMMAND_LAUNCHERS)) {
|
||||
if (installCommand(name, commandScript(launcher))) count++;
|
||||
}
|
||||
if (installCommand("sproutclaw", sproutclawScript())) count++;
|
||||
if (count > 0) {
|
||||
ctx.ui.notify(`Installed ${count} command(s): mengya (source), sproutclaw (build/run)`, "success");
|
||||
} else {
|
||||
ctx.ui.notify("Commands already up to date", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -65,6 +65,10 @@ function keyTextOr(action: string, fallback: string): string {
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
function renderDivider(theme: { fg: (color: string, text: string) => string }, width: number): string {
|
||||
return theme.fg("dim", "─".repeat(Math.max(1, width)));
|
||||
}
|
||||
|
||||
function renderSproutclawLogo(theme: any, width: number): string[] {
|
||||
if (width < 96) {
|
||||
return [
|
||||
@@ -111,13 +115,6 @@ export default function (pi: ExtensionAPI) {
|
||||
keyHint("app.clipboard.pasteImage", "粘贴图片"),
|
||||
rawKeyHint("drop files", "附加文件"),
|
||||
].join("\n");
|
||||
const compactInstructions = [
|
||||
keyHint("app.interrupt", "中断"),
|
||||
rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "清空/退出"),
|
||||
rawKeyHint("/", "命令"),
|
||||
rawKeyHint("!", "shell"),
|
||||
rawKeyHint(keyTextOr("app.tools.expand", "Ctrl+O"), "详情"),
|
||||
].join(rgb(" • ", [165, 205, 255]));
|
||||
const compactOnboarding = startupLine(
|
||||
"工作台",
|
||||
`描述要处理的代码、部署或服务器问题,我会执行命令、修改文件并同步进度。按 ${keyTextOr("app.tools.expand", "Ctrl+O")} 展开详情。`,
|
||||
@@ -132,8 +129,11 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
const divider = renderDivider(theme, width);
|
||||
const lines: string[] = [
|
||||
divider,
|
||||
...renderSproutclawLogo(theme, width),
|
||||
divider,
|
||||
rgb("萌芽运维开发 Agent 助手", [170, 235, 255]),
|
||||
"",
|
||||
];
|
||||
@@ -142,7 +142,6 @@ export default function (pi: ExtensionAPI) {
|
||||
lines.push("");
|
||||
lines.push(onboarding);
|
||||
} else {
|
||||
lines.push(compactInstructions);
|
||||
lines.push(compactOnboarding);
|
||||
lines.push("");
|
||||
lines.push(onboarding);
|
||||
6
.pi/agent/extensions/webui/.gitignore
vendored
6
.pi/agent/extensions/webui/.gitignore
vendored
@@ -1,4 +1,8 @@
|
||||
dist/
|
||||
frontend/dist/
|
||||
frontend/dist-desketop/
|
||||
frontend/.env.desktop
|
||||
data/
|
||||
webui-service.env
|
||||
*.db
|
||||
webui-settings.json.migrated
|
||||
.webui.pid
|
||||
|
||||
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" };
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
node_modules
|
||||
dist/
|
||||
dist-desketop/
|
||||
|
||||
@@ -12,15 +12,6 @@
|
||||
<title>萌小芽</title>
|
||||
<link rel="icon" href="/logo.png" type="image/png" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/lxgwwenkaimono-regular.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/lxgwwenkaimono-bold.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"react-router-dom": "7.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mogeko/maple-mono-cn": "7.9.0",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
@@ -72,7 +73,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -2630,6 +2630,13 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mogeko/maple-mono-cn": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@mogeko/maple-mono-cn/-/maple-mono-cn-7.9.0.tgz",
|
||||
"integrity": "sha512-NZls63+8Q4+17saZqhN5sFB9UqIfyI73q+NNyXNQFmdzivzSYAKLCuo2yvheyEm95kuH8VEhwTl4YvbMoqIbFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -3181,7 +3188,6 @@
|
||||
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -3250,7 +3256,6 @@
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -3442,7 +3447,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -5292,7 +5296,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -5302,7 +5305,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -6072,7 +6074,6 @@
|
||||
"integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
@@ -6360,7 +6361,6 @@
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"generate-icons": "node scripts/generate-icons.mjs",
|
||||
"dev": "vite",
|
||||
"build": "npm run generate-icons && tsc -b && vite build",
|
||||
"build": "npm run generate-icons && tsc -b && vite build && vite build --mode desktop",
|
||||
"build:web": "npm run generate-icons && tsc -b && vite build",
|
||||
"build:desktop": "npm run generate-icons && tsc -b && vite build --mode desktop",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -17,6 +19,7 @@
|
||||
"react-router-dom": "7.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mogeko/maple-mono-cn": "7.9.0",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
|
||||
@@ -12,20 +12,15 @@ export function App() {
|
||||
<BrowserRouter>
|
||||
<BootstrapProvider>
|
||||
<AvatarProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ChatProvider>
|
||||
<ChatLayout />
|
||||
</ChatProvider>
|
||||
}
|
||||
>
|
||||
<Route index element={<ChatPage />} />
|
||||
</Route>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
<PwaUpdatePrompt />
|
||||
<ChatProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<ChatLayout />}>
|
||||
<Route index element={<ChatPage />} />
|
||||
</Route>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
<PwaUpdatePrompt />
|
||||
</ChatProvider>
|
||||
</AvatarProvider>
|
||||
</BootstrapProvider>
|
||||
</BrowserRouter>
|
||||
|
||||
13
.pi/agent/extensions/webui/frontend/src/api/base.ts
Normal file
13
.pi/agent/extensions/webui/frontend/src/api/base.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE ?? "").trim().replace(/\/$/, "");
|
||||
|
||||
/** Resolve an API path. Empty base keeps same-origin relative paths for web `dist/`. */
|
||||
export function apiUrl(path: string): string {
|
||||
if (!path.startsWith("/")) {
|
||||
throw new Error(`API path must start with /: ${path}`);
|
||||
}
|
||||
return API_BASE ? `${API_BASE}${path}` : path;
|
||||
}
|
||||
|
||||
export function getApiBase(): string {
|
||||
return API_BASE;
|
||||
}
|
||||
@@ -1,10 +1,26 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
import type { ImageContent, ModelInfo, SessionState } from "../types/message";
|
||||
import type { BashResult } from "../utils/bash";
|
||||
import type { NewSessionResponse } from "../types/session";
|
||||
|
||||
export function sendChat(message: string, images?: ImageContent[]): Promise<{ ok: boolean }> {
|
||||
export interface ChatResponse {
|
||||
ok?: boolean;
|
||||
accepted?: boolean;
|
||||
slash?: boolean;
|
||||
message?: string;
|
||||
action?: "new_session" | "reload_messages" | "reload_sessions" | "copy";
|
||||
sessionFile?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function sendChat(message: string, images?: ImageContent[]): Promise<ChatResponse> {
|
||||
return apiPost("/api/chat", { message, images });
|
||||
}
|
||||
|
||||
export function runBash(command: string): Promise<BashResult> {
|
||||
return apiPost("/api/bash", { command });
|
||||
}
|
||||
|
||||
export function abortChat(): Promise<{ ok: boolean }> {
|
||||
return apiPost("/api/abort");
|
||||
}
|
||||
@@ -25,6 +41,6 @@ export function setThinkingLevel(level: string): Promise<{ ok: boolean }> {
|
||||
return apiPost("/api/thinking", { level });
|
||||
}
|
||||
|
||||
export function createNewSession(): Promise<{ sessionFile?: string }> {
|
||||
export function createNewSession(): Promise<NewSessionResponse> {
|
||||
return apiPost("/api/new-session");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { apiUrl } from "./base";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -9,7 +11,7 @@ export class ApiError extends Error {
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
const res = await fetch(apiUrl(url), init);
|
||||
const data = (await res.json().catch(() => ({}))) as T & { error?: string };
|
||||
if (!res.ok || (data && "error" in data && data.error)) {
|
||||
throw new ApiError((data as { error?: string }).error || res.statusText, res.status);
|
||||
|
||||
6
.pi/agent/extensions/webui/frontend/src/api/commands.ts
Normal file
6
.pi/agent/extensions/webui/frontend/src/api/commands.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { apiGet } from "./client";
|
||||
import type { SlashCommandsResponse } from "../types/commands";
|
||||
|
||||
export function fetchSlashCommands(): Promise<SlashCommandsResponse> {
|
||||
return apiGet("/api/commands");
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
import { apiUrl } from "./base";
|
||||
import type { SessionState } from "../types/message";
|
||||
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
|
||||
|
||||
export function fetchSessions(): Promise<{ sessions: SessionSummary[] }> {
|
||||
@@ -9,21 +11,26 @@ export function fetchSessionHistory(path: string): Promise<SessionHistoryPayload
|
||||
return apiPost("/api/sessions/history", { path });
|
||||
}
|
||||
|
||||
export async function activateSessionBackend(path: string): Promise<void> {
|
||||
const activateRes = await fetch("/api/sessions/activate", {
|
||||
export async function activateSessionBackend(path: string): Promise<SessionState | null> {
|
||||
const activateRes = await fetch(apiUrl("/api/sessions/activate"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
const activateData = (await activateRes.json().catch(() => ({}))) as { error?: string };
|
||||
if (activateRes.ok && !activateData.error) return;
|
||||
const activateData = (await activateRes.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
state?: SessionState;
|
||||
};
|
||||
if (activateRes.ok && !activateData.error) {
|
||||
return activateData.state ?? null;
|
||||
}
|
||||
|
||||
const activateError = activateData.error || activateRes.statusText || "Unknown error";
|
||||
if (activateRes.status !== 404 && !/not found/i.test(activateError)) {
|
||||
throw new Error(activateError);
|
||||
}
|
||||
|
||||
const fallbackRes = await fetch("/api/sessions/load", {
|
||||
const fallbackRes = await fetch(apiUrl("/api/sessions/load"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
@@ -32,6 +39,7 @@ export async function activateSessionBackend(path: string): Promise<void> {
|
||||
if (!fallbackRes.ok || fallbackData.error) {
|
||||
throw new Error(fallbackData.error || fallbackRes.statusText);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadSession(path: string): Promise<{ ok?: boolean; error?: string }> {
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
import type { AvatarSettings, SettingsData } from "../types/events";
|
||||
import type { AvatarSettings, ExtensionInfo, McpServerInfo, McpToolInfo, SettingsData, SkillInfo } from "../types/events";
|
||||
|
||||
export function fetchSettings(): Promise<SettingsData> {
|
||||
return apiGet("/api/settings");
|
||||
}
|
||||
|
||||
export function toggleSkill(path: string, enabled: boolean): Promise<{ ok?: boolean; skill?: SkillInfo }> {
|
||||
return apiPost("/api/settings/skills/toggle", { path, enabled });
|
||||
}
|
||||
|
||||
export function toggleExtension(
|
||||
path: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ ok?: boolean; extension?: ExtensionInfo }> {
|
||||
return apiPost("/api/settings/extensions/toggle", { path, enabled });
|
||||
}
|
||||
|
||||
export function toggleMcpServer(
|
||||
server: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ ok?: boolean; server?: McpServerInfo }> {
|
||||
return apiPost("/api/settings/mcp/server/toggle", { server, enabled });
|
||||
}
|
||||
|
||||
export function toggleMcpTool(
|
||||
server: string,
|
||||
tool: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ ok?: boolean; tool?: McpToolInfo }> {
|
||||
return apiPost("/api/settings/mcp/tool/toggle", { server, tool, enabled });
|
||||
}
|
||||
|
||||
export function fetchAvatars(): Promise<AvatarSettings> {
|
||||
return apiGet("/api/avatars");
|
||||
}
|
||||
@@ -17,6 +43,10 @@ export function saveSystemPrompt(systemPrompt: string): Promise<{ systemPromptPa
|
||||
return apiPost("/api/settings/system-prompt", { systemPrompt });
|
||||
}
|
||||
|
||||
export function saveModelsConfig(modelsConfig: string): Promise<{ modelsConfigPath?: string }> {
|
||||
return apiPost("/api/settings/models-config", { modelsConfig });
|
||||
}
|
||||
|
||||
export function saveAvatars(avatars: AvatarSettings): Promise<AvatarSettings & { ok?: boolean }> {
|
||||
return apiPost("/api/settings/avatars", avatars);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
.block {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e8ecf0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
font-family: var(--font-mono);
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
color: #6366f1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.command {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-size: inherit;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.output {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: #4b5563;
|
||||
background: #fff;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
max-height: min(420px, 50vh);
|
||||
}
|
||||
|
||||
.outputEmpty {
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.outputStreaming::after {
|
||||
content: "▊";
|
||||
animation: blink 0.8s step-end infinite;
|
||||
color: #6366f1;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.exit {
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
font-family: var(--font-mono);
|
||||
font-size: inherit;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import styles from "./BashOutputBlock.module.css";
|
||||
|
||||
interface BashOutputBlockProps {
|
||||
command?: string;
|
||||
content: string;
|
||||
exitCode?: number;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
export function BashOutputBlock({ command, content, exitCode, streaming }: BashOutputBlockProps) {
|
||||
const hasOutput = Boolean(content.trim());
|
||||
const showExit = exitCode !== undefined && exitCode !== 0 && !streaming;
|
||||
|
||||
return (
|
||||
<div className={styles.block}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className={styles.prompt}>$</span>
|
||||
<span className={styles.command}>{command || "shell"}</span>
|
||||
</div>
|
||||
<pre
|
||||
className={`${styles.output} ${!hasOutput ? styles.outputEmpty : ""} ${streaming ? styles.outputStreaming : ""}`}
|
||||
>
|
||||
{hasOutput ? content : streaming ? "执行中..." : "(无输出)"}
|
||||
</pre>
|
||||
{showExit ? <div className={styles.exit}>退出码 {exitCode}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useRef, useState, type ClipboardEvent, type DragEvent, type KeyboardEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent, type KeyboardEvent } from "react";
|
||||
import { fetchSlashCommands } from "../../api/commands";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import type { SlashCommand } from "../../types/commands";
|
||||
import type { ImageContent } from "../../types/message";
|
||||
import { isTouchLike } from "../../utils/format";
|
||||
import {
|
||||
@@ -8,18 +10,72 @@ import {
|
||||
imageToDataUrl,
|
||||
MAX_CHAT_IMAGES,
|
||||
} from "../../utils/images";
|
||||
import { parseBashInput } from "../../utils/bash";
|
||||
import {
|
||||
applySlashCompletion,
|
||||
filterSlashCommands,
|
||||
filterWebUiSlashCommands,
|
||||
parseSlashCommandInput,
|
||||
} from "../../utils/slashCommands";
|
||||
import { SlashCommandMenu } from "./SlashCommandMenu";
|
||||
import styles from "./ChatInput.module.css";
|
||||
|
||||
export function ChatInput() {
|
||||
const { isStreaming, sendMessage, addSystemMessage } = useChatContext();
|
||||
const { isStreaming, sendMessage, runBashCommand, addSystemMessage } = useChatContext();
|
||||
const [value, setValue] = useState("");
|
||||
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const touchLike = isTouchLike();
|
||||
|
||||
const canSend = (value.trim().length > 0 || pendingImages.length > 0) && !isStreaming;
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchSlashCommands()
|
||||
.then((data) => {
|
||||
if (!cancelled) setSlashCommands(filterWebUiSlashCommands(data.commands || []));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
addSystemMessage(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [addSystemMessage]);
|
||||
|
||||
const slashContext = useMemo(() => parseSlashCommandInput(value), [value]);
|
||||
const filteredSlashCommands = useMemo(
|
||||
() => (slashContext ? filterSlashCommands(slashCommands, slashContext.query) : []),
|
||||
[slashCommands, slashContext],
|
||||
);
|
||||
const slashMenuOpen = Boolean(slashContext && filteredSlashCommands.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSlashIndex(0);
|
||||
}, [value, slashMenuOpen]);
|
||||
|
||||
const isBashInput = Boolean(value.trim() && parseBashInput(value));
|
||||
const isSlashInput = value.trimStart().startsWith("/");
|
||||
const canSendDuringStream = isBashInput || isSlashInput;
|
||||
const canSend =
|
||||
(value.trim().length > 0 || pendingImages.length > 0) && (!isStreaming || canSendDuringStream);
|
||||
|
||||
const applySlashSelection = (command: SlashCommand) => {
|
||||
const nextValue = applySlashCompletion(value, command.name);
|
||||
setValue(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(nextValue.length, nextValue.length);
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
|
||||
});
|
||||
};
|
||||
|
||||
const addImages = async (files: FileList | File[]) => {
|
||||
const list = Array.from(files);
|
||||
@@ -50,18 +106,62 @@ export function ChatInput() {
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = value.trim();
|
||||
if ((!text && pendingImages.length === 0) || isStreaming) return;
|
||||
const isBash = Boolean(text && parseBashInput(text));
|
||||
if ((!text && pendingImages.length === 0) || (isStreaming && !isBash && !text.startsWith("/"))) return;
|
||||
|
||||
const images = pendingImages.length ? pendingImages : undefined;
|
||||
setValue("");
|
||||
setPendingImages([]);
|
||||
if (inputRef.current) inputRef.current.style.height = "auto";
|
||||
await sendMessage(text, images);
|
||||
|
||||
if (text.startsWith("!") && parseBashInput(text)) {
|
||||
await runBashCommand(text);
|
||||
} else {
|
||||
await sendMessage(text, images);
|
||||
}
|
||||
|
||||
if (touchLike) inputRef.current?.blur();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
|
||||
if (slashMenuOpen) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedSlashIndex(
|
||||
(prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const command = filteredSlashCommands[selectedSlashIndex];
|
||||
if (command) applySlashSelection(command);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setValue((prev) => {
|
||||
const trimmed = prev.trimStart();
|
||||
if (!trimmed.startsWith("/")) return prev;
|
||||
return prev.replace(/^\s*\//, "");
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const command = filteredSlashCommands[selectedSlashIndex];
|
||||
if (command) applySlashSelection(command);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
@@ -161,6 +261,13 @@ export function ChatInput() {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{slashMenuOpen ? (
|
||||
<SlashCommandMenu
|
||||
commands={filteredSlashCommands}
|
||||
selectedIndex={selectedSlashIndex}
|
||||
onSelect={applySlashSelection}
|
||||
/>
|
||||
) : null}
|
||||
<div className={styles.wrapper}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -195,7 +302,7 @@ export function ChatInput() {
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
placeholder="输入消息,Ctrl+V 粘贴图片..."
|
||||
placeholder="输入消息…"
|
||||
aria-label="消息输入框"
|
||||
enterKeyHint="send"
|
||||
inputMode="text"
|
||||
|
||||
@@ -4,69 +4,76 @@
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
background: #fff;
|
||||
border-color: rgba(37, 99, 235, 0.22);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
color: #9ca3af;
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
background: #f9fafb;
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.btnIcon {
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 148px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
|
||||
min-width: 168px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--toolbar-border);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
padding: 9px 10px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111827;
|
||||
background: rgba(37, 99, 235, 0.06);
|
||||
}
|
||||
|
||||
.menuItemDesc {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { fetchSessionHistory } from "../../api/sessions";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import type { ChatMessage } from "../../types/message";
|
||||
import { extractContent, formatToolCall, getToolCalls } from "../../utils/toolCall";
|
||||
import type { ChatMessage, ToolCallBlock } from "../../types/message";
|
||||
import { extractContent, extractThinking, formatToolCall, getToolCalls } from "../../utils/toolCall";
|
||||
import { nextMessageId } from "../../utils/format";
|
||||
import {
|
||||
downloadTextFile,
|
||||
@@ -20,23 +20,52 @@ function historyToExportMessages(
|
||||
if (m.role === "user") {
|
||||
result.push({ id: nextMessageId(), role: "user", content: extractContent(m.content) });
|
||||
} else if (m.role === "assistant") {
|
||||
const text = extractContent(m.content);
|
||||
if (text) result.push({ id: nextMessageId(), role: "assistant", content: text });
|
||||
for (const tc of getToolCalls(m.content)) {
|
||||
const tid = tc.toolCallId || tc.id;
|
||||
result.push({
|
||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||
role: "tool_call",
|
||||
content: formatToolCall(tc),
|
||||
toolCallId: tid ? String(tid) : undefined,
|
||||
});
|
||||
if (Array.isArray(m.content)) {
|
||||
for (const block of m.content) {
|
||||
if (block.type === "thinking") {
|
||||
const thinking = (block.thinking ?? "").trim();
|
||||
if (thinking) {
|
||||
result.push({ id: nextMessageId(), role: "thinking", content: thinking });
|
||||
}
|
||||
} else if (block.type === "text") {
|
||||
const text = (block.text ?? "").trim();
|
||||
if (text) {
|
||||
result.push({ id: nextMessageId(), role: "assistant", content: text });
|
||||
}
|
||||
} else if (block.type === "toolCall") {
|
||||
const tc = block as ToolCallBlock;
|
||||
const tid = tc.toolCallId || tc.id;
|
||||
result.push({
|
||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||
role: "tool_call",
|
||||
content: formatToolCall(tc),
|
||||
toolCallId: tid ? String(tid) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const thinking = extractThinking(m.content);
|
||||
if (thinking) {
|
||||
result.push({ id: nextMessageId(), role: "thinking", content: thinking });
|
||||
}
|
||||
const text = extractContent(m.content);
|
||||
if (text) result.push({ id: nextMessageId(), role: "assistant", content: text });
|
||||
for (const tc of getToolCalls(m.content)) {
|
||||
const tid = tc.toolCallId || tc.id;
|
||||
result.push({
|
||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||
role: "tool_call",
|
||||
content: formatToolCall(tc),
|
||||
toolCallId: tid ? String(tid) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ExportMenu() {
|
||||
export function ExportMenu({ compact = false }: { compact?: boolean }) {
|
||||
const { headerTitle, messages, activeSessionPath, isStreaming, addSystemMessage } = useChatContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
@@ -106,16 +135,17 @@ export function ExportMenu() {
|
||||
<div className={styles.wrap} ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btn}
|
||||
className={compact ? `${styles.btn} ${styles.btnIcon}` : styles.btn}
|
||||
disabled={!canExport}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
aria-label="导出对话"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 3v12M7 8l5 5 5-5M5 21h14" />
|
||||
</svg>
|
||||
导出
|
||||
{compact ? null : "导出"}
|
||||
</button>
|
||||
{open ? (
|
||||
<div className={styles.menu} role="menu">
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { renderMarkdown } from "../../utils/markdown";
|
||||
import { imageToDataUrl } from "../../utils/images";
|
||||
import { ToolCallBlock } from "./ToolCallBlock";
|
||||
import { ThinkingBlock } from "./ThinkingBlock";
|
||||
import { BashOutputBlock } from "./BashOutputBlock";
|
||||
import styles from "./MessageList.module.css";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
@@ -81,6 +83,21 @@ export function MessageBubble({
|
||||
}: MessageBubbleProps) {
|
||||
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
|
||||
|
||||
if (message.role === "thinking") {
|
||||
return (
|
||||
<div className={styles.assistantRow}>
|
||||
{showAgentAvatar ? (
|
||||
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
||||
) : agentAvatarSpacer ? (
|
||||
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
|
||||
) : null}
|
||||
<div className={`${styles.msg} ${styles.thinking}`}>
|
||||
<ThinkingBlock content={message.content} streaming={message.streaming} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.role === "tool_call") {
|
||||
return (
|
||||
<div className={styles.assistantRow}>
|
||||
@@ -96,6 +113,26 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
if (message.role === "bash") {
|
||||
return (
|
||||
<div className={styles.assistantRow}>
|
||||
{showAgentAvatar ? (
|
||||
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
||||
) : agentAvatarSpacer ? (
|
||||
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
|
||||
) : null}
|
||||
<div className={`${styles.msg} ${styles.bash}`}>
|
||||
<BashOutputBlock
|
||||
command={message.bashCommand}
|
||||
content={message.content}
|
||||
exitCode={message.bashExitCode}
|
||||
streaming={message.streaming}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const classNames = [styles.msg];
|
||||
if (message.role === "user") classNames.push(styles.user);
|
||||
if (message.role === "assistant") classNames.push(styles.assistant);
|
||||
|
||||
@@ -201,15 +201,42 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
line-height: 1.46;
|
||||
color: #666;
|
||||
padding: 0;
|
||||
margin: 0 0 4px;
|
||||
background: #fafafa;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #eee;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.thinking {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 16px;
|
||||
line-height: 1.46;
|
||||
padding: 0;
|
||||
margin: 0 0 4px;
|
||||
background: #faf8fc;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #ece6f5;
|
||||
}
|
||||
|
||||
.bash {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 16px;
|
||||
line-height: 1.46;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #e8ecf0;
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.streaming::after {
|
||||
@@ -375,10 +402,6 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.toolCall {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistantAvatarSpacer {
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
@@ -73,14 +73,18 @@ export function MessageList() {
|
||||
<div className={styles.chat} ref={chatRef}>
|
||||
{messages.map((msg, index) => {
|
||||
const prev = messages[index - 1];
|
||||
const prevIsAgentSide =
|
||||
prev?.role === "assistant" ||
|
||||
prev?.role === "thinking" ||
|
||||
prev?.role === "tool_call" ||
|
||||
prev?.role === "bash";
|
||||
const showAgentAvatar =
|
||||
msg.role === "assistant" ||
|
||||
(msg.role === "tool_call" &&
|
||||
prev?.role !== "tool_call" &&
|
||||
prev?.role !== "assistant");
|
||||
msg.role === "thinking" ||
|
||||
((msg.role === "tool_call" || msg.role === "bash") && !prevIsAgentSide);
|
||||
const agentAvatarSpacer =
|
||||
msg.role === "tool_call" &&
|
||||
(prev?.role === "tool_call" || prev?.role === "assistant");
|
||||
(msg.role === "tool_call" || msg.role === "bash" || msg.role === "thinking") &&
|
||||
prevIsAgentSide;
|
||||
|
||||
return (
|
||||
<MessageBubble
|
||||
|
||||
@@ -1,61 +1,105 @@
|
||||
.bar {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.42;
|
||||
color: #5c6578;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.left {
|
||||
flex: 0 1 auto;
|
||||
max-width: 100%;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-break: break-word;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: #8b95a8;
|
||||
.barCompact {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.secondary:empty {
|
||||
.compactLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.compactLine::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compactStats {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chip,
|
||||
.ctxChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.ctxChip {
|
||||
color: #1e40af;
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
border: 1px solid rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.barCompact .ctxChip {
|
||||
flex: 0 0 auto;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ctxWarn {
|
||||
color: #b45309;
|
||||
font-weight: 500;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border-color: rgba(245, 158, 11, 0.18);
|
||||
}
|
||||
|
||||
.ctxDanger {
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.16);
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bar {
|
||||
font-size: 10px;
|
||||
.barCompact {
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
font-size: 9px;
|
||||
.compactLine {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useChatContext } from "../../context/ChatContext";
|
||||
import { formatFooterTokens } from "../../utils/format";
|
||||
import styles from "./SessionContextBar.module.css";
|
||||
|
||||
export function SessionContextBar() {
|
||||
interface SessionContextBarProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function SessionContextBar({ compact = false }: SessionContextBarProps) {
|
||||
const { sessionState, isStreaming } = useChatContext();
|
||||
const data = sessionState;
|
||||
|
||||
@@ -11,46 +15,62 @@ export function SessionContextBar() {
|
||||
}
|
||||
|
||||
const tok = data.stats.tokens || {};
|
||||
const parts: string[] = [];
|
||||
if (tok.input) parts.push(`↑${formatFooterTokens(tok.input)}`);
|
||||
if (tok.output) parts.push(`↓${formatFooterTokens(tok.output)}`);
|
||||
if (tok.cacheRead) parts.push(`R${formatFooterTokens(tok.cacheRead)}`);
|
||||
if (tok.cacheWrite) parts.push(`W${formatFooterTokens(tok.cacheWrite)}`);
|
||||
const chips: string[] = [];
|
||||
if (tok.input) chips.push(`↑${formatFooterTokens(tok.input)}`);
|
||||
if (tok.output) chips.push(`↓${formatFooterTokens(tok.output)}`);
|
||||
if (tok.cacheRead) chips.push(`R${formatFooterTokens(tok.cacheRead)}`);
|
||||
if (tok.cacheWrite) chips.push(`W${formatFooterTokens(tok.cacheWrite)}`);
|
||||
|
||||
const costNum = Number(data.stats.cost ?? 0);
|
||||
parts.push(`$${costNum.toFixed(3)}`);
|
||||
chips.push(`$${costNum.toFixed(3)}`);
|
||||
|
||||
const cx = data.stats.contextUsage;
|
||||
const cw = cx?.contextWindow ?? data.model.contextWindow ?? 0;
|
||||
const pctRaw = cx?.percent;
|
||||
const pctStr = pctRaw != null ? Number(pctRaw).toFixed(1) : "?";
|
||||
const pctNum = pctRaw != null ? Number(pctRaw) : null;
|
||||
const autoInd = data.autoCompactionEnabled ? " (auto)" : "";
|
||||
const autoInd = data.autoCompactionEnabled ? " auto" : "";
|
||||
const ctxTxt =
|
||||
pctStr === "?" && cw
|
||||
? `?/${formatFooterTokens(cw)}${autoInd}`
|
||||
: `${pctStr}%/${formatFooterTokens(cw)}${autoInd}`;
|
||||
|
||||
const liveStreaming = isStreaming || data.isStreaming;
|
||||
let sub = "";
|
||||
if (data.isCompacting) sub = "⚡ 正在整理上下文…";
|
||||
else if (liveStreaming) sub = "⋯ 回复中…";
|
||||
else if ((data.turnIndex ?? 0) > 0) sub = `✓ Turn ${data.turnIndex} complete`;
|
||||
let status = "";
|
||||
if (!compact) {
|
||||
if (data.isCompacting) status = "整理上下文";
|
||||
else if (liveStreaming) status = "生成中";
|
||||
else if ((data.turnIndex ?? 0) > 0) status = `Turn ${data.turnIndex}`;
|
||||
}
|
||||
|
||||
let ctxClass = "";
|
||||
let ctxClass = styles.ctxChip;
|
||||
if (pctNum != null) {
|
||||
if (pctNum > 90) ctxClass = styles.ctxDanger;
|
||||
else if (pctNum > 70) ctxClass = styles.ctxWarn;
|
||||
if (pctNum > 90) ctxClass = `${styles.ctxChip} ${styles.ctxDanger}`;
|
||||
else if (pctNum > 70) ctxClass = `${styles.ctxChip} ${styles.ctxWarn}`;
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`${styles.bar} ${styles.barCompact}`}>
|
||||
<div className={styles.compactLine}>
|
||||
<span className={styles.compactStats}>{chips.join(" ")}</span>
|
||||
<span className={ctxClass}>{ctxTxt}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.left}>
|
||||
{parts.join(" ")} <span className={ctxClass}>{ctxTxt}</span>
|
||||
</div>
|
||||
<div className={styles.chips}>
|
||||
{chips.map((chip) => (
|
||||
<span key={chip} className={styles.chip}>
|
||||
{chip}
|
||||
</span>
|
||||
))}
|
||||
<span className={ctxClass}>{ctxTxt}</span>
|
||||
</div>
|
||||
{sub ? <div className={styles.secondary}>{sub}</div> : null}
|
||||
{status ? <span className={styles.status}>{status}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
.menu {
|
||||
max-width: 720px;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, auto) 1fr auto;
|
||||
gap: 8px 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item:hover,
|
||||
.itemActive {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.source {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { SlashCommand } from "../../types/commands";
|
||||
import { slashCommandSourceLabel } from "../../utils/slashCommands";
|
||||
import styles from "./SlashCommandMenu.module.css";
|
||||
|
||||
interface SlashCommandMenuProps {
|
||||
commands: SlashCommand[];
|
||||
selectedIndex: number;
|
||||
onSelect: (command: SlashCommand) => void;
|
||||
}
|
||||
|
||||
export function SlashCommandMenu({ commands, selectedIndex, onSelect }: SlashCommandMenuProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const item = list.children[selectedIndex] as HTMLElement | undefined;
|
||||
item?.scrollIntoView({ block: "nearest" });
|
||||
}, [selectedIndex, commands.length]);
|
||||
|
||||
if (!commands.length) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.menu} role="listbox" aria-label="斜杠命令">
|
||||
<div ref={listRef} className={styles.list}>
|
||||
{commands.map((command, index) => (
|
||||
<button
|
||||
key={command.name}
|
||||
type="button"
|
||||
className={`${styles.item} ${index === selectedIndex ? styles.itemActive : ""}`}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(command);
|
||||
}}
|
||||
>
|
||||
<span className={styles.name}>/{command.name}</span>
|
||||
{command.description ? (
|
||||
<span className={styles.description}>{command.description}</span>
|
||||
) : null}
|
||||
<span className={styles.source}>{slashCommandSourceLabel(command.source)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
.block {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
user-select: none;
|
||||
font-weight: 500;
|
||||
color: #7c6a9a;
|
||||
}
|
||||
|
||||
.summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary::before {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid #a894c6;
|
||||
border-top: 3.5px solid transparent;
|
||||
border-bottom: 3.5px solid transparent;
|
||||
flex-shrink: 0;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.block[open] > .summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
flex-shrink: 0;
|
||||
font-size: inherit;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-style: italic;
|
||||
color: #8b7aa8;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 0;
|
||||
padding: 6px 10px 8px;
|
||||
border-top: 1px solid #ece6f5;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
font-style: italic;
|
||||
color: #6f5f8d;
|
||||
}
|
||||
|
||||
.streaming::after {
|
||||
content: "▊";
|
||||
animation: blink 0.8s step-end infinite;
|
||||
color: #8b6fd4;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { deriveThinkingSummary } from "../../utils/toolCall";
|
||||
import styles from "./ThinkingBlock.module.css";
|
||||
|
||||
interface ThinkingBlockProps {
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
export function ThinkingBlock({ content, streaming }: ThinkingBlockProps) {
|
||||
const summary = deriveThinkingSummary(content);
|
||||
|
||||
return (
|
||||
<details className={styles.block}>
|
||||
<summary className={styles.summary}>
|
||||
<span className={styles.summaryLabel}>思考</span>
|
||||
<span className={styles.summaryText}>{summary}</span>
|
||||
</summary>
|
||||
<div className={styles.detail}>
|
||||
<div className={`${styles.body} ${streaming ? styles.streaming : ""}`}>{content}</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,6 @@
|
||||
padding: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-wrap: wrap;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: var(--header-shadow);
|
||||
}
|
||||
|
||||
.trailing {
|
||||
margin-left: auto;
|
||||
.titleGroup {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
max-width: min(68%, 680px);
|
||||
gap: 10px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -28,47 +24,189 @@
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #555;
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--toolbar-border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menuBtn:hover {
|
||||
background: #f0f0f0;
|
||||
color: #1a1a1a;
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1 1 auto;
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info h1 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.meta {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.abortBtn {
|
||||
.titleActions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
max-width: min(72%, 760px);
|
||||
min-width: 0;
|
||||
padding: 6px 8px 6px 10px;
|
||||
border: 1px solid var(--toolbar-border);
|
||||
border-radius: 14px;
|
||||
background: var(--toolbar-bg);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.selectWrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selectWrap::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid var(--text-tertiary);
|
||||
transform: translateY(-35%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.select {
|
||||
appearance: none;
|
||||
height: 30px;
|
||||
min-width: 120px;
|
||||
max-width: 220px;
|
||||
width: 100%;
|
||||
padding: 0 24px 0 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectModel {
|
||||
min-width: 168px;
|
||||
max-width: min(320px, 42vw);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.selectCompact {
|
||||
min-width: 72px;
|
||||
max-width: 96px;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.select:disabled {
|
||||
color: var(--text-tertiary);
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbarActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statsMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.statsDesktop {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.toolbarActions::before {
|
||||
content: "";
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
min-height: 30px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(15, 23, 42, 0.08) 18%,
|
||||
rgba(15, 23, 42, 0.08) 82%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.abortBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
min-width: 30px;
|
||||
padding: 0 10px;
|
||||
background: rgba(254, 242, 242, 0.95);
|
||||
border: 1px solid rgba(252, 165, 165, 0.65);
|
||||
border-radius: 9px;
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -76,72 +214,132 @@
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.modelControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.toolbar {
|
||||
max-width: min(78%, 620px);
|
||||
}
|
||||
|
||||
.modelSelect,
|
||||
.thinkingSelect {
|
||||
height: 30px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modelSelect {
|
||||
width: min(260px, 28vw);
|
||||
}
|
||||
|
||||
.thinkingSelect {
|
||||
width: 86px;
|
||||
}
|
||||
|
||||
.modelSelect:focus,
|
||||
.thinkingSelect:focus {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.22);
|
||||
}
|
||||
|
||||
.modelSelect:disabled,
|
||||
.thinkingSelect:disabled {
|
||||
color: #9ca3af;
|
||||
background: #f9fafb;
|
||||
cursor: not-allowed;
|
||||
.select {
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 10px 12px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 8px 10px 7px;
|
||||
}
|
||||
|
||||
.titleGroup {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
grid-template-areas: "menu title actions";
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menuBtn {
|
||||
display: flex;
|
||||
grid-area: menu;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.trailing {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
.title {
|
||||
grid-area: title;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.titleActions {
|
||||
display: flex;
|
||||
grid-area: actions;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-left: 0;
|
||||
justify-content: flex-start;
|
||||
padding-left: 44px;
|
||||
box-sizing: border-box;
|
||||
flex-wrap: wrap;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.modelControls {
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 72px;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modelSelect {
|
||||
width: min(100%, 260px);
|
||||
flex: 1 1 180px;
|
||||
.field {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.select {
|
||||
height: 32px;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.selectCompact {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.toolbarActions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.statsDesktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.statsMobile {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbarActions::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.abortText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.abortBtn {
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.title {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,61 @@
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { modelKey, modelLabel } from "../../utils/sessionTitle";
|
||||
import { modelKey, modelLabel, shortModelLabel } from "../../utils/sessionTitle";
|
||||
import { ExportMenu } from "../chat/ExportMenu";
|
||||
import { SessionContextBar } from "../chat/SessionContextBar";
|
||||
import styles from "./Header.module.css";
|
||||
|
||||
export function Header() {
|
||||
const {
|
||||
connected,
|
||||
isStreaming,
|
||||
headerTitle,
|
||||
headerMeta,
|
||||
toggleSidebar,
|
||||
abortStream,
|
||||
availableModels,
|
||||
currentModelKey,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
isApplyingModelSettings,
|
||||
applyModelSelection,
|
||||
applyThinkingSelection,
|
||||
} = useChatContext();
|
||||
|
||||
const metaText =
|
||||
headerMeta ||
|
||||
(!connected ? "未连接" : isStreaming ? "回复中" : "");
|
||||
|
||||
function ModelControls({
|
||||
availableModels,
|
||||
currentModelKey,
|
||||
currentModel,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
isStreaming,
|
||||
isApplyingModelSettings,
|
||||
applyModelSelection,
|
||||
applyThinkingSelection,
|
||||
}: {
|
||||
availableModels: ReturnType<typeof useChatContext>["availableModels"];
|
||||
currentModelKey: string;
|
||||
currentModel: ReturnType<typeof useChatContext>["availableModels"][number] | undefined;
|
||||
thinkingLevel: string;
|
||||
availableThinkingLevels: ReturnType<typeof useChatContext>["availableThinkingLevels"];
|
||||
isStreaming: boolean;
|
||||
isApplyingModelSettings: boolean;
|
||||
applyModelSelection: (key: string) => Promise<void>;
|
||||
applyThinkingSelection: (level: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<button type="button" className={styles.menuBtn} onClick={toggleSidebar} aria-label="切换菜单">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className={styles.info}>
|
||||
<h1>{headerTitle}</h1>
|
||||
{metaText ? <span className={styles.meta}>{metaText}</span> : null}
|
||||
</div>
|
||||
<div className={styles.trailing}>
|
||||
<div className={styles.modelControls}>
|
||||
<div className={styles.controls}>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.fieldLabel}>模型</span>
|
||||
<div className={styles.selectWrap}>
|
||||
<select
|
||||
className={styles.modelSelect}
|
||||
className={`${styles.select} ${styles.selectModel}`}
|
||||
aria-label="选择模型"
|
||||
title="选择模型"
|
||||
title={currentModel ? modelLabel(currentModel) : "选择模型"}
|
||||
value={currentModelKey}
|
||||
disabled={isStreaming || isApplyingModelSettings}
|
||||
onChange={(e) => void applyModelSelection(e.target.value)}
|
||||
>
|
||||
{availableModels.length === 0 ? (
|
||||
<option value="">加载模型...</option>
|
||||
<option value="">加载中…</option>
|
||||
) : (
|
||||
availableModels.map((m) => (
|
||||
<option key={modelKey(m)} value={modelKey(m)}>
|
||||
{modelLabel(m)}
|
||||
<option key={modelKey(m)} value={modelKey(m)} title={modelLabel(m)}>
|
||||
{shortModelLabel(m)}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span className={styles.fieldLabel}>推理</span>
|
||||
<div className={styles.selectWrap}>
|
||||
<select
|
||||
className={styles.thinkingSelect}
|
||||
className={`${styles.select} ${styles.selectCompact}`}
|
||||
aria-label="选择推理强度"
|
||||
title="推理强度"
|
||||
value={
|
||||
@@ -80,16 +78,81 @@ export function Header() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SessionContextBar />
|
||||
<ExportMenu />
|
||||
{isStreaming ? (
|
||||
<button type="button" className={styles.abortBtn} onClick={() => void abortStream()} title="中止回复">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
中止
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AbortButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className={styles.abortBtn} onClick={onClick} title="中止回复">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
<span className={styles.abortText}>中止</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const {
|
||||
isStreaming,
|
||||
headerTitle,
|
||||
headerMeta,
|
||||
toggleSidebar,
|
||||
abortStream,
|
||||
availableModels,
|
||||
currentModelKey,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
isApplyingModelSettings,
|
||||
applyModelSelection,
|
||||
applyThinkingSelection,
|
||||
} = useChatContext();
|
||||
|
||||
const currentModel = availableModels.find((m) => modelKey(m) === currentModelKey);
|
||||
const controlProps = {
|
||||
availableModels,
|
||||
currentModelKey,
|
||||
currentModel,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
isStreaming,
|
||||
isApplyingModelSettings,
|
||||
applyModelSelection,
|
||||
applyThinkingSelection,
|
||||
};
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.titleGroup}>
|
||||
<button type="button" className={styles.menuBtn} onClick={toggleSidebar} aria-label="切换菜单">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className={styles.title} title={headerTitle}>
|
||||
{headerTitle}
|
||||
</h1>
|
||||
{headerMeta ? <span className={styles.meta}>{headerMeta}</span> : null}
|
||||
<div className={styles.titleActions}>
|
||||
<ExportMenu compact />
|
||||
{isStreaming ? <AbortButton onClick={() => void abortStream()} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
<ModelControls {...controlProps} />
|
||||
<div className={styles.statsDesktop}>
|
||||
<SessionContextBar />
|
||||
</div>
|
||||
<div className={styles.statsMobile}>
|
||||
<SessionContextBar compact />
|
||||
</div>
|
||||
<div className={styles.toolbarActions}>
|
||||
<ExportMenu />
|
||||
{isStreaming ? <AbortButton onClick={() => void abortStream()} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
.main {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding: 8px 4px 8px 10px;
|
||||
padding: 8px 2px 8px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -64,7 +64,7 @@
|
||||
color: #1a1a1a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
@@ -74,59 +74,49 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pinnedBadge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ca8a04;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.pinnedBadge:hover {
|
||||
opacity: 1;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.titleRow .name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.renameInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #2563eb;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 10px;
|
||||
color: #bbb;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-overflow: clip;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-right: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.item:hover .actions,
|
||||
.active .actions,
|
||||
.actions:focus-within {
|
||||
opacity: 1;
|
||||
gap: 0;
|
||||
margin-right: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pinBtn,
|
||||
.renameBtn,
|
||||
.deleteBtn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -138,7 +128,12 @@
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.pinBtn:hover {
|
||||
.pinBtnActive {
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
.pinBtn:hover,
|
||||
.pinBtnActive:hover {
|
||||
color: #ca8a04;
|
||||
background: #fef9c3;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { formatTimeAgo } from "../../utils/format";
|
||||
import { formatSessionTitle } from "../../utils/sessionTitle";
|
||||
@@ -6,6 +7,27 @@ import styles from "./SessionList.module.css";
|
||||
export function SessionList() {
|
||||
const { sessions, activeSessionPath, loadSession, deleteSession, renameSession, toggleSessionPin } =
|
||||
useChatContext();
|
||||
const [editingPath, setEditingPath] = useState<string | null>(null);
|
||||
const [editingValue, setEditingValue] = useState("");
|
||||
|
||||
const startEditing = (path: string, currentName: string) => {
|
||||
setEditingPath(path);
|
||||
setEditingValue(currentName);
|
||||
};
|
||||
|
||||
const confirmRename = (path: string) => {
|
||||
const trimmed = editingValue.trim();
|
||||
if (trimmed) {
|
||||
void renameSession(path, trimmed);
|
||||
}
|
||||
setEditingPath(null);
|
||||
setEditingValue("");
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingPath(null);
|
||||
setEditingValue("");
|
||||
};
|
||||
|
||||
if (!sessions.length) {
|
||||
return <div className={styles.empty}>暂无历史会话</div>;
|
||||
@@ -17,6 +39,7 @@ export function SessionList() {
|
||||
const isActive = activeSessionPath === s.path;
|
||||
const isPinned = Boolean(s.pinned);
|
||||
const title = formatSessionTitle(s.name);
|
||||
const isEditing = editingPath === s.path;
|
||||
return (
|
||||
<div
|
||||
key={s.path}
|
||||
@@ -24,46 +47,47 @@ export function SessionList() {
|
||||
>
|
||||
<button type="button" className={styles.main} onClick={() => void loadSession(s.path)}>
|
||||
<div className={styles.titleRow}>
|
||||
{isPinned ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pinnedBadge}
|
||||
title="取消置顶"
|
||||
aria-label={`取消置顶 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleSessionPin(s.path, false);
|
||||
{isEditing ? (
|
||||
<input
|
||||
className={styles.renameInput}
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
confirmRename(s.path);
|
||||
} else if (e.key === "Escape") {
|
||||
cancelEditing();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M16 3H5a1 1 0 00-.8 1.6l4.6 6.12-4.6 6.12A1 1 0 005 18h11l4 3V3l-4 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<div className={styles.name}>{title}</div>
|
||||
onBlur={() => confirmRename(s.path)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.name}>{title}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.meta}>
|
||||
{s.messageCount ?? 0} 条 · {formatTimeAgo(s.modified || s.created)}
|
||||
</div>
|
||||
</button>
|
||||
<div className={styles.actions}>
|
||||
{!isPinned ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pinBtn}
|
||||
title="置顶会话"
|
||||
aria-label={`置顶 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleSessionPin(s.path, true);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 3h6l1 7h4l-5 6v4H9v-4L4 10h4l1-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.pinBtn} ${isPinned ? styles.pinBtnActive : ""}`}
|
||||
title={isPinned ? "取消置顶" : "置顶会话"}
|
||||
aria-label={isPinned ? `取消置顶 ${title}` : `置顶 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleSessionPin(s.path, !isPinned);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 3h6l1 7h4l-5 6v4H9v-4L4 10h4l1-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.renameBtn}
|
||||
@@ -71,7 +95,11 @@ export function SessionList() {
|
||||
aria-label={`重命名 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void renameSession(s.path, title);
|
||||
if (isEditing) {
|
||||
cancelEditing();
|
||||
} else {
|
||||
startEditing(s.path, s.name || title);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@mogeko/maple-mono-cn/dist/font/result.css";
|
||||
import { App } from "./App";
|
||||
import "./styles/global.css";
|
||||
|
||||
|
||||
@@ -374,6 +374,154 @@
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.extensionGroup + .extensionGroup {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.extensionGroupHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.extensionGroupHeader h3 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.extensionGroupHeader span {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extensionGroupList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.extensionGroupList {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.extensionGroupList {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.extensionGroupList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.extensionHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.extensionTitleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.extensionItem .itemName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.extensionSubtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.extensionGroupList .extensionItem + .extensionItem {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.skillItemDisabled {
|
||||
opacity: 0.72;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.skillActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skillToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.skillToggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.skillToggleUi {
|
||||
position: relative;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skillToggleUi::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.skillToggle input:checked + .skillToggleUi {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.skillToggle input:checked + .skillToggleUi::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.skillToggle input:disabled + .skillToggleUi {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.skillToggleLabel {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
min-width: 42px;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -416,11 +564,29 @@
|
||||
|
||||
.mcpToolList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.mcpToolList {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.mcpToolList {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.mcpToolList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mcpServerHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -473,27 +639,38 @@
|
||||
|
||||
.mcpToolSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mcpToolSummaryActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mcpToolItemDisabled {
|
||||
opacity: 0.72;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.mcpToolSummary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mcpToolName {
|
||||
min-width: 0;
|
||||
color: #111827;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mcpToolDesc {
|
||||
|
||||
@@ -7,7 +7,25 @@ import type { ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "..
|
||||
import type { McpToolInfo } from "../types/events";
|
||||
import styles from "./SettingsPage.module.css";
|
||||
|
||||
const PANE_IDS: SettingsPaneId[] = ["prompt", "skills", "mcp", "extensions", "other"];
|
||||
const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "other"];
|
||||
|
||||
function isNpmSkill(skill: SkillInfo): boolean {
|
||||
if (skill.toggleable === false) return true;
|
||||
const source = skill.source || "";
|
||||
if (source.startsWith("npm:")) return true;
|
||||
const path = skill.path || "";
|
||||
return path.includes("/node_modules/") || path.includes("\\node_modules\\");
|
||||
}
|
||||
|
||||
function isSkillToggleable(skill: SkillInfo): boolean {
|
||||
return !isNpmSkill(skill) && skill.toggleable !== false;
|
||||
}
|
||||
|
||||
function formatSkillMeta(skill: SkillInfo): string {
|
||||
const source = (skill.source || "").trim();
|
||||
if (!source || source === "auto") return "";
|
||||
return source;
|
||||
}
|
||||
const SETTINGS_PANE_KEY = "sproutclaw-settings-pane";
|
||||
|
||||
function renderNameList(label: string, items: string[] | undefined) {
|
||||
@@ -24,7 +42,152 @@ function renderNameList(label: string, items: string[] | undefined) {
|
||||
);
|
||||
}
|
||||
|
||||
function McpToolItem({ tool }: { tool: McpToolInfo }) {
|
||||
function extensionCategory(extension: ExtensionInfo): "local" | "npm" {
|
||||
if (extension.category === "npm" || extension.category === "local") {
|
||||
return extension.category;
|
||||
}
|
||||
if (extension.source?.startsWith("npm:")) return "npm";
|
||||
const pathHint = `${extension.path || ""} ${extension.location || ""}`.replace(/\\/g, "/");
|
||||
if (pathHint.includes("/.pi/agent/npm/node_modules/")) return "npm";
|
||||
return "local";
|
||||
}
|
||||
|
||||
function extensionTogglePath(extension: ExtensionInfo): string {
|
||||
return extension.resolvedPath || extension.path || "";
|
||||
}
|
||||
|
||||
function ExtensionCard({
|
||||
extension,
|
||||
toggling,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
extension: ExtensionInfo;
|
||||
toggling: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}) {
|
||||
const meta =
|
||||
extension.kind || [extension.scope, extension.source].filter(Boolean).join(" · ");
|
||||
const enabled = extension.enabled !== false;
|
||||
const togglePath = extensionTogglePath(extension);
|
||||
const counts = (
|
||||
[
|
||||
["命令", extension.commands?.length || 0],
|
||||
["工具", extension.tools?.length || 0],
|
||||
["事件", extension.handlers?.length || 0],
|
||||
["Flags", extension.flags?.length || 0],
|
||||
["快捷键", extension.shortcuts?.length || 0],
|
||||
] as const
|
||||
).filter(([, count]) => count > 0);
|
||||
const hasDetails = ["commands", "tools", "handlers", "flags", "shortcuts"].some(
|
||||
(key) => (extension[key as keyof ExtensionInfo] as string[] | undefined)?.length,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${styles.extensionItem} ${enabled ? "" : styles.skillItemDisabled}`}>
|
||||
<div className={styles.extensionHeader}>
|
||||
<div className={styles.extensionTitleRow}>
|
||||
<div className={styles.itemName}>{extension.name || extension.path || "未命名"}</div>
|
||||
<label className={styles.skillToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={!togglePath || toggling || disabled}
|
||||
onChange={(e) => {
|
||||
if (!togglePath) return;
|
||||
onToggle(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.skillToggleUi} aria-hidden />
|
||||
<span className={styles.skillToggleLabel}>{enabled ? "已启用" : "已禁用"}</span>
|
||||
</label>
|
||||
</div>
|
||||
{meta ? <div className={styles.extensionSubtitle}>{meta}</div> : null}
|
||||
</div>
|
||||
{counts.length ? (
|
||||
<div className={styles.extensionCounts}>
|
||||
{counts.map(([label, count]) => (
|
||||
<span key={label}>
|
||||
{label} {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{hasDetails ? (
|
||||
<details className={styles.extensionDetails}>
|
||||
<summary>查看注册项</summary>
|
||||
{renderNameList("命令", extension.commands)}
|
||||
{renderNameList("工具", extension.tools)}
|
||||
{renderNameList("事件", extension.handlers)}
|
||||
{renderNameList("Flags", extension.flags)}
|
||||
{renderNameList("快捷键", extension.shortcuts)}
|
||||
</details>
|
||||
) : null}
|
||||
{extension.location ? <div className={styles.itemPath}>{extension.location}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtensionGroup({
|
||||
title,
|
||||
items,
|
||||
emptyText,
|
||||
togglingPath,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
title: string;
|
||||
items: ExtensionInfo[];
|
||||
emptyText: string;
|
||||
togglingPath: string | null;
|
||||
disabled: boolean;
|
||||
onToggle: (path: string, enabled: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className={styles.extensionGroup}>
|
||||
<div className={styles.extensionGroupHeader}>
|
||||
<h3>{title}</h3>
|
||||
<span>{items.length} 个</span>
|
||||
</div>
|
||||
{items.length ? (
|
||||
<div className={styles.extensionGroupList}>
|
||||
{items.map((extension) => {
|
||||
const togglePath = extensionTogglePath(extension);
|
||||
return (
|
||||
<ExtensionCard
|
||||
key={togglePath || extension.location || extension.name}
|
||||
extension={extension}
|
||||
toggling={Boolean(togglePath && togglingPath === togglePath)}
|
||||
disabled={disabled}
|
||||
onToggle={(enabled) => {
|
||||
if (!togglePath) return;
|
||||
onToggle(togglePath, enabled);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.empty} ${styles.emptyCompact}`}>{emptyText}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function McpToolItem({
|
||||
tool,
|
||||
serverEnabled,
|
||||
toggling,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
tool: McpToolInfo;
|
||||
serverEnabled: boolean;
|
||||
toggling: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}) {
|
||||
const params = Array.isArray(tool.parameters) ? tool.parameters : [];
|
||||
const required = new Set(Array.isArray(tool.required) ? tool.required : []);
|
||||
const description = String(tool.description || "").replace(/\s+/g, " ").trim();
|
||||
@@ -33,12 +196,36 @@ function McpToolItem({ tool }: { tool: McpToolInfo }) {
|
||||
const title = [tool.name, description, params.length ? `参数: ${params.join(", ")}` : ""]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const enabled = tool.enabled !== false;
|
||||
const toolName = tool.name || "";
|
||||
|
||||
return (
|
||||
<details className={styles.mcpToolItem} title={title}>
|
||||
<details
|
||||
className={`${styles.mcpToolItem} ${enabled ? "" : styles.mcpToolItemDisabled}`}
|
||||
title={title}
|
||||
>
|
||||
<summary className={styles.mcpToolSummary}>
|
||||
<span className={styles.mcpToolName}>{tool.name || "未命名工具"}</span>
|
||||
{params.length ? <span className={styles.badge}>{params.length} 参数</span> : null}
|
||||
<span className={styles.mcpToolName}>{toolName || "未命名工具"}</span>
|
||||
<div className={styles.mcpToolSummaryActions}>
|
||||
{params.length ? <span className={styles.badge}>{params.length} 参数</span> : null}
|
||||
<label
|
||||
className={styles.skillToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={!toolName || !serverEnabled || toggling || disabled}
|
||||
onChange={(e) => {
|
||||
if (!toolName || !serverEnabled) return;
|
||||
onToggle(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.skillToggleUi} aria-hidden />
|
||||
<span className={styles.skillToggleLabel}>{enabled ? "开" : "关"}</span>
|
||||
</label>
|
||||
</div>
|
||||
</summary>
|
||||
{shortDescription ? <div className={styles.mcpToolDesc}>{shortDescription}</div> : null}
|
||||
{params.length ? (
|
||||
@@ -61,6 +248,8 @@ export function SettingsPage() {
|
||||
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
|
||||
const [systemPrompt, setSystemPrompt] = useState("");
|
||||
const [systemPromptPath, setSystemPromptPath] = useState("");
|
||||
const [modelsConfig, setModelsConfig] = useState("");
|
||||
const [modelsConfigPath, setModelsConfigPath] = useState("");
|
||||
const [userAvatarUrl, setUserAvatarUrl] = useState("");
|
||||
const [agentAvatarUrl, setAgentAvatarUrl] = useState("");
|
||||
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||
@@ -71,8 +260,13 @@ export function SettingsPage() {
|
||||
const [statusKind, setStatusKind] = useState<"" | "ok" | "error">("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingModels, setSavingModels] = useState(false);
|
||||
const [savingAvatars, setSavingAvatars] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [togglingSkillPath, setTogglingSkillPath] = useState<string | null>(null);
|
||||
const [togglingMcpServer, setTogglingMcpServer] = useState<string | null>(null);
|
||||
const [togglingMcpTool, setTogglingMcpTool] = useState<string | null>(null);
|
||||
const [togglingExtensionPath, setTogglingExtensionPath] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let initial: SettingsPaneId = "prompt";
|
||||
@@ -108,6 +302,8 @@ export function SettingsPage() {
|
||||
const data = await settingsApi.fetchSettings();
|
||||
setSystemPrompt(data.systemPrompt || "");
|
||||
setSystemPromptPath(data.systemPromptPath || "");
|
||||
setModelsConfig(data.modelsConfig || "");
|
||||
setModelsConfigPath(data.modelsConfigPath || "");
|
||||
setUserAvatarUrl(data.userAvatarUrl || "");
|
||||
setAgentAvatarUrl(data.agentAvatarUrl || "");
|
||||
updateAvatars({
|
||||
@@ -140,6 +336,106 @@ export function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSkillEnabled = async (path: string, enabled: boolean) => {
|
||||
setTogglingSkillPath(path);
|
||||
setStatus(enabled ? "正在启用 skill…" : "正在禁用 skill…");
|
||||
try {
|
||||
const data = await settingsApi.toggleSkill(path, enabled);
|
||||
if (data.skill) {
|
||||
setSkills((prev) =>
|
||||
prev.map((skill) => (skill.path === path ? { ...skill, ...data.skill } : skill)),
|
||||
);
|
||||
} else {
|
||||
await loadSettings();
|
||||
}
|
||||
setStatus(enabled ? "Skill 已启用" : "Skill 已禁用", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`切换失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setTogglingSkillPath(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMcpServer = (entry: McpServerInfo) => {
|
||||
setMcpTools((prev) =>
|
||||
prev.map((server) => (server.name === entry.name ? { ...server, ...entry } : server)),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleMcpServerEnabled = async (serverName: string, enabled: boolean) => {
|
||||
setTogglingMcpServer(serverName);
|
||||
setStatus(enabled ? "正在启用 MCP Server…" : "正在禁用 MCP Server…");
|
||||
try {
|
||||
const data = await settingsApi.toggleMcpServer(serverName, enabled);
|
||||
if (data.server) {
|
||||
updateMcpServer(data.server);
|
||||
} else {
|
||||
await loadSettings();
|
||||
}
|
||||
setStatus(
|
||||
enabled
|
||||
? "MCP Server 已启用,必要时可运行 /mcp reconnect 刷新连接"
|
||||
: "MCP Server 已禁用",
|
||||
"ok",
|
||||
);
|
||||
} catch (err) {
|
||||
setStatus(`切换失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setTogglingMcpServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMcpToolEnabled = async (serverName: string, toolName: string, enabled: boolean) => {
|
||||
const toggleKey = `${serverName}/${toolName}`;
|
||||
setTogglingMcpTool(toggleKey);
|
||||
setStatus(enabled ? "正在启用 MCP工具…" : "正在禁用 MCP工具…");
|
||||
try {
|
||||
const data = await settingsApi.toggleMcpTool(serverName, toolName, enabled);
|
||||
if (data.tool) {
|
||||
setMcpTools((prev) =>
|
||||
prev.map((server) => {
|
||||
if (server.name !== serverName || !Array.isArray(server.tools)) return server;
|
||||
const tools = server.tools.map((tool) =>
|
||||
tool.name === toolName ? { ...tool, ...data.tool } : tool,
|
||||
);
|
||||
const enabledToolCount = tools.filter((tool) => tool.enabled !== false).length;
|
||||
return { ...server, tools, enabledToolCount };
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
await loadSettings();
|
||||
}
|
||||
setStatus(enabled ? "MCP工具已启用" : "MCP工具已禁用", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`切换失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setTogglingMcpTool(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExtensionEnabled = async (path: string, enabled: boolean) => {
|
||||
setTogglingExtensionPath(path);
|
||||
setStatus(enabled ? "正在启用扩展…" : "正在禁用扩展…");
|
||||
try {
|
||||
const data = await settingsApi.toggleExtension(path, enabled);
|
||||
if (data.extension) {
|
||||
setExtensions((prev) =>
|
||||
prev.map((extension) => {
|
||||
const key = extensionTogglePath(extension);
|
||||
return key === path ? { ...extension, ...data.extension } : extension;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
await loadSettings();
|
||||
}
|
||||
setStatus(enabled ? "扩展已启用,Agent 已重新加载" : "扩展已禁用,Agent 已重新加载", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`切换失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setTogglingExtensionPath(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSystemPrompt = async () => {
|
||||
setSaving(true);
|
||||
setStatus("保存中...");
|
||||
@@ -154,6 +450,22 @@ export function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveModelsConfig = async () => {
|
||||
setSavingModels(true);
|
||||
setStatus("保存中...");
|
||||
try {
|
||||
const data = await settingsApi.saveModelsConfig(modelsConfig);
|
||||
if (data.modelsConfigPath) setModelsConfigPath(data.modelsConfigPath);
|
||||
const refreshed = await settingsApi.fetchSettings();
|
||||
setModelsConfig(refreshed.modelsConfig || "");
|
||||
setStatus("已保存,Agent 已重新加载", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setSavingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAvatars = async () => {
|
||||
setSavingAvatars(true);
|
||||
setStatus("保存中...");
|
||||
@@ -179,6 +491,16 @@ export function SettingsPage() {
|
||||
}, [loadSettings]);
|
||||
|
||||
const toolTotal = mcpTools.reduce((sum, server) => sum + (server.toolCount || 0), 0);
|
||||
const enabledMcpServerCount = mcpTools.filter((server) => server.enabled !== false).length;
|
||||
const enabledMcpToolCount = mcpTools.reduce(
|
||||
(sum, server) => sum + (server.enabled !== false ? server.enabledToolCount ?? 0 : 0),
|
||||
0,
|
||||
);
|
||||
const localExtensions = extensions.filter((extension) => extensionCategory(extension) === "local");
|
||||
const npmExtensions = extensions.filter((extension) => extensionCategory(extension) === "npm");
|
||||
const toggleableSkills = skills.filter(isSkillToggleable);
|
||||
const enabledSkillCount = toggleableSkills.filter((skill) => skill.enabled !== false).length;
|
||||
const enabledExtensionCount = extensions.filter((extension) => extension.enabled !== false).length;
|
||||
const statusClass =
|
||||
statusKind === "ok" ? styles.statusOk : statusKind === "error" ? styles.statusError : "";
|
||||
|
||||
@@ -190,11 +512,10 @@ export function SettingsPage() {
|
||||
<img className={styles.logo} src="/logo.png" width={34} height={34} alt="" decoding="async" />
|
||||
<div>
|
||||
<h1>设置</h1>
|
||||
<p>萌小芽 Agent</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/" className={`${styles.secondaryBtn} ${styles.linkBtn}`}>
|
||||
返回聊天
|
||||
返回
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
@@ -204,8 +525,9 @@ export function SettingsPage() {
|
||||
{(
|
||||
[
|
||||
["prompt", "系统提示词"],
|
||||
["models", "模型配置"],
|
||||
["skills", "Skills"],
|
||||
["mcp", "MCP 工具"],
|
||||
["mcp", "MCP工具"],
|
||||
["extensions", "插件扩展"],
|
||||
["other", "其他设置"],
|
||||
] as const
|
||||
@@ -255,6 +577,37 @@ export function SettingsPage() {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "models" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "models"}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>模型配置</h2>
|
||||
<p>{modelsConfigPath}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
disabled={savingModels}
|
||||
onClick={() => void saveModelsConfig()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
spellCheck={false}
|
||||
aria-label="模型配置"
|
||||
value={modelsConfig}
|
||||
onChange={(e) => setModelsConfig(e.target.value)}
|
||||
/>
|
||||
{statusText && activePane === "models" ? (
|
||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "skills" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
@@ -263,7 +616,9 @@ export function SettingsPage() {
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>Skills</h2>
|
||||
<p>{skills.length} 个已加载</p>
|
||||
<p>
|
||||
{enabledSkillCount} / {toggleableSkills.length} 个已启用
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -281,14 +636,40 @@ export function SettingsPage() {
|
||||
<div className={styles.empty}>暂无已加载 skills</div>
|
||||
) : (
|
||||
skills.map((skill) => {
|
||||
const meta = [skill.scope, skill.source].filter(Boolean).join(" · ");
|
||||
const meta = formatSkillMeta(skill);
|
||||
const skillPath = skill.path || "";
|
||||
const enabled = skill.enabled !== false;
|
||||
const toggleable = isSkillToggleable(skill);
|
||||
const toggling = togglingSkillPath === skillPath;
|
||||
return (
|
||||
<div key={skill.path || skill.name || skill.command} className={styles.skillItem}>
|
||||
<div
|
||||
key={skillPath || skill.name || skill.command}
|
||||
className={`${styles.skillItem} ${enabled ? "" : styles.skillItemDisabled}`}
|
||||
>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.itemName}>
|
||||
{skill.name || skill.command || "未命名"}
|
||||
</div>
|
||||
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
|
||||
<div className={styles.skillActions}>
|
||||
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
|
||||
{toggleable ? (
|
||||
<label className={styles.skillToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={!skillPath || toggling || reloading}
|
||||
onChange={(e) => {
|
||||
if (!skillPath) return;
|
||||
void toggleSkillEnabled(skillPath, e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.skillToggleUi} aria-hidden />
|
||||
<span className={styles.skillToggleLabel}>
|
||||
{enabled ? "已启用" : "已禁用"}
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className={styles.itemDesc}>{skill.description}</div>
|
||||
@@ -308,9 +689,10 @@ export function SettingsPage() {
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>MCP 工具</h2>
|
||||
<h2>MCP工具</h2>
|
||||
<p>
|
||||
{mcpTools.length} 个 Server · {toolTotal} 个工具
|
||||
{enabledMcpServerCount} / {mcpTools.length} 个 Server · {enabledMcpToolCount} /{" "}
|
||||
{toolTotal} 个工具已启用
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -330,22 +712,51 @@ export function SettingsPage() {
|
||||
) : (
|
||||
mcpTools.map((server) => {
|
||||
const tools = Array.isArray(server.tools) ? server.tools : [];
|
||||
const serverName = server.name || "";
|
||||
const serverEnabled = server.enabled !== false;
|
||||
const togglingServer = togglingMcpServer === serverName;
|
||||
const status = server.cached ? "已缓存" : "未连接";
|
||||
const cachedAt = server.cachedAt
|
||||
? new Date(server.cachedAt).toLocaleString()
|
||||
: "";
|
||||
return (
|
||||
<div key={server.name} className={styles.mcpServerItem}>
|
||||
<div
|
||||
key={serverName || "unnamed"}
|
||||
className={`${styles.mcpServerItem} ${serverEnabled ? "" : styles.skillItemDisabled}`}
|
||||
>
|
||||
<div className={styles.mcpServerHead}>
|
||||
<div className={styles.mcpServerMain}>
|
||||
<div className={styles.itemName}>{server.name || "未命名 Server"}</div>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.itemName}>
|
||||
{serverName || "未命名 Server"}
|
||||
</div>
|
||||
<label className={styles.skillToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={serverEnabled}
|
||||
disabled={!serverName || togglingServer || reloading}
|
||||
onChange={(e) => {
|
||||
if (!serverName) return;
|
||||
void toggleMcpServerEnabled(serverName, e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.skillToggleUi} aria-hidden />
|
||||
<span className={styles.skillToggleLabel}>
|
||||
{serverEnabled ? "已启用" : "已禁用"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{cachedAt ? (
|
||||
<div className={styles.mcpServerSub}>更新 {cachedAt}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.mcpServerBadges}>
|
||||
<span className={styles.badge}>{status}</span>
|
||||
<span className={styles.badge}>{server.toolCount || 0} 工具</span>
|
||||
<span className={styles.badge}>
|
||||
{serverEnabled
|
||||
? `${server.enabledToolCount ?? tools.filter((tool) => tool.enabled !== false).length} / ${server.toolCount || tools.length} 工具`
|
||||
: `${server.toolCount || tools.length} 工具`}
|
||||
</span>
|
||||
{server.resourceCount ? (
|
||||
<span className={styles.badge}>{server.resourceCount} 资源</span>
|
||||
) : null}
|
||||
@@ -353,9 +764,23 @@ export function SettingsPage() {
|
||||
</div>
|
||||
{tools.length ? (
|
||||
<div className={styles.mcpToolList}>
|
||||
{tools.map((tool) => (
|
||||
<McpToolItem key={tool.name} tool={tool} />
|
||||
))}
|
||||
{tools.map((tool) => {
|
||||
const toolName = tool.name || "";
|
||||
const toggleKey = `${serverName}/${toolName}`;
|
||||
return (
|
||||
<McpToolItem
|
||||
key={toolName || "unnamed"}
|
||||
tool={tool}
|
||||
serverEnabled={serverEnabled}
|
||||
toggling={togglingMcpTool === toggleKey}
|
||||
disabled={reloading || togglingServer}
|
||||
onToggle={(enabled) => {
|
||||
if (!serverName || !toolName) return;
|
||||
void toggleMcpToolEnabled(serverName, toolName, enabled);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.empty} ${styles.emptyCompact}`}>
|
||||
@@ -367,6 +792,9 @@ export function SettingsPage() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{statusText && activePane === "mcp" ? (
|
||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -378,9 +806,9 @@ export function SettingsPage() {
|
||||
<div>
|
||||
<h2>插件扩展</h2>
|
||||
<p>
|
||||
{extensionsPath
|
||||
? `${extensions.length} 个已加载 · ${extensionsPath}`
|
||||
: `${extensions.length} 个已加载`}
|
||||
{enabledExtensionCount} / {extensions.length} 个已启用 · {localExtensions.length}{" "}
|
||||
个本地 · {npmExtensions.length} 个 npm
|
||||
{extensionsPath ? ` · ${extensionsPath}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -398,60 +826,29 @@ export function SettingsPage() {
|
||||
) : !extensions.length ? (
|
||||
<div className={styles.empty}>暂无已加载插件扩展</div>
|
||||
) : (
|
||||
extensions.map((extension) => {
|
||||
const meta =
|
||||
extension.kind ||
|
||||
[extension.scope, extension.source].filter(Boolean).join(" · ");
|
||||
const counts = (
|
||||
[
|
||||
["命令", extension.commands?.length || 0],
|
||||
["工具", extension.tools?.length || 0],
|
||||
["事件", extension.handlers?.length || 0],
|
||||
["Flags", extension.flags?.length || 0],
|
||||
["快捷键", extension.shortcuts?.length || 0],
|
||||
] as const
|
||||
).filter(([, count]) => count > 0);
|
||||
const hasDetails = ["commands", "tools", "handlers", "flags", "shortcuts"].some(
|
||||
(key) => (extension[key as keyof ExtensionInfo] as string[] | undefined)?.length,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={extension.location || extension.name || extension.path}
|
||||
className={styles.extensionItem}
|
||||
>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.itemName}>
|
||||
{extension.name || extension.path || "未命名"}
|
||||
</div>
|
||||
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
|
||||
</div>
|
||||
{counts.length ? (
|
||||
<div className={styles.extensionCounts}>
|
||||
{counts.map(([label, count]) => (
|
||||
<span key={label}>
|
||||
{label} {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{hasDetails ? (
|
||||
<details className={styles.extensionDetails}>
|
||||
<summary>查看注册项</summary>
|
||||
{renderNameList("命令", extension.commands)}
|
||||
{renderNameList("工具", extension.tools)}
|
||||
{renderNameList("事件", extension.handlers)}
|
||||
{renderNameList("Flags", extension.flags)}
|
||||
{renderNameList("快捷键", extension.shortcuts)}
|
||||
</details>
|
||||
) : null}
|
||||
{extension.location ? (
|
||||
<div className={styles.itemPath}>{extension.location}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
<>
|
||||
<ExtensionGroup
|
||||
title="本地扩展"
|
||||
items={localExtensions}
|
||||
emptyText="暂无本地扩展"
|
||||
togglingPath={togglingExtensionPath}
|
||||
disabled={reloading}
|
||||
onToggle={(path, enabled) => void toggleExtensionEnabled(path, enabled)}
|
||||
/>
|
||||
<ExtensionGroup
|
||||
title="npm 扩展"
|
||||
items={npmExtensions}
|
||||
emptyText="暂无 npm 扩展"
|
||||
togglingPath={togglingExtensionPath}
|
||||
disabled={reloading}
|
||||
onToggle={(path, enabled) => void toggleExtensionEnabled(path, enabled)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{statusText && activePane === "extensions" ? (
|
||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
|
||||
@@ -10,6 +10,7 @@ html {
|
||||
font-size: 16px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-family: var(--font-family);
|
||||
letter-spacing: var(--font-letter-spacing);
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
:root {
|
||||
--app-height: 100vh;
|
||||
--font-family: "LXGW WenKai Mono", "霞鹜文楷等宽", ui-monospace, monospace;
|
||||
--font-family: "Maple Mono CN", ui-monospace, monospace;
|
||||
--font-letter-spacing: -0.05em;
|
||||
--font-ui: var(--font-family);
|
||||
--font-mono: var(--font-family);
|
||||
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #4b5563;
|
||||
--text-tertiary: #9ca3af;
|
||||
--surface-muted: #f3f4f6;
|
||||
--header-bg: rgba(255, 255, 255, 0.9);
|
||||
--header-border: rgba(15, 23, 42, 0.06);
|
||||
--header-shadow: 0 1px 0 rgba(15, 23, 42, 0.04), 0 10px 30px rgba(15, 23, 42, 0.04);
|
||||
--toolbar-bg: linear-gradient(180deg, rgba(248, 250, 252, 0.96) 0%, rgba(241, 245, 249, 0.96) 100%);
|
||||
--toolbar-border: rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
11
.pi/agent/extensions/webui/frontend/src/types/commands.ts
Normal file
11
.pi/agent/extensions/webui/frontend/src/types/commands.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type SlashCommandSource = "builtin" | "extension" | "prompt" | "skill";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
source: SlashCommandSource;
|
||||
}
|
||||
|
||||
export interface SlashCommandsResponse {
|
||||
commands: SlashCommand[];
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { RpcMessage } from "./message";
|
||||
|
||||
export interface ConnectedEvent {
|
||||
type: "connected";
|
||||
}
|
||||
|
||||
export interface AgentStartEvent {
|
||||
type: "agent_start";
|
||||
}
|
||||
@@ -49,8 +45,21 @@ export interface ToolExecutionEndEvent {
|
||||
result?: { content?: unknown };
|
||||
}
|
||||
|
||||
export type SseEvent =
|
||||
| ConnectedEvent
|
||||
export interface CompactionStartEvent {
|
||||
type: "compaction_start";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CompactionEndEvent {
|
||||
type: "compaction_end";
|
||||
reason?: string;
|
||||
aborted?: boolean;
|
||||
errorMessage?: string;
|
||||
result?: { tokensBefore?: number; summary?: string };
|
||||
}
|
||||
|
||||
/** Agent stream events replayed on SSE reconnect (excludes connected / prompt_rejected). */
|
||||
export type StreamSseEvent =
|
||||
| AgentStartEvent
|
||||
| AgentEndEvent
|
||||
| MessageStartEvent
|
||||
@@ -58,7 +67,22 @@ export type SseEvent =
|
||||
| MessageEndEvent
|
||||
| ToolExecutionStartEvent
|
||||
| ToolExecutionUpdateEvent
|
||||
| ToolExecutionEndEvent;
|
||||
| ToolExecutionEndEvent
|
||||
| CompactionStartEvent
|
||||
| CompactionEndEvent;
|
||||
|
||||
export interface ConnectedEvent {
|
||||
type: "connected";
|
||||
isStreaming?: boolean;
|
||||
replay?: StreamSseEvent[];
|
||||
}
|
||||
|
||||
export interface PromptRejectedEvent {
|
||||
type: "prompt_rejected";
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type SseEvent = ConnectedEvent | PromptRejectedEvent | StreamSseEvent;
|
||||
|
||||
export interface SkillInfo {
|
||||
name?: string;
|
||||
@@ -67,20 +91,26 @@ export interface SkillInfo {
|
||||
path?: string;
|
||||
scope?: string;
|
||||
source?: string;
|
||||
enabled?: boolean;
|
||||
toggleable?: boolean;
|
||||
}
|
||||
|
||||
export interface McpToolInfo {
|
||||
server?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
parameters?: string[];
|
||||
required?: string[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface McpServerInfo {
|
||||
name?: string;
|
||||
configured?: boolean;
|
||||
enabled?: boolean;
|
||||
cached?: boolean;
|
||||
toolCount?: number;
|
||||
enabledToolCount?: number;
|
||||
resourceCount?: number;
|
||||
cachedAt?: string;
|
||||
tools?: McpToolInfo[];
|
||||
@@ -89,10 +119,14 @@ export interface McpServerInfo {
|
||||
export interface ExtensionInfo {
|
||||
name?: string;
|
||||
path?: string;
|
||||
resolvedPath?: string;
|
||||
kind?: string;
|
||||
category?: "local" | "npm";
|
||||
scope?: string;
|
||||
source?: string;
|
||||
location?: string;
|
||||
version?: string;
|
||||
enabled?: boolean;
|
||||
commands?: string[];
|
||||
tools?: string[];
|
||||
handlers?: string[];
|
||||
@@ -103,6 +137,8 @@ export interface ExtensionInfo {
|
||||
export interface SettingsData {
|
||||
systemPrompt?: string;
|
||||
systemPromptPath?: string;
|
||||
modelsConfig?: string;
|
||||
modelsConfigPath?: string;
|
||||
userAvatarUrl?: string;
|
||||
agentAvatarUrl?: string;
|
||||
skills?: SkillInfo[];
|
||||
@@ -119,4 +155,4 @@ export interface AvatarSettings {
|
||||
agentAvatarUrl: string;
|
||||
}
|
||||
|
||||
export type SettingsPaneId = "prompt" | "skills" | "mcp" | "extensions" | "other";
|
||||
export type SettingsPaneId = "prompt" | "models" | "skills" | "mcp" | "extensions" | "other";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export type MessageRole = "user" | "assistant" | "system" | "tool_call";
|
||||
export type MessageRole = "user" | "assistant" | "system" | "tool_call" | "bash" | "thinking";
|
||||
|
||||
export interface ContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
toolName?: string;
|
||||
@@ -42,6 +43,8 @@ export interface ChatMessage {
|
||||
content: string;
|
||||
images?: ImageContent[];
|
||||
toolCallId?: string;
|
||||
bashCommand?: string;
|
||||
bashExitCode?: number;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
@@ -72,6 +75,7 @@ export interface SessionState {
|
||||
error?: string;
|
||||
sessionId?: string | number;
|
||||
sessionName?: string;
|
||||
sessionFile?: string;
|
||||
model?: ModelInfo;
|
||||
thinkingLevel?: string;
|
||||
stats?: {
|
||||
|
||||
39
.pi/agent/extensions/webui/frontend/src/utils/bash.ts
Normal file
39
.pi/agent/extensions/webui/frontend/src/utils/bash.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface BashResult {
|
||||
output: string;
|
||||
exitCode?: number;
|
||||
cancelled?: boolean;
|
||||
truncated?: boolean;
|
||||
fullOutputPath?: string;
|
||||
}
|
||||
|
||||
export interface ParsedBashInput {
|
||||
command: string;
|
||||
display: string;
|
||||
excludeFromContext: boolean;
|
||||
}
|
||||
|
||||
export function parseBashInput(text: string): ParsedBashInput | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith("!")) return null;
|
||||
|
||||
const excludeFromContext = trimmed.startsWith("!!");
|
||||
const command = (excludeFromContext ? trimmed.slice(2) : trimmed.slice(1)).trim();
|
||||
if (!command) return null;
|
||||
|
||||
return {
|
||||
command,
|
||||
display: trimmed,
|
||||
excludeFromContext,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatBashOutput(result: BashResult): string {
|
||||
if (result.cancelled) return "(已取消)";
|
||||
if (result.output?.trim()) {
|
||||
let output = result.output;
|
||||
if (result.truncated) output += "\n...(输出已截断)";
|
||||
return output;
|
||||
}
|
||||
if (result.exitCode === 0) return "(无输出)";
|
||||
return "(无输出)";
|
||||
}
|
||||
@@ -7,10 +7,14 @@ function roleLabel(role: ChatMessage["role"]): string {
|
||||
return "用户";
|
||||
case "assistant":
|
||||
return "助手";
|
||||
case "thinking":
|
||||
return "思考";
|
||||
case "tool_call":
|
||||
return "工具";
|
||||
case "system":
|
||||
return "系统";
|
||||
case "bash":
|
||||
return "Shell";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +32,20 @@ export function messagesToMarkdown(title: string, messages: ChatMessage[]): stri
|
||||
case "assistant":
|
||||
lines.push("## 助手", "", m.content, "");
|
||||
break;
|
||||
case "thinking":
|
||||
lines.push("## 思考", "", m.content, "");
|
||||
break;
|
||||
case "tool_call":
|
||||
lines.push("## 工具", "", "```", m.content, "```", "");
|
||||
break;
|
||||
case "system":
|
||||
lines.push(`> ${m.content}`, "");
|
||||
break;
|
||||
case "bash": {
|
||||
const cmd = m.bashCommand ? `$ ${m.bashCommand}` : "Shell";
|
||||
lines.push(`## Shell (${cmd})`, "", "```", m.content, "```", "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
@@ -54,7 +66,7 @@ export function messagesToMinimalHtml(title: string, messages: ChatMessage[]): s
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escHtml(title)}</title>
|
||||
<style>
|
||||
body{font-family:"LXGW WenKai Mono","霞鹜文楷等宽",ui-monospace,monospace;max-width:720px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#111;background:#fff}
|
||||
body{font-family:"Maple Mono CN",ui-monospace,monospace;letter-spacing:-0.05em;max-width:720px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#111;background:#fff}
|
||||
h1{font-size:1.25rem;font-weight:600;margin:0 0 1.25rem}
|
||||
.block{margin:0 0 1rem}
|
||||
h2{font-size:.75rem;font-weight:600;color:#6b7280;margin:0 0 .35rem;text-transform:uppercase;letter-spacing:.04em}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function isMachineSessionLabel(text: string | undefined, headerId: string
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 首句截取(侧边栏兜底与顶栏回填,需与 server.ts 保持一致逻辑) */
|
||||
/** 首句截取(侧边栏兜底与顶栏回填,需与 backend/services/sessions.ts 保持一致逻辑) */
|
||||
export function titleFromFirstUserMessage(text: string | undefined, maxChars = 56): string {
|
||||
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
|
||||
if (!cleaned) return "";
|
||||
@@ -33,3 +33,18 @@ export function modelLabel(model: { provider: string; id: string } | undefined):
|
||||
if (!model) return "";
|
||||
return `${model.provider}/${model.id}`;
|
||||
}
|
||||
|
||||
/** 顶栏模型下拉展示:[渠道名]模型id,避免同名模型无法区分 */
|
||||
export function shortModelLabel(model: { provider: string; id: string } | undefined, maxChars = 52): string {
|
||||
if (!model) return "";
|
||||
const provider = model.provider.trim();
|
||||
const id = model.id.trim();
|
||||
if (!provider) return id;
|
||||
if (!id) return `[${provider}]`;
|
||||
const prefix = `[${provider}]`;
|
||||
const combined = `${prefix}${id}`;
|
||||
if (combined.length <= maxChars) return combined;
|
||||
const idBudget = Math.max(12, maxChars - prefix.length);
|
||||
const clippedId = id.length <= idBudget ? id : id.slice(0, idBudget);
|
||||
return `${prefix}${clippedId}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { SlashCommand } from "../types/commands";
|
||||
|
||||
/** Keep in sync with backend/slash/dispatch.ts WEBUI_BUILTIN_SLASH_NAMES */
|
||||
const WEBUI_BUILTIN_SLASH_NAMES = new Set([
|
||||
"compact",
|
||||
"new",
|
||||
"reload",
|
||||
"clone",
|
||||
"name",
|
||||
"model",
|
||||
"session",
|
||||
"export",
|
||||
"copy",
|
||||
]);
|
||||
|
||||
export function isWebUiSlashCommand(command: SlashCommand): boolean {
|
||||
if (command.source === "extension" || command.source === "prompt" || command.source === "skill") {
|
||||
return true;
|
||||
}
|
||||
return WEBUI_BUILTIN_SLASH_NAMES.has(command.name);
|
||||
}
|
||||
|
||||
export function filterWebUiSlashCommands(commands: SlashCommand[]): SlashCommand[] {
|
||||
return commands.filter(isWebUiSlashCommand);
|
||||
}
|
||||
|
||||
export function parseSlashCommandInput(text: string): { query: string } | null {
|
||||
const trimmed = text.trimStart();
|
||||
if (!trimmed.startsWith("/")) return null;
|
||||
if (trimmed.includes(" ")) return null;
|
||||
return { query: trimmed.slice(1) };
|
||||
}
|
||||
|
||||
export function filterSlashCommands(commands: SlashCommand[], query: string): SlashCommand[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return commands;
|
||||
|
||||
return commands
|
||||
.filter((command) => command.name.toLowerCase().includes(normalized))
|
||||
.sort((a, b) => {
|
||||
const aStarts = a.name.toLowerCase().startsWith(normalized);
|
||||
const bStarts = b.name.toLowerCase().startsWith(normalized);
|
||||
if (aStarts !== bStarts) return aStarts ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function applySlashCompletion(current: string, commandName: string): string {
|
||||
const leadingSpaces = current.match(/^\s*/)?.[0] ?? "";
|
||||
return `${leadingSpaces}/${commandName} `;
|
||||
}
|
||||
|
||||
export function slashCommandSourceLabel(source: SlashCommand["source"]): string {
|
||||
switch (source) {
|
||||
case "builtin":
|
||||
return "内置";
|
||||
case "extension":
|
||||
return "扩展";
|
||||
case "prompt":
|
||||
return "指令";
|
||||
case "skill":
|
||||
return "技能";
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,24 @@ export function extractContent(content: string | ContentBlock[] | undefined): st
|
||||
return String(content);
|
||||
}
|
||||
|
||||
export function extractThinking(content: string | ContentBlock[] | undefined): string {
|
||||
if (!content) return "";
|
||||
if (typeof content === "string") return "";
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((c) => c.type === "thinking")
|
||||
.map((c) => c.thinking ?? "")
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function deriveThinkingSummary(content: string, maxLen = 56): string {
|
||||
const oneLine = String(content || "").replace(/\s+/g, " ").trim();
|
||||
if (!oneLine) return "思考中…";
|
||||
if (oneLine.length <= maxLen) return oneLine;
|
||||
return `${oneLine.slice(0, maxLen).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function extractImages(content: string | ContentBlock[] | undefined): ImageContent[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -2,64 +2,79 @@ import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "prompt",
|
||||
includeAssets: ["logo.png", "logo192.png", "logo512.png"],
|
||||
manifest: {
|
||||
name: "萌小芽",
|
||||
short_name: "萌小芽",
|
||||
description: "树萌芽智能 AI 助手 Web 客户端",
|
||||
theme_color: "#f7f8fb",
|
||||
background_color: "#f7f8fb",
|
||||
display: "standalone",
|
||||
orientation: "portrait-primary",
|
||||
scope: "/",
|
||||
start_url: "/",
|
||||
lang: "zh-CN",
|
||||
icons: [
|
||||
{
|
||||
src: "logo192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: "index.html",
|
||||
globPatterns: ["**/*.{js,css,html,png,ico,woff2}"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^\/api\//,
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "../dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:19133",
|
||||
const DESKTOP_API_ORIGIN = "https://sproutclaw.shumengya.top";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isDesktop = mode === "desktop";
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "prompt",
|
||||
includeAssets: ["logo.png", "logo192.png", "logo512.png"],
|
||||
manifest: {
|
||||
name: "萌小芽",
|
||||
short_name: "萌小芽",
|
||||
description: "树萌芽智能 AI 助手 Web 客户端",
|
||||
theme_color: "#f7f8fb",
|
||||
background_color: "#f7f8fb",
|
||||
display: "standalone",
|
||||
orientation: "portrait-primary",
|
||||
scope: "/",
|
||||
start_url: "/",
|
||||
lang: "zh-CN",
|
||||
icons: [
|
||||
{
|
||||
src: "logo192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: "index.html",
|
||||
globPatterns: ["**/*.{js,css,html,png,ico,woff2}"],
|
||||
runtimeCaching: isDesktop
|
||||
? [
|
||||
{
|
||||
urlPattern: new RegExp(
|
||||
`^${DESKTOP_API_ORIGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/api/`,
|
||||
),
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
urlPattern: /^\/api\//,
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: isDesktop ? "dist-desketop" : "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:19133",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* 用法:
|
||||
* /webui on → 启动网页服务(默认端口 19133)
|
||||
* /webui off → 停止网页服务
|
||||
* /webui down → 同 off
|
||||
* /webui reload → 重载网页服务(等同 off + on,保留当前端口)
|
||||
* /webui on 8080 → 指定端口启动
|
||||
*/
|
||||
|
||||
@@ -15,14 +17,25 @@ import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
DEFAULT_WEBUI_PORT,
|
||||
WEBUI_SERVICE_NAME,
|
||||
ensureSystemdService,
|
||||
getSystemdDisabledReason,
|
||||
isSystemdManaged,
|
||||
syncSystemdPort,
|
||||
systemdControl,
|
||||
type SystemdServiceConfig,
|
||||
} from "./systemd/service.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = join(__dirname, ".webui.pid");
|
||||
const DIST_DIR = join(__dirname, "dist");
|
||||
const DIST_DIR = join(__dirname, "frontend", "dist");
|
||||
const FRONTEND_DIR = join(__dirname, "frontend");
|
||||
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
let serverPort = 19133;
|
||||
let serverPort = DEFAULT_WEBUI_PORT;
|
||||
let systemdConfig: SystemdServiceConfig | null = null;
|
||||
|
||||
function findTsx(): string {
|
||||
// 从扩展所在目录向上找 repo 根目录
|
||||
@@ -93,6 +106,15 @@ function isProcessAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForStartupByProbe(port: number, timeoutMs = 10000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if ((await probePort(port)) === "running") return;
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error("webui 启动超时");
|
||||
}
|
||||
|
||||
function waitForStartup(port: number, child: ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
@@ -151,23 +173,62 @@ function probePort(port: number): Promise<"free" | "running" | "occupied"> {
|
||||
});
|
||||
}
|
||||
|
||||
function findNpm(): string {
|
||||
const local = join(dirname(process.execPath), "npm");
|
||||
if (existsSync(local)) return local;
|
||||
|
||||
let dir = __dirname;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const candidate = join(dir, "node_modules", ".bin", "npm");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
return "npm";
|
||||
}
|
||||
|
||||
function runNpm(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
): { ok: boolean; status: number | null; error?: string } {
|
||||
const result = spawnSync(findNpm(), args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
if (result.error) {
|
||||
return { ok: false, status: result.status, error: result.error.message };
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
return { ok: false, status: result.status };
|
||||
}
|
||||
return { ok: true, status: 0 };
|
||||
}
|
||||
|
||||
async function ensureBuilt(ctx: { ui: { notify: (msg: string, kind: string) => void } }): Promise<boolean> {
|
||||
const indexHtml = join(DIST_DIR, "index.html");
|
||||
if (existsSync(indexHtml)) return true;
|
||||
|
||||
ctx.ui.notify("正在构建 WebUI 前端...", "info");
|
||||
ctx.ui.notify("正在构建 WebUI 前端(build:web)...", "info");
|
||||
|
||||
if (!existsSync(join(FRONTEND_DIR, "node_modules"))) {
|
||||
const install = spawnSync("npm", ["install"], { cwd: FRONTEND_DIR, stdio: "inherit" });
|
||||
if (install.status !== 0) {
|
||||
ctx.ui.notify("WebUI 前端依赖安装失败", "error");
|
||||
const install = runNpm(["install"], FRONTEND_DIR);
|
||||
if (!install.ok) {
|
||||
const detail = install.error ?? (install.status != null ? `exit ${install.status}` : "unknown");
|
||||
ctx.ui.notify(`WebUI 前端依赖安装失败: ${detail}`, "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const build = spawnSync("npm", ["run", "build"], { cwd: FRONTEND_DIR, stdio: "inherit" });
|
||||
if (build.status !== 0 || !existsSync(indexHtml)) {
|
||||
ctx.ui.notify("WebUI 前端构建失败", "error");
|
||||
const build = runNpm(["run", "build:web"], FRONTEND_DIR);
|
||||
if (!build.ok || !existsSync(indexHtml)) {
|
||||
const details: string[] = [];
|
||||
if (build.error) details.push(build.error);
|
||||
if (build.status != null && build.status !== 0) details.push(`exit ${build.status}`);
|
||||
if (!existsSync(indexHtml)) details.push(`未生成 ${indexHtml}`);
|
||||
ctx.ui.notify(`WebUI 前端构建失败${details.length ? `: ${details.join("; ")}` : ""}`, "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -176,7 +237,7 @@ async function ensureBuilt(ctx: { ui: { notify: (msg: string, kind: string) => v
|
||||
}
|
||||
|
||||
async function startServer(port: number, ctx: any): Promise<void> {
|
||||
if (serverProcess) {
|
||||
if (!isSystemdManaged() && serverProcess) {
|
||||
ctx.ui.notify(`网页服务已在端口 ${serverPort} 运行`, "error");
|
||||
return;
|
||||
}
|
||||
@@ -199,20 +260,40 @@ async function startServer(port: number, ctx: any): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await ensureBuilt(ctx))) {
|
||||
return;
|
||||
}
|
||||
|
||||
serverPort = port;
|
||||
|
||||
if (isSystemdManaged() && systemdConfig) {
|
||||
syncSystemdPort(systemdConfig, port);
|
||||
const result = systemdControl("start");
|
||||
if (!result.ok) {
|
||||
ctx.ui.notify(`systemctl start 失败: ${result.output}`, "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await waitForStartupByProbe(port);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.ui.notify(`网页服务启动失败: ${message}`, "error");
|
||||
return;
|
||||
}
|
||||
ctx.ui.notify(`网页服务已启动(systemd)→ http://smallmengya:${port}`, "info");
|
||||
notifyLanAddresses(ctx, port);
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = findRepoRoot();
|
||||
const tsxBin = findTsx();
|
||||
const serverFile = join(__dirname, "server.ts");
|
||||
const serverFile = join(__dirname, "backend", "main.ts");
|
||||
|
||||
if (!existsSync(serverFile)) {
|
||||
ctx.ui.notify(`服务器文件未找到: ${serverFile}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await ensureBuilt(ctx))) {
|
||||
return;
|
||||
}
|
||||
|
||||
serverPort = port;
|
||||
serverProcess = spawn(tsxBin, [serverFile, "--port", String(port)], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
@@ -222,7 +303,7 @@ async function startServer(port: number, ctx: any): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
serverProcess.on("exit", () => {
|
||||
serverProcess = null;
|
||||
clearPidFile();
|
||||
});
|
||||
@@ -233,16 +314,19 @@ async function startServer(port: number, ctx: any): Promise<void> {
|
||||
|
||||
try {
|
||||
await waitForStartup(port, serverProcess);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
clearPidFile();
|
||||
serverProcess = null;
|
||||
ctx.ui.notify(`网页服务启动失败: ${err.message}`, "error");
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.ui.notify(`网页服务启动失败: ${message}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(`网页服务已启动 → http://smallmengya:${port}`, "info");
|
||||
notifyLanAddresses(ctx, port);
|
||||
}
|
||||
|
||||
// 显示局域网地址
|
||||
function notifyLanAddresses(ctx: any, port: number): void {
|
||||
const { networkInterfaces } = require("node:os");
|
||||
const nets = networkInterfaces();
|
||||
for (const name of Object.keys(nets)) {
|
||||
@@ -304,7 +388,26 @@ async function stopByPort(port: number, ctx: any): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function stopServer(ctx: any, port = serverPort || 19133): Promise<void> {
|
||||
async function stopServer(ctx: any, port = serverPort || DEFAULT_WEBUI_PORT): Promise<void> {
|
||||
if (isSystemdManaged()) {
|
||||
const state = await probePort(port);
|
||||
if (state === "free") {
|
||||
clearPidFile();
|
||||
ctx.ui.notify("网页服务未运行", "info");
|
||||
return;
|
||||
}
|
||||
const result = systemdControl("stop");
|
||||
if (!result.ok) {
|
||||
ctx.ui.notify(`systemctl stop 失败: ${result.output}`, "error");
|
||||
return;
|
||||
}
|
||||
await waitForPortFree(port);
|
||||
clearPidFile();
|
||||
serverPort = 0;
|
||||
ctx.ui.notify(`网页服务已停止(systemd,端口 ${port})`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverProcess) {
|
||||
serverProcess.kill("SIGTERM");
|
||||
serverProcess = null;
|
||||
@@ -338,23 +441,78 @@ async function stopServer(ctx: any, port = serverPort || 19133): Promise<void> {
|
||||
ctx.ui.notify("网页服务未运行", "info");
|
||||
}
|
||||
|
||||
async function reloadServer(ctx: any, port: number): Promise<void> {
|
||||
if (isSystemdManaged() && systemdConfig) {
|
||||
if (!(await ensureBuilt(ctx))) return;
|
||||
syncSystemdPort(systemdConfig, port);
|
||||
const result = systemdControl("restart");
|
||||
if (!result.ok) {
|
||||
ctx.ui.notify(`systemctl restart 失败: ${result.output}`, "error");
|
||||
return;
|
||||
}
|
||||
serverPort = port;
|
||||
try {
|
||||
await waitForStartupByProbe(port);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.ui.notify(`网页服务重载失败: ${message}`, "error");
|
||||
return;
|
||||
}
|
||||
ctx.ui.notify(`网页服务已重载(systemd,端口 ${port})`, "info");
|
||||
notifyLanAddresses(ctx, port);
|
||||
return;
|
||||
}
|
||||
|
||||
await stopServer(ctx, port);
|
||||
await startServer(port, ctx);
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const repoRoot = findRepoRoot();
|
||||
systemdConfig = {
|
||||
extensionDir: __dirname,
|
||||
repoRoot,
|
||||
agentDir: getAgentDir(repoRoot),
|
||||
nodeBin: process.execPath,
|
||||
tsxCli: join(repoRoot, "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
};
|
||||
|
||||
if (ensureSystemdService(systemdConfig, serverPort || DEFAULT_WEBUI_PORT)) {
|
||||
console.log(`[webui] 已注册 systemd 保活服务: ${WEBUI_SERVICE_NAME}`);
|
||||
} else {
|
||||
const reason = getSystemdDisabledReason();
|
||||
if (reason) {
|
||||
console.warn(`[webui] systemd 保活不可用,回退进程内管理: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
pi.registerCommand("webui", {
|
||||
description: "通过 /webui on 启动、/webui off 停止 Web 聊天界面",
|
||||
description: "通过 /webui on 启动、/webui off|down 停止、/webui reload 重载 Web 聊天界面",
|
||||
handler: async (args, ctx) => {
|
||||
const trimmed = args.trim();
|
||||
const [command = "", value = ""] = trimmed.split(/\s+/, 2);
|
||||
|
||||
if (command === "off" || command === "stop" || command === "0") {
|
||||
const stopPort = value ? parseInt(value, 10) : serverPort || 19133;
|
||||
await stopServer(ctx, Number.isFinite(stopPort) ? stopPort : 19133);
|
||||
if (command === "off" || command === "stop" || command === "down" || command === "0") {
|
||||
const stopPort = value ? parseInt(value, 10) : serverPort || DEFAULT_WEBUI_PORT;
|
||||
await stopServer(ctx, Number.isFinite(stopPort) ? stopPort : DEFAULT_WEBUI_PORT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "reload" || command === "restart") {
|
||||
const portArg = value ? parseInt(value, 10) : NaN;
|
||||
const reloadPort =
|
||||
Number.isFinite(portArg) && portArg >= 1 && portArg <= 65535
|
||||
? portArg
|
||||
: serverPort || DEFAULT_WEBUI_PORT;
|
||||
ctx.ui.notify(`正在重载 WebUI(端口 ${reloadPort})…`, "info");
|
||||
await reloadServer(ctx, reloadPort);
|
||||
return;
|
||||
}
|
||||
|
||||
const portInput = command === "on" ? value : command;
|
||||
const port = portInput ? parseInt(portInput, 10) : 19133;
|
||||
const port = portInput ? parseInt(portInput, 10) : DEFAULT_WEBUI_PORT;
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
ctx.ui.notify(`无效端口: ${trimmed || "(空)"},使用 19133`, "error");
|
||||
ctx.ui.notify(`无效端口: ${trimmed || "(空)"},使用 ${DEFAULT_WEBUI_PORT}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
10
.pi/agent/extensions/webui/scripts/webui-run.sh
Executable file
10
.pi/agent/extensions/webui/scripts/webui-run.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
: "${REPO_ROOT:?REPO_ROOT required}"
|
||||
: "${EXTENSION_DIR:?EXTENSION_DIR required}"
|
||||
: "${NODE_BIN:?NODE_BIN required}"
|
||||
: "${TSX_CLI:?TSX_CLI required}"
|
||||
: "${PI_CODING_AGENT_DIR:?PI_CODING_AGENT_DIR required}"
|
||||
PORT="${PORT:-19133}"
|
||||
cd "$REPO_ROOT"
|
||||
exec "$NODE_BIN" "$TSX_CLI" "$EXTENSION_DIR/backend/main.ts" --port "$PORT"
|
||||
@@ -1,928 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* pi-mono WebUI Server
|
||||
*
|
||||
* 被 webui 扩展启动,作为独立子进程运行。
|
||||
* 内嵌 HTTP 服务 + pi RPC 子进程,提供浏览器聊天界面。
|
||||
*
|
||||
* 用法(由扩展自动调用):
|
||||
* tsx server.ts --port 19133
|
||||
*
|
||||
* 前端静态文件位于同目录 dist/ 下(Vite 构建产物)。
|
||||
* 兼容旧启动路径:pi 扩展未重载时 index.ts 仍可能 spawn server.ts。
|
||||
* 实际逻辑在 backend/main.ts。
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { appendFileSync, readFileSync, readdirSync, statSync, existsSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname, resolve, basename } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
closeWebuiDatabase,
|
||||
deleteWebuiConfig,
|
||||
getAllWebuiConfig,
|
||||
getWebuiConfig,
|
||||
initWebuiDatabase,
|
||||
prunePinnedSessionPaths,
|
||||
readWebuiAvatarSettings,
|
||||
removePinnedSessionPath,
|
||||
setSessionPinned,
|
||||
setWebuiConfig,
|
||||
setWebuiConfigMany,
|
||||
writeWebuiAvatarSettings,
|
||||
} from "./db.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PUBLIC_DIR = join(__dirname, "dist");
|
||||
const PID_FILE = join(__dirname, ".webui.pid");
|
||||
|
||||
// 从 CWD 确定 repo 根目录(启动时 CWD 为 repo 根目录)
|
||||
const REPO_ROOT = process.cwd();
|
||||
const AGENT_DIR = resolve(process.env.PI_CODING_AGENT_DIR || join(REPO_ROOT, ".pi", "agent"));
|
||||
const SESSIONS_DIR = join(AGENT_DIR, "sessions");
|
||||
const AGENT_EXTENSIONS_DIR = join(AGENT_DIR, "extensions");
|
||||
const SYSTEM_PROMPT_FILE = join(AGENT_DIR, "AGENTS.md");
|
||||
const MCP_CACHE_FILE = join(AGENT_DIR, "mcp-cache.json");
|
||||
const MCP_CONFIG_FILE = join(AGENT_DIR, "mcp.json");
|
||||
const TSX_BIN = join(REPO_ROOT, "node_modules", ".bin", "tsx");
|
||||
const PI_CLI_DIST = join(REPO_ROOT, "packages", "coding-agent", "dist", "cli.js");
|
||||
const PI_CLI_SRC = join(REPO_ROOT, "packages", "coding-agent", "src", "cli.ts");
|
||||
|
||||
function resolvePiRpcLaunch(): { command: string; args: string[]; mode: "dist" | "src" } {
|
||||
const rpcArgs = ["--mode", "rpc"];
|
||||
if (existsSync(PI_CLI_DIST)) {
|
||||
return { command: process.execPath, args: [PI_CLI_DIST, ...rpcArgs], mode: "dist" };
|
||||
}
|
||||
console.warn(`[webui] 未找到 ${PI_CLI_DIST},回退 tsx 源码启动(较慢)。请运行: npm run build --workspace=@earendil-works/pi-coding-agent`);
|
||||
return { command: TSX_BIN, args: [PI_CLI_SRC, ...rpcArgs], mode: "src" };
|
||||
}
|
||||
|
||||
// ─── 解析 CLI 参数 ─────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
function getArg(flag: string): string | undefined {
|
||||
const idx = args.indexOf(flag);
|
||||
return idx !== -1 ? args[idx + 1] : undefined;
|
||||
}
|
||||
const PORT = parseInt(getArg("--port") || "19133", 10);
|
||||
|
||||
function writePidFile(): void {
|
||||
try {
|
||||
writeFileSync(PID_FILE, String(process.pid), "utf8");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function clearPidFile(): void {
|
||||
try {
|
||||
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function shutdown(exitCode = 0): never {
|
||||
closeWebuiDatabase();
|
||||
clearPidFile();
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
const webuiDb = initWebuiDatabase(__dirname);
|
||||
console.log(`[webui] 配置数据库: ${webuiDb.dbPath}`);
|
||||
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
writePidFile();
|
||||
|
||||
// ─── 启动 pi RPC 子进程 ────────────────────────────────────────────
|
||||
|
||||
const piLaunch = resolvePiRpcLaunch();
|
||||
|
||||
console.log(`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${piLaunch.args.join(" ")}`);
|
||||
|
||||
const pi = spawn(piLaunch.command, piLaunch.args, {
|
||||
cwd: REPO_ROOT,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
PI_CODING_AGENT_DIR: AGENT_DIR,
|
||||
},
|
||||
});
|
||||
|
||||
pi.stderr.on("data", (data) => process.stderr.write(`[pi] ${data}`));
|
||||
pi.on("exit", (code) => {
|
||||
console.log(`[webui] pi 退出, code=${code}`);
|
||||
shutdown(1);
|
||||
});
|
||||
|
||||
// ─── JSONL 行协议 ──────────────────────────────────────────────────
|
||||
|
||||
let buffer = "";
|
||||
const pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
||||
const sseClients = new Set<any>();
|
||||
let reqId = 0;
|
||||
|
||||
function onLine(line: string) {
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.type === "response" && msg.id && pending.has(msg.id)) {
|
||||
const p = pending.get(msg.id)!;
|
||||
pending.delete(msg.id);
|
||||
p.resolve(msg);
|
||||
return;
|
||||
}
|
||||
const data = `data: ${JSON.stringify(msg)}\n\n`;
|
||||
for (const res of sseClients) res.write(data);
|
||||
} catch {
|
||||
// 忽略非 JSON 行
|
||||
}
|
||||
}
|
||||
|
||||
pi.stdout.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) onLine(line);
|
||||
});
|
||||
|
||||
function sendCmd(command: Record<string, unknown>): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = `req_${++reqId}`;
|
||||
const line = JSON.stringify({ ...command, id }) + "\n";
|
||||
pending.set(id, { resolve, reject });
|
||||
pi.stdin.write(line);
|
||||
setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id);
|
||||
reject(new Error(`命令超时: ${command.type}`));
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 会话文件读取 ──────────────────────────────────────────────────
|
||||
|
||||
function listSessionFiles(): string[] {
|
||||
if (!existsSync(SESSIONS_DIR)) return [];
|
||||
const files: string[] = [];
|
||||
const visit = (dir: string) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(SESSIONS_DIR);
|
||||
return files
|
||||
.sort()
|
||||
.reverse();
|
||||
}
|
||||
|
||||
function readSessionSummary(filePath: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const lines = content.trim().split("\n");
|
||||
if (!lines.length) return null;
|
||||
const header = JSON.parse(lines[0]);
|
||||
if (header.type !== "session") return null;
|
||||
|
||||
let nameFromInfo = "";
|
||||
let messageCount = 0;
|
||||
let firstMessage = "";
|
||||
const stats = statSync(filePath);
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === "session_info" && entry.name) {
|
||||
const n = String(entry.name).trim();
|
||||
if (n && !isMachineSessionLabel(n, header.id)) {
|
||||
nameFromInfo = n;
|
||||
}
|
||||
}
|
||||
if (entry.type === "message") {
|
||||
messageCount++;
|
||||
if (!firstMessage && entry.message?.role === "user") {
|
||||
firstMessage = extractPreview(entry.message);
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
const fromFirstUser = titleFromFirstUserMessage(firstMessage);
|
||||
let name = nameFromInfo;
|
||||
if (!name || isMachineSessionLabel(name, header.id)) {
|
||||
name = fromFirstUser || "";
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
id: header.id,
|
||||
name,
|
||||
created: header.timestamp,
|
||||
modified: stats.mtime.toISOString(),
|
||||
messageCount,
|
||||
firstMessage: firstMessage || "(空)",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isMachineSessionLabel(text: string, sessionHeaderId: string): boolean {
|
||||
const t = (text ?? "").trim();
|
||||
if (!t) return true;
|
||||
if (sessionHeaderId && t === sessionHeaderId) return true;
|
||||
if (/^[0-9a-f]{8,}$/i.test(t)) return true;
|
||||
if (/^[0-9]{10,}$/.test(t)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 侧边栏标题:优先用户首句(遇句号等截断),否则整段截取 */
|
||||
function titleFromFirstUserMessage(text: string, maxChars = 56): string {
|
||||
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
|
||||
if (!cleaned) return "";
|
||||
const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/);
|
||||
let candidate =
|
||||
sentenceMatch && sentenceMatch[1] ? sentenceMatch[1].trim() : cleaned;
|
||||
if (candidate.length > maxChars) {
|
||||
candidate = `${candidate.slice(0, maxChars).trimEnd()}…`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function readSessionMessages(filePath: string): unknown[] {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
return content
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => { try { const e = JSON.parse(l); return e.type === "message" ? e.message : null; } catch { return null; } })
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function extractPreview(msg: any): string {
|
||||
const c = msg.content;
|
||||
if (!c) return "";
|
||||
if (typeof c === "string") return c.slice(0, 200);
|
||||
if (Array.isArray(c)) {
|
||||
const text = c.filter((x: any) => x.type === "text").map((x: any) => x.text).join("").slice(0, 200);
|
||||
if (text) return text;
|
||||
const imageCount = c.filter((x: any) => x.type === "image").length;
|
||||
if (imageCount > 0) return `[${imageCount} 张图片]`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const ALLOWED_CHAT_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||
const MAX_CHAT_IMAGES = 8;
|
||||
const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function normalizeChatImages(images: unknown): Array<{ type: "image"; data: string; mimeType: string }> | undefined {
|
||||
if (!Array.isArray(images) || images.length === 0) return undefined;
|
||||
const out: Array<{ type: "image"; data: string; mimeType: string }> = [];
|
||||
for (const raw of images.slice(0, MAX_CHAT_IMAGES)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const mimeType = String((raw as any).mimeType || "");
|
||||
const data = String((raw as any).data || "");
|
||||
if ((raw as any).type !== "image" || !ALLOWED_CHAT_IMAGE_MIME.has(mimeType) || !data) {
|
||||
throw new Error(`不支持的图片类型: ${mimeType || "unknown"}`);
|
||||
}
|
||||
const size = Buffer.from(data, "base64").length;
|
||||
if (size > MAX_CHAT_IMAGE_BYTES) {
|
||||
throw new Error(`图片过大(最大 ${Math.round(MAX_CHAT_IMAGE_BYTES / 1024 / 1024)}MB)`);
|
||||
}
|
||||
out.push({ type: "image", mimeType, data });
|
||||
}
|
||||
return out.length ? out : undefined;
|
||||
}
|
||||
|
||||
function resolveSessionFile(filePath: string): string {
|
||||
const resolved = resolve(filePath);
|
||||
const sessionsRoot = resolve(SESSIONS_DIR);
|
||||
if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) {
|
||||
throw new Error("无效会话路径");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function appendSessionName(filePath: string, name: string): string {
|
||||
const sessionPath = resolveSessionFile(filePath);
|
||||
if (!existsSync(sessionPath)) throw new Error("会话不存在");
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error("会话名称不能为空");
|
||||
|
||||
const lines = readFileSync(sessionPath, "utf8").trim().split("\n");
|
||||
const ids = new Set<string>();
|
||||
let leafId: string | null = null;
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (typeof entry.id === "string") {
|
||||
ids.add(entry.id);
|
||||
leafId = entry.id;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (!leafId) throw new Error("无效会话文件");
|
||||
|
||||
let id = randomUUID().slice(0, 8);
|
||||
for (let i = 0; i < 100 && ids.has(id); i++) {
|
||||
id = randomUUID().slice(0, 8);
|
||||
}
|
||||
|
||||
const entry = {
|
||||
type: "session_info",
|
||||
id,
|
||||
parentId: leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
name: trimmed,
|
||||
};
|
||||
appendFileSync(sessionPath, `\n${JSON.stringify(entry)}`, "utf8");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function readSystemPrompt(): string {
|
||||
if (!existsSync(SYSTEM_PROMPT_FILE)) return "";
|
||||
return readFileSync(SYSTEM_PROMPT_FILE, "utf8");
|
||||
}
|
||||
|
||||
function writeSystemPrompt(content: string): void {
|
||||
mkdirSync(dirname(SYSTEM_PROMPT_FILE), { recursive: true });
|
||||
writeFileSync(SYSTEM_PROMPT_FILE, content, "utf8");
|
||||
}
|
||||
|
||||
function normalizeAvatarUrl(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
const url = value.trim();
|
||||
if (!url) return "";
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`无效头像链接: ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("头像链接仅支持 http 或 https");
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function normalizeConfigKey(key: unknown): string {
|
||||
if (typeof key !== "string") throw new Error("配置键必须是字符串");
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) throw new Error("配置键不能为空");
|
||||
if (trimmed.length > 128) throw new Error("配置键过长");
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(trimmed)) {
|
||||
throw new Error("配置键格式无效");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeConfigValue(value: unknown): string {
|
||||
if (typeof value !== "string") throw new Error("配置值必须是字符串");
|
||||
if (value.length > 65536) throw new Error("配置值过长");
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSkillCommand(command: any): Record<string, unknown> {
|
||||
const rawName = String(command.name || "");
|
||||
const sourceInfo = command.sourceInfo || {};
|
||||
return {
|
||||
name: rawName.replace(/^skill:/, ""),
|
||||
command: rawName,
|
||||
description: command.description || "",
|
||||
scope: sourceInfo.scope || "",
|
||||
source: sourceInfo.source || "",
|
||||
path: sourceInfo.path || "",
|
||||
};
|
||||
}
|
||||
|
||||
async function listLoadedSkills(): Promise<Record<string, unknown>[]> {
|
||||
const response = await sendCmd({ type: "get_commands" });
|
||||
if (!response.success) throw new Error(response.error || "读取 skills 失败");
|
||||
const commands = response.data?.commands || [];
|
||||
return commands
|
||||
.filter((command: any) => command?.source === "skill")
|
||||
.map(normalizeSkillCommand)
|
||||
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
|
||||
}
|
||||
|
||||
function normalizeExtension(extension: any): Record<string, unknown> {
|
||||
const sourceInfo = extension.sourceInfo || {};
|
||||
const pathValue = String(extension.path || "");
|
||||
const source = String(sourceInfo.source || "");
|
||||
const resolvedPath = String(extension.resolvedPath || "");
|
||||
return {
|
||||
name: displayExtensionName(pathValue, source),
|
||||
rawName: extension.name || "",
|
||||
path: pathValue,
|
||||
resolvedPath,
|
||||
scope: sourceInfo.scope || "",
|
||||
source,
|
||||
sourcePath: sourceInfo.path || "",
|
||||
location: displayExtensionLocation(pathValue, resolvedPath),
|
||||
kind: displayExtensionKind(sourceInfo.scope, source),
|
||||
commands: extension.commands || [],
|
||||
tools: extension.tools || [],
|
||||
flags: extension.flags || [],
|
||||
shortcuts: extension.shortcuts || [],
|
||||
handlers: extension.handlers || [],
|
||||
};
|
||||
}
|
||||
|
||||
function isProjectExtension(extension: any): boolean {
|
||||
const pathValue = String(extension.path || extension.resolvedPath || "");
|
||||
if (!pathValue) return false;
|
||||
const resolved = resolve(pathValue);
|
||||
const agentExtensionsRoot = resolve(AGENT_EXTENSIONS_DIR);
|
||||
return resolved === agentExtensionsRoot || resolved.startsWith(`${agentExtensionsRoot}/`);
|
||||
}
|
||||
|
||||
function displayExtensionName(extensionPath: string, source: string): string {
|
||||
const sourceMatch = source.match(/^npm:(.+)$/);
|
||||
if (sourceMatch?.[1]) return sourceMatch[1];
|
||||
|
||||
const normalized = extensionPath.replace(/\\/g, "/");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
const file = parts[parts.length - 1] || normalized;
|
||||
if (/^index\.[tj]s$/i.test(file) && parts.length >= 2) {
|
||||
return cleanExtensionName(parts[parts.length - 2]);
|
||||
}
|
||||
return cleanExtensionName(file);
|
||||
}
|
||||
|
||||
function cleanExtensionName(name: string): string {
|
||||
return basename(name).replace(/\.[cm]?[tj]s$/i, "");
|
||||
}
|
||||
|
||||
function displayExtensionKind(scope: string, source: string): string {
|
||||
const scopeText = scope === "project" ? "项目" : scope === "user" ? "用户" : scope || "未知";
|
||||
if (source.startsWith("npm:")) return `${scopeText} NPM`;
|
||||
if (source === "auto") return `${scopeText}本地`;
|
||||
return source ? `${scopeText} · ${source}` : scopeText;
|
||||
}
|
||||
|
||||
function displayExtensionLocation(extensionPath: string, resolvedPath: string): string {
|
||||
const pathValue = extensionPath || resolvedPath;
|
||||
if (!pathValue) return "";
|
||||
return pathValue.replace(REPO_ROOT, ".");
|
||||
}
|
||||
|
||||
async function listLoadedExtensions(): Promise<Record<string, unknown>[]> {
|
||||
const response = await sendCmd({ type: "get_extensions" });
|
||||
if (!response.success) throw new Error(response.error || "读取扩展失败");
|
||||
return (response.data?.extensions || [])
|
||||
.filter(isProjectExtension)
|
||||
.map(normalizeExtension)
|
||||
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
|
||||
}
|
||||
|
||||
function readJsonFile(filePath: string): any | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMcpConfig(): any {
|
||||
const projectConfig = readJsonFile(MCP_CONFIG_FILE);
|
||||
if (projectConfig?.mcpServers && typeof projectConfig.mcpServers === "object") {
|
||||
return projectConfig;
|
||||
}
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
|
||||
function normalizeMcpTool(serverName: string, tool: any): Record<string, unknown> {
|
||||
const schema = tool?.inputSchema && typeof tool.inputSchema === "object" ? tool.inputSchema : {};
|
||||
const properties = schema && typeof schema.properties === "object" ? Object.keys(schema.properties) : [];
|
||||
const required = Array.isArray(schema.required) ? schema.required.map(String) : [];
|
||||
return {
|
||||
server: serverName,
|
||||
name: String(tool?.name || ""),
|
||||
description: String(tool?.description || ""),
|
||||
parameters: properties,
|
||||
required,
|
||||
hasSchema: Object.keys(schema).length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function listMcpTools(): Record<string, unknown>[] {
|
||||
const config = readMcpConfig();
|
||||
const cache = readJsonFile(MCP_CACHE_FILE);
|
||||
const configuredServers = config?.mcpServers && typeof config.mcpServers === "object"
|
||||
? Object.keys(config.mcpServers)
|
||||
: [];
|
||||
const cachedServers = cache?.servers && typeof cache.servers === "object" ? cache.servers : {};
|
||||
const serverNames = Array.from(new Set([...configuredServers, ...Object.keys(cachedServers)])).sort();
|
||||
|
||||
return serverNames.map((serverName) => {
|
||||
const entry = cachedServers[serverName] || {};
|
||||
const tools = Array.isArray(entry.tools)
|
||||
? entry.tools.map((tool: any) => normalizeMcpTool(serverName, tool)).filter((tool: any) => tool.name)
|
||||
: [];
|
||||
const resources = Array.isArray(entry.resources) ? entry.resources : [];
|
||||
return {
|
||||
name: serverName,
|
||||
configured: configuredServers.includes(serverName),
|
||||
cached: Boolean(cachedServers[serverName]),
|
||||
toolCount: tools.length,
|
||||
resourceCount: resources.length,
|
||||
cachedAt: entry.cachedAt ? new Date(entry.cachedAt).toISOString() : "",
|
||||
tools: tools.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name))),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function sortSessionSummaries(
|
||||
summaries: Array<Record<string, unknown>>,
|
||||
pinnedPaths: string[],
|
||||
): Array<Record<string, unknown>> {
|
||||
const pinnedOrder = new Map(pinnedPaths.map((path, index) => [path, index]));
|
||||
return [...summaries].sort((a, b) => {
|
||||
const aPath = String(a.path);
|
||||
const bPath = String(b.path);
|
||||
const aPin = pinnedOrder.get(aPath);
|
||||
const bPin = pinnedOrder.get(bPath);
|
||||
if (aPin !== undefined && bPin !== undefined) return aPin - bPin;
|
||||
if (aPin !== undefined) return -1;
|
||||
if (bPin !== undefined) return 1;
|
||||
return new Date(String(b.modified || b.created || 0)).getTime() - new Date(String(a.modified || a.created || 0)).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function annotatePinnedSessions(
|
||||
summaries: Array<Record<string, unknown>>,
|
||||
pinnedPaths: string[],
|
||||
): Array<Record<string, unknown>> {
|
||||
const pinnedSet = new Set(pinnedPaths);
|
||||
return summaries.map((summary) => ({
|
||||
...summary,
|
||||
pinned: pinnedSet.has(String(summary.path)),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSessionListResponse() {
|
||||
const summaries = listSessionFiles()
|
||||
.map(readSessionSummary)
|
||||
.filter(Boolean) as Array<Record<string, unknown>>;
|
||||
const pinnedPaths = prunePinnedSessionPaths(summaries.map((summary) => String(summary.path)));
|
||||
const sorted = sortSessionSummaries(summaries, pinnedPaths);
|
||||
return {
|
||||
sessions: annotatePinnedSessions(sorted, pinnedPaths),
|
||||
pinnedPaths,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── HTTP 服务 ─────────────────────────────────────────────────────
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".woff2": "font/woff2",
|
||||
".woff": "font/woff",
|
||||
".ico": "image/x-icon",
|
||||
".webmanifest": "application/manifest+json",
|
||||
".json": "application/json",
|
||||
};
|
||||
|
||||
function serveStatic(urlPath: string, res: any, spaFallback = false): void {
|
||||
const file = urlPath === "/" ? "/index.html" : urlPath;
|
||||
const full = join(PUBLIC_DIR, file);
|
||||
if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end("Forbidden"); return; }
|
||||
if (!existsSync(full)) {
|
||||
if (spaFallback && !urlPath.includes(".")) {
|
||||
return serveStatic("/index.html", res, false);
|
||||
}
|
||||
res.writeHead(404); res.end("Not found"); return;
|
||||
}
|
||||
const ext = file.match(/\.\w+$/)?.[0] || ".html";
|
||||
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
|
||||
res.end(readFileSync(full));
|
||||
}
|
||||
|
||||
function json(res: any, data: unknown, status = 200): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function readBody(req: any): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (c: Buffer) => (body += c.toString()));
|
||||
req.on("end", () => { try { resolve(JSON.parse(body)); } catch { reject(new Error("无效 JSON")); } });
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url!, `http://localhost:${PORT}`);
|
||||
|
||||
// 静态文件
|
||||
if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
|
||||
return serveStatic(url.pathname, res, true);
|
||||
}
|
||||
|
||||
// POST /api/chat
|
||||
if (req.method === "POST" && url.pathname === "/api/chat") {
|
||||
return readBody(req)
|
||||
.then(({ message, images }) => {
|
||||
const msg = typeof message === "string" ? message : "";
|
||||
const imgs = normalizeChatImages(images);
|
||||
if (!msg.trim() && !imgs?.length) throw new Error("消息不能为空");
|
||||
if (imgs?.length) console.log(`[webui] chat: ${imgs.length} image(s), message=${msg.length} chars`);
|
||||
return sendCmd({ type: "prompt", message: msg, images: imgs }).then(() => json(res, { ok: true }));
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/events (SSE)
|
||||
if (req.method === "GET" && url.pathname === "/api/events") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write('data: {"type":"connected"}\n\n');
|
||||
sseClients.add(res);
|
||||
req.on("close", () => sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /api/messages
|
||||
if (req.method === "POST" && url.pathname === "/api/messages") {
|
||||
return sendCmd({ type: "get_messages" })
|
||||
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/new-session
|
||||
if (req.method === "POST" && url.pathname === "/api/new-session") {
|
||||
return sendCmd({ type: "new_session" })
|
||||
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/abort
|
||||
if (req.method === "POST" && url.pathname === "/api/abort") {
|
||||
return sendCmd({ type: "abort" })
|
||||
.then(() => json(res, { ok: true }))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/models
|
||||
if (req.method === "GET" && url.pathname === "/api/models") {
|
||||
return sendCmd({ type: "get_available_models" })
|
||||
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/model
|
||||
if (req.method === "POST" && url.pathname === "/api/model") {
|
||||
return readBody(req)
|
||||
.then(({ provider, modelId }) =>
|
||||
sendCmd({ type: "set_model", provider, modelId }).then((r: any) =>
|
||||
r.success ? json(res, { model: r.data }) : json(res, { error: r.error }, 500),
|
||||
),
|
||||
)
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/thinking
|
||||
if (req.method === "POST" && url.pathname === "/api/thinking") {
|
||||
return readBody(req)
|
||||
.then(({ level }) =>
|
||||
sendCmd({ type: "set_thinking_level", level }).then((r: any) =>
|
||||
r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500),
|
||||
),
|
||||
)
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/avatars
|
||||
if (req.method === "GET" && url.pathname === "/api/avatars") {
|
||||
return json(res, readWebuiAvatarSettings());
|
||||
}
|
||||
|
||||
// GET /api/webui/config
|
||||
if (req.method === "GET" && url.pathname === "/api/webui/config") {
|
||||
const key = url.searchParams.get("key");
|
||||
if (key) {
|
||||
const normalized = normalizeConfigKey(key);
|
||||
return json(res, { key: normalized, value: getWebuiConfig(normalized) });
|
||||
}
|
||||
return json(res, { config: getAllWebuiConfig(), dbPath: webuiDb.dbPath });
|
||||
}
|
||||
|
||||
// POST /api/webui/config
|
||||
if (req.method === "POST" && url.pathname === "/api/webui/config") {
|
||||
return readBody(req)
|
||||
.then(({ key, value, entries }) => {
|
||||
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(entries as Record<string, unknown>)) {
|
||||
normalized[normalizeConfigKey(rawKey)] = normalizeConfigValue(rawValue);
|
||||
}
|
||||
setWebuiConfigMany(normalized);
|
||||
return json(res, { ok: true, config: getAllWebuiConfig() });
|
||||
}
|
||||
if (typeof key !== "string") throw new Error("缺少配置键 key");
|
||||
const normalizedKey = normalizeConfigKey(key);
|
||||
const normalizedValue = normalizeConfigValue(value);
|
||||
setWebuiConfig(normalizedKey, normalizedValue);
|
||||
return json(res, { ok: true, key: normalizedKey, value: normalizedValue });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/webui/config/delete
|
||||
if (req.method === "POST" && url.pathname === "/api/webui/config/delete") {
|
||||
return readBody(req)
|
||||
.then(({ key }) => {
|
||||
const normalized = normalizeConfigKey(key);
|
||||
const deleted = deleteWebuiConfig(normalized);
|
||||
json(res, { ok: true, deleted, key: normalized });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/settings
|
||||
if (req.method === "GET" && url.pathname === "/api/settings") {
|
||||
return Promise.all([listLoadedSkills(), listLoadedExtensions()])
|
||||
.then(([skills, extensions]) => json(res, {
|
||||
...readWebuiAvatarSettings(),
|
||||
webuiDbPath: webuiDb.dbPath,
|
||||
systemPrompt: readSystemPrompt(),
|
||||
systemPromptPath: SYSTEM_PROMPT_FILE,
|
||||
extensionsPath: AGENT_EXTENSIONS_DIR,
|
||||
mcpCachePath: MCP_CACHE_FILE,
|
||||
mcpConfigPath: MCP_CONFIG_FILE,
|
||||
skills,
|
||||
extensions,
|
||||
mcpTools: listMcpTools(),
|
||||
}))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/settings/reload
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/reload") {
|
||||
return sendCmd({ type: "reload" })
|
||||
.then((r: any) => (r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/settings/system-prompt
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/system-prompt") {
|
||||
return readBody(req)
|
||||
.then(async ({ systemPrompt }) => {
|
||||
if (typeof systemPrompt !== "string") {
|
||||
throw new Error("systemPrompt 必须是字符串");
|
||||
}
|
||||
writeSystemPrompt(systemPrompt);
|
||||
const reload = await sendCmd({ type: "reload" });
|
||||
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
|
||||
json(res, { ok: true, systemPromptPath: SYSTEM_PROMPT_FILE });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/settings/avatars
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/avatars") {
|
||||
return readBody(req)
|
||||
.then(({ userAvatarUrl, agentAvatarUrl }) => {
|
||||
const settings = {
|
||||
userAvatarUrl: normalizeAvatarUrl(userAvatarUrl),
|
||||
agentAvatarUrl: normalizeAvatarUrl(agentAvatarUrl),
|
||||
};
|
||||
writeWebuiAvatarSettings(settings);
|
||||
json(res, { ok: true, ...settings });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/sessions
|
||||
if (req.method === "GET" && url.pathname === "/api/sessions") {
|
||||
return json(res, buildSessionListResponse());
|
||||
}
|
||||
|
||||
// POST /api/sessions/history
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/history") {
|
||||
return readBody(req)
|
||||
.then(({ path: sp }) => {
|
||||
const messages = readSessionMessages(sp as string);
|
||||
const summary = readSessionSummary(sp as string);
|
||||
json(res, { messages, session: summary });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/delete
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/delete") {
|
||||
return readBody(req)
|
||||
.then(({ path: sp }) => {
|
||||
const sessionPath = resolveSessionFile(sp as string);
|
||||
if (!existsSync(sessionPath)) throw new Error("会话不存在");
|
||||
unlinkSync(sessionPath);
|
||||
removePinnedSessionPath(sessionPath);
|
||||
json(res, { ok: true });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/pin
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/pin") {
|
||||
return readBody(req)
|
||||
.then(({ path: sp, pinned }) => {
|
||||
const sessionPath = resolveSessionFile(sp as string);
|
||||
if (!existsSync(sessionPath)) throw new Error("会话不存在");
|
||||
const pinnedPaths = setSessionPinned(sessionPath, Boolean(pinned));
|
||||
json(res, { ok: true, path: sessionPath, pinned: Boolean(pinned), pinnedPaths });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/load
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/load") {
|
||||
return readBody(req)
|
||||
.then(async ({ path: sp }) => {
|
||||
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
|
||||
if (!sw.success) throw new Error(sw.error);
|
||||
const mr = await sendCmd({ type: "get_messages" });
|
||||
if (!mr.success) throw new Error(mr.error);
|
||||
const summary = readSessionSummary(sp as string);
|
||||
json(res, { messages: mr.data.messages, session: summary });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/activate
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/activate") {
|
||||
return readBody(req)
|
||||
.then(async ({ path: sp }) => {
|
||||
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
|
||||
if (!sw.success) throw new Error(sw.error);
|
||||
json(res, { ok: true });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/name
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/name") {
|
||||
return readBody(req)
|
||||
.then(async ({ path: sp, name }) => {
|
||||
if (typeof sp !== "string" || !sp.trim()) throw new Error("无效会话路径");
|
||||
const savedName = appendSessionName(sp, typeof name === "string" ? name : "");
|
||||
const sessionPath = resolveSessionFile(sp);
|
||||
const state = await sendCmd({ type: "get_state" });
|
||||
if (state.success && state.data?.sessionFile === sessionPath) {
|
||||
const rename = await sendCmd({ type: "set_session_name", name: savedName });
|
||||
if (!rename.success) throw new Error(rename.error || "同步当前会话名称失败");
|
||||
}
|
||||
json(res, { ok: true, name: savedName });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/session-state
|
||||
if (req.method === "GET" && url.pathname === "/api/session-state") {
|
||||
return sendCmd({ type: "get_state" })
|
||||
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
console.error(`[webui] 端口 ${PORT} 已被占用`);
|
||||
} else {
|
||||
console.error(`[webui] HTTP 服务启动失败: ${err.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`[webui] HTTP 服务已启动: http://localhost:${PORT}`);
|
||||
console.log(`[webui] 局域网访问: http://smallmengya:${PORT}`);
|
||||
});
|
||||
import "./backend/main.ts";
|
||||
|
||||
168
.pi/agent/extensions/webui/systemd/service.ts
Normal file
168
.pi/agent/extensions/webui/systemd/service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
export const WEBUI_SERVICE_NAME = "sproutclaw-webui.service";
|
||||
const SYSTEM_UNIT_PATH = `/etc/systemd/system/${WEBUI_SERVICE_NAME}`;
|
||||
export const DEFAULT_WEBUI_PORT = 19133;
|
||||
|
||||
export interface SystemdServiceConfig {
|
||||
extensionDir: string;
|
||||
repoRoot: string;
|
||||
agentDir: string;
|
||||
nodeBin: string;
|
||||
tsxCli: string;
|
||||
}
|
||||
|
||||
let systemdReady = false;
|
||||
let systemdDisabledReason: string | null = null;
|
||||
|
||||
function runSystemctl(args: string[]): { ok: boolean; output: string } {
|
||||
const result = spawnSync("systemctl", args, { encoding: "utf8" });
|
||||
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
||||
return { ok: result.status === 0, output };
|
||||
}
|
||||
|
||||
function isSystemctlAvailable(): boolean {
|
||||
return spawnSync("systemctl", ["--version"], { encoding: "utf8" }).status === 0;
|
||||
}
|
||||
|
||||
function isServiceRegistered(): boolean {
|
||||
const result = runSystemctl(["list-unit-files", WEBUI_SERVICE_NAME, "--no-legend"]);
|
||||
return result.ok && result.output.length > 0 && !result.output.includes("0 unit files");
|
||||
}
|
||||
|
||||
function envFilePath(extensionDir: string): string {
|
||||
return join(extensionDir, "data", "webui-service.env");
|
||||
}
|
||||
|
||||
function runScriptPath(extensionDir: string): string {
|
||||
return join(extensionDir, "scripts", "webui-run.sh");
|
||||
}
|
||||
|
||||
function writeIfChanged(path: string, content: string): boolean {
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
if (readFileSync(path, "utf8") === content) return false;
|
||||
} catch {
|
||||
// Rewrite unreadable files below.
|
||||
}
|
||||
}
|
||||
writeFileSync(path, content, "utf8");
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildEnvContent(config: SystemdServiceConfig, port: number): string {
|
||||
return [
|
||||
`REPO_ROOT=${config.repoRoot}`,
|
||||
`EXTENSION_DIR=${config.extensionDir}`,
|
||||
`NODE_BIN=${config.nodeBin}`,
|
||||
`TSX_CLI=${config.tsxCli}`,
|
||||
`PI_CODING_AGENT_DIR=${config.agentDir}`,
|
||||
`PORT=${port}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildRunScript(): string {
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
: "\${REPO_ROOT:?REPO_ROOT required}"
|
||||
: "\${EXTENSION_DIR:?EXTENSION_DIR required}"
|
||||
: "\${NODE_BIN:?NODE_BIN required}"
|
||||
: "\${TSX_CLI:?TSX_CLI required}"
|
||||
: "\${PI_CODING_AGENT_DIR:?PI_CODING_AGENT_DIR required}"
|
||||
PORT="\${PORT:-19133}"
|
||||
cd "\$REPO_ROOT"
|
||||
exec "\$NODE_BIN" "\$TSX_CLI" "\$EXTENSION_DIR/backend/main.ts" --port "\$PORT"
|
||||
`;
|
||||
}
|
||||
|
||||
function buildUnitFile(config: SystemdServiceConfig): string {
|
||||
const envFile = envFilePath(config.extensionDir);
|
||||
const runScript = runScriptPath(config.extensionDir);
|
||||
return `[Unit]
|
||||
Description=SproutClaw WebUI HTTP Server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=-${envFile}
|
||||
ExecStart=${runScript}
|
||||
WorkingDirectory=${config.repoRoot}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
KillMode=mixed
|
||||
TimeoutStopSec=15
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`;
|
||||
}
|
||||
|
||||
export function isSystemdManaged(): boolean {
|
||||
return systemdReady;
|
||||
}
|
||||
|
||||
export function getSystemdDisabledReason(): string | null {
|
||||
return systemdDisabledReason;
|
||||
}
|
||||
|
||||
export function syncSystemdPort(config: SystemdServiceConfig, port: number): void {
|
||||
mkdirSync(join(config.extensionDir, "data"), { recursive: true });
|
||||
writeIfChanged(envFilePath(config.extensionDir), `${buildEnvContent(config, port)}\n`);
|
||||
}
|
||||
|
||||
export function ensureSystemdService(
|
||||
config: SystemdServiceConfig,
|
||||
port = DEFAULT_WEBUI_PORT,
|
||||
): boolean {
|
||||
if (!isSystemctlAvailable()) {
|
||||
systemdDisabledReason = "systemctl 不可用";
|
||||
systemdReady = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!existsSync(config.nodeBin)) {
|
||||
throw new Error(`Node 未找到: ${config.nodeBin}`);
|
||||
}
|
||||
if (!existsSync(config.tsxCli)) {
|
||||
throw new Error(`tsx 未找到: ${config.tsxCli}`);
|
||||
}
|
||||
|
||||
mkdirSync(join(config.extensionDir, "data"), { recursive: true });
|
||||
mkdirSync(join(config.extensionDir, "scripts"), { recursive: true });
|
||||
|
||||
const envChanged = writeIfChanged(envFilePath(config.extensionDir), `${buildEnvContent(config, port)}\n`);
|
||||
const scriptChanged = writeIfChanged(runScriptPath(config.extensionDir), buildRunScript());
|
||||
chmodSync(runScriptPath(config.extensionDir), 0o755);
|
||||
const unitChanged = writeIfChanged(SYSTEM_UNIT_PATH, buildUnitFile(config));
|
||||
|
||||
if (unitChanged || scriptChanged || envChanged || !isServiceRegistered()) {
|
||||
runSystemctl(["daemon-reload"]);
|
||||
}
|
||||
|
||||
if (!isServiceRegistered()) {
|
||||
const enable = runSystemctl(["enable", WEBUI_SERVICE_NAME]);
|
||||
if (!enable.ok) {
|
||||
throw new Error(enable.output || "systemctl enable 失败");
|
||||
}
|
||||
}
|
||||
|
||||
systemdReady = true;
|
||||
systemdDisabledReason = null;
|
||||
return true;
|
||||
} catch (err) {
|
||||
systemdReady = false;
|
||||
systemdDisabledReason = err instanceof Error ? err.message : String(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function systemdControl(action: "start" | "stop" | "restart"): { ok: boolean; output: string } {
|
||||
if (!systemdReady) {
|
||||
return { ok: false, output: systemdDisabledReason ?? "systemd 未就绪" };
|
||||
}
|
||||
return runSystemctl([action, WEBUI_SERVICE_NAME]);
|
||||
}
|
||||
Reference in New Issue
Block a user