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