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:
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");
|
||||
}
|
||||
Reference in New Issue
Block a user