Files
sproutclaw/.pi/agent/extensions/webui/backend/slash/dispatch.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

247 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}