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>
247 lines
8.8 KiB
TypeScript
247 lines
8.8 KiB
TypeScript
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;
|
||
}
|
||
}
|