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:
246
.pi/agent/extensions/webui/backend/slash/dispatch.ts
Normal file
246
.pi/agent/extensions/webui/backend/slash/dispatch.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user