Files
sproutclaw/.pi/agent/extensions/webui/backend/services/extensions-display.ts
root cf5edd6394
Some checks failed
CI / build-check-test (push) Has been cancelled
feat(sproutclaw): modularize webui, extensions, and agent config layout
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>
2026-06-10 16:57:08 +08:00

197 lines
6.5 KiB
TypeScript

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);
}