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,32 @@
export function normalizeAvatarUrl(value: unknown): string {
if (typeof value !== "string") return "";
const url = value.trim();
if (!url) return "";
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`无效头像链接: ${url}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("头像链接仅支持 http 或 https");
}
return parsed.toString();
}
export function normalizeConfigKey(key: unknown): string {
if (typeof key !== "string") throw new Error("配置键必须是字符串");
const trimmed = key.trim();
if (!trimmed) throw new Error("配置键不能为空");
if (trimmed.length > 128) throw new Error("配置键过长");
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(trimmed)) {
throw new Error("配置键格式无效");
}
return trimmed;
}
export function normalizeConfigValue(value: unknown): string {
if (typeof value !== "string") throw new Error("配置值必须是字符串");
if (value.length > 65536) throw new Error("配置值过长");
return value;
}

View File

@@ -0,0 +1,24 @@
const ALLOWED_CHAT_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const MAX_CHAT_IMAGES = 8;
const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
export function normalizeChatImages(
images: unknown,
): Array<{ type: "image"; data: string; mimeType: string }> | undefined {
if (!Array.isArray(images) || images.length === 0) return undefined;
const out: Array<{ type: "image"; data: string; mimeType: string }> = [];
for (const raw of images.slice(0, MAX_CHAT_IMAGES)) {
if (!raw || typeof raw !== "object") continue;
const mimeType = String((raw as { mimeType?: string }).mimeType || "");
const data = String((raw as { data?: string }).data || "");
if ((raw as { type?: string }).type !== "image" || !ALLOWED_CHAT_IMAGE_MIME.has(mimeType) || !data) {
throw new Error(`不支持的图片类型: ${mimeType || "unknown"}`);
}
const size = Buffer.from(data, "base64").length;
if (size > MAX_CHAT_IMAGE_BYTES) {
throw new Error(`图片过大(最大 ${Math.round(MAX_CHAT_IMAGE_BYTES / 1024 / 1024)}MB`);
}
out.push({ type: "image", mimeType, data });
}
return out.length ? out : undefined;
}

View File

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

View File

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

View File

@@ -0,0 +1,250 @@
import { randomUUID } from "node:crypto";
import {
appendFileSync,
existsSync,
readFileSync,
readdirSync,
statSync,
unlinkSync,
} from "node:fs";
import { join, resolve } from "node:path";
import type { WebUiPaths } from "../config/paths.ts";
import { prunePinnedSessionPaths, removePinnedSessionPath, setSessionPinned } from "../db/index.ts";
function isMachineSessionLabel(text: string, sessionHeaderId: string): boolean {
const t = (text ?? "").trim();
if (!t) return true;
if (sessionHeaderId && t === sessionHeaderId) return true;
if (/^[0-9a-f]{8,}$/i.test(t)) return true;
if (/^[0-9]{10,}$/.test(t)) return true;
return false;
}
function titleFromFirstUserMessage(text: string, maxChars = 56): string {
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
if (!cleaned) return "";
const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/);
let candidate = sentenceMatch && sentenceMatch[1] ? sentenceMatch[1].trim() : cleaned;
if (candidate.length > maxChars) {
candidate = `${candidate.slice(0, maxChars).trimEnd()}`;
}
return candidate;
}
function extractPreview(msg: any): string {
const c = msg.content;
if (!c) return "";
if (typeof c === "string") return c.slice(0, 200);
if (Array.isArray(c)) {
const text = c
.filter((x: any) => x.type === "text")
.map((x: any) => x.text)
.join("")
.slice(0, 200);
if (text) return text;
const imageCount = c.filter((x: any) => x.type === "image").length;
if (imageCount > 0) return `[${imageCount} 张图片]`;
}
return "";
}
export function listSessionFiles(paths: WebUiPaths): string[] {
if (!existsSync(paths.sessionsDir)) return [];
const files: string[] = [];
const visit = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
visit(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
files.push(fullPath);
}
}
};
visit(paths.sessionsDir);
return files.sort().reverse();
}
export function readSessionSummary(paths: WebUiPaths, filePath: string): Record<string, unknown> | null {
try {
const content = readFileSync(filePath, "utf8");
const lines = content.trim().split("\n");
if (!lines.length) return null;
const header = JSON.parse(lines[0]);
if (header.type !== "session") return null;
let nameFromInfo = "";
let messageCount = 0;
let firstMessage = "";
const stats = statSync(filePath);
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.type === "session_info" && entry.name) {
const n = String(entry.name).trim();
if (n && !isMachineSessionLabel(n, header.id)) {
nameFromInfo = n;
}
}
if (entry.type === "message") {
messageCount++;
if (!firstMessage && entry.message?.role === "user") {
firstMessage = extractPreview(entry.message);
}
}
} catch {
/* skip */
}
}
const fromFirstUser = titleFromFirstUserMessage(firstMessage);
let name = nameFromInfo;
if (!name || isMachineSessionLabel(name, header.id)) {
name = fromFirstUser || "";
}
return {
path: filePath,
id: header.id,
name,
created: header.timestamp,
modified: stats.mtime.toISOString(),
messageCount,
firstMessage: firstMessage || "(空)",
};
} catch {
return null;
}
}
export function readSessionMessages(filePath: string): unknown[] {
try {
const content = readFileSync(filePath, "utf8");
return content
.trim()
.split("\n")
.map((line) => {
try {
const entry = JSON.parse(line);
return entry.type === "message" ? entry.message : null;
} catch {
return null;
}
})
.filter(Boolean);
} catch {
return [];
}
}
export function resolveSessionFile(paths: WebUiPaths, filePath: string): string {
const resolved = resolve(filePath);
const sessionsRoot = resolve(paths.sessionsDir);
if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) {
throw new Error("无效会话路径");
}
return resolved;
}
export function appendSessionName(paths: WebUiPaths, filePath: string, name: string): string {
const sessionPath = resolveSessionFile(paths, filePath);
if (!existsSync(sessionPath)) throw new Error("会话不存在");
const trimmed = name.trim();
if (!trimmed) throw new Error("会话名称不能为空");
const lines = readFileSync(sessionPath, "utf8").trim().split("\n");
const ids = new Set<string>();
let leafId: string | null = null;
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (typeof entry.id === "string") {
ids.add(entry.id);
leafId = entry.id;
}
} catch {
/* skip */
}
}
if (!leafId) throw new Error("无效会话文件");
let id = randomUUID().slice(0, 8);
for (let i = 0; i < 100 && ids.has(id); i++) {
id = randomUUID().slice(0, 8);
}
const entry = {
type: "session_info",
id,
parentId: leafId,
timestamp: new Date().toISOString(),
name: trimmed,
};
appendFileSync(sessionPath, `\n${JSON.stringify(entry)}`, "utf8");
return trimmed;
}
function sortSessionSummaries(
summaries: Array<Record<string, unknown>>,
pinnedPaths: string[],
): Array<Record<string, unknown>> {
const pinnedOrder = new Map(pinnedPaths.map((path, index) => [path, index]));
return [...summaries].sort((a, b) => {
const aPath = String(a.path);
const bPath = String(b.path);
const aPin = pinnedOrder.get(aPath);
const bPin = pinnedOrder.get(bPath);
if (aPin !== undefined && bPin !== undefined) return aPin - bPin;
if (aPin !== undefined) return -1;
if (bPin !== undefined) return 1;
return (
new Date(String(b.modified || b.created || 0)).getTime() -
new Date(String(a.modified || a.created || 0)).getTime()
);
});
}
function annotatePinnedSessions(
summaries: Array<Record<string, unknown>>,
pinnedPaths: string[],
): Array<Record<string, unknown>> {
const pinnedSet = new Set(pinnedPaths);
return summaries.map((summary) => ({
...summary,
pinned: pinnedSet.has(String(summary.path)),
}));
}
export function buildSessionListResponse(paths: WebUiPaths) {
const summaries = listSessionFiles(paths)
.map((filePath) => readSessionSummary(paths, filePath))
.filter(Boolean) as Array<Record<string, unknown>>;
const pinnedPaths = prunePinnedSessionPaths(summaries.map((summary) => String(summary.path)));
const sorted = sortSessionSummaries(summaries, pinnedPaths);
return {
sessions: annotatePinnedSessions(sorted, pinnedPaths),
pinnedPaths,
};
}
export function deleteSessionFile(paths: WebUiPaths, sessionPathInput: string): void {
const sessionPath = resolveSessionFile(paths, sessionPathInput);
if (!existsSync(sessionPath)) throw new Error("会话不存在");
unlinkSync(sessionPath);
removePinnedSessionPath(sessionPath);
}
export function pinSession(paths: WebUiPaths, sessionPathInput: string, pinned: boolean): {
path: string;
pinned: boolean;
pinnedPaths: string[];
} {
const sessionPath = resolveSessionFile(paths, sessionPathInput);
if (!existsSync(sessionPath)) throw new Error("会话不存在");
const pinnedPaths = setSessionPinned(sessionPath, pinned);
return { path: sessionPath, pinned, pinnedPaths };
}

View File

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

View File

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