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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";
export function handleModelsRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
): boolean {
const { sendCmd } = ctx.rpc;
if (req.method === "GET" && pathname === "/api/models") {
void sendCmd({ type: "get_available_models" })
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/model") {
void readBody(req)
.then(({ provider, modelId }) =>
sendCmd({ type: "set_model", provider, modelId }).then((r: any) =>
r.success ? json(res, { model: r.data }) : json(res, { error: r.error }, 500),
),
)
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/thinking") {
void readBody(req)
.then(({ level }) =>
sendCmd({ type: "set_thinking_level", level }).then((r: any) =>
r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500),
),
)
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
return false;
}

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import {
deleteWebuiConfig,
getAllWebuiConfig,
getWebuiConfig,
readWebuiAvatarSettings,
setWebuiConfig,
setWebuiConfigMany,
} from "../db/index.ts";
import { normalizeConfigKey, normalizeConfigValue } from "../services/avatars.ts";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";
export function handleWebuiConfigRoute(
req: IncomingMessage,
res: ServerResponse,
ctx: WebUiContext,
pathname: string,
url: URL,
): boolean {
if (req.method === "GET" && pathname === "/api/avatars") {
json(res, readWebuiAvatarSettings());
return true;
}
if (req.method === "GET" && pathname === "/api/webui/config") {
const key = url.searchParams.get("key");
if (key) {
const normalized = normalizeConfigKey(key);
json(res, { key: normalized, value: getWebuiConfig(normalized) });
return true;
}
json(res, { config: getAllWebuiConfig(), dbPath: ctx.db.dbPath });
return true;
}
if (req.method === "POST" && pathname === "/api/webui/config") {
void readBody(req)
.then(({ key, value, entries }) => {
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
const normalized: Record<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(entries as Record<string, unknown>)) {
normalized[normalizeConfigKey(rawKey)] = normalizeConfigValue(rawValue);
}
setWebuiConfigMany(normalized);
return json(res, { ok: true, config: getAllWebuiConfig() });
}
if (typeof key !== "string") throw new Error("缺少配置键 key");
const normalizedKey = normalizeConfigKey(key);
const normalizedValue = normalizeConfigValue(value);
setWebuiConfig(normalizedKey, normalizedValue);
return json(res, { ok: true, key: normalizedKey, value: normalizedValue });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
if (req.method === "POST" && pathname === "/api/webui/config/delete") {
void readBody(req)
.then(({ key }) => {
const normalized = normalizeConfigKey(key);
const deleted = deleteWebuiConfig(normalized);
json(res, { ok: true, deleted, key: normalized });
})
.catch((err) => json(res, { error: err.message }, 500));
return true;
}
return false;
}