agent-zero/plugins/_commands/webui/commands-slash-store.js
Alessandro 84c13dab01 Add built-in goal management plugin
Adds the always-enabled _goal plugin with per-chat goal storage, WebUI goal strip, /goal slash command, and agent-facing goal tools.

Includes command-picker integration, focused plugin tests, and the generated 256x256 thumbnail asset.
2026-07-09 17:09:08 +02:00

526 lines
15 KiB
JavaScript

import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import { store as chatInputStore } from "/components/chat/input/input-store.js";
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
import {
toastFrontendError,
toastFrontendInfo,
toastFrontendSuccess,
} from "/components/notifications/notification-store.js";
import { store as commandsManagerStore } from "/plugins/_commands/webui/commands-store.js";
const COMMANDS_API_PATH = "/plugins/_commands/commands";
function sanitizeCommandName(rawName) {
return (rawName || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
}
function parseSlashInput(message) {
const text = String(message || "");
const match = text.match(/^\s*\/([^\s]*)(?:\s+([\s\S]*))?$/);
if (!match) {
return {
active: false,
query: "",
rawArguments: "",
rawMessage: text,
};
}
return {
active: true,
query: (match[1] || "").trim().toLowerCase(),
rawArguments: match[2] || "",
rawMessage: text,
};
}
function notifyError(message) {
void toastFrontendError(message, "Commands");
}
function notifySuccess(message) {
void toastFrontendSuccess(message, "Commands");
}
const HTML_ESCAPE = {
"&": "&",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
function escapeHtml(value) {
return String(value || "").replace(/[&<>"']/g, (char) => HTML_ESCAPE[char]);
}
function notifyInfo(title, message) {
const formatted = escapeHtml(message).replace(/\n/g, "<br>");
void toastFrontendInfo(formatted, title || "Commands", 3, "", undefined, true);
}
const model = {
loading: false,
applying: false,
commands: [],
contextScope: { project_name: "" },
lastContextId: "",
active: false,
dismissed: false,
query: "",
rawArguments: "",
rawMessage: "",
selectedIndex: 0,
boundInput: null,
keydownHandler: null,
inputHandler: null,
focusHandler: null,
commandsUpdatedHandler: null,
get menuVisible() {
return this.active && !this.dismissed;
},
get filteredCommands() {
const needle = (this.query || "").trim().toLowerCase();
const commands = Array.isArray(this.commands) ? this.commands : [];
if (!needle) return commands;
return commands.filter((command) => {
const haystack = `${command?.name || ""} ${command?.description || ""}`.toLowerCase();
return haystack.includes(needle);
});
},
get selectedCommand() {
const commands = this.filteredCommands;
if (!commands.length) return null;
return commands[this.selectedIndex] || commands[0] || null;
},
get emptyStateLabel() {
const name = sanitizeCommandName(this.query || "");
return name ? `Create /${name}` : "Create slash command";
},
onMount() {
this.ensureBindings();
this.keydownHandler = (event) => this.handleKeydown(event);
this.commandsUpdatedHandler = () => {
this.commands = [];
if (this.menuVisible) {
void this.loadCommands(true);
}
};
document.addEventListener("keydown", this.keydownHandler, true);
window.addEventListener("commands:updated", this.commandsUpdatedHandler);
this.handleInput();
},
cleanup() {
this.removeBindings();
if (this.keydownHandler) {
document.removeEventListener("keydown", this.keydownHandler, true);
}
if (this.commandsUpdatedHandler) {
window.removeEventListener("commands:updated", this.commandsUpdatedHandler);
}
this.keydownHandler = null;
this.commandsUpdatedHandler = null;
this.dismissed = false;
this.active = false;
this.query = "";
this.rawArguments = "";
this.rawMessage = "";
this.selectedIndex = 0;
this.applying = false;
},
ensureBindings() {
const input = this.getInputElement();
if (!input || input === this.boundInput) return;
this.removeBindings();
this.inputHandler = (event) => this.handleInput(event);
this.focusHandler = () => this.handleInput();
input.addEventListener("input", this.inputHandler);
input.addEventListener("focus", this.focusHandler);
this.boundInput = input;
},
removeBindings() {
if (this.boundInput && this.inputHandler) {
this.boundInput.removeEventListener("input", this.inputHandler);
}
if (this.boundInput && this.focusHandler) {
this.boundInput.removeEventListener("focus", this.focusHandler);
}
this.boundInput = null;
this.inputHandler = null;
this.focusHandler = null;
},
getInputElement() {
return document.getElementById("chat-input");
},
getInputMessage(event = null) {
const target = event?.target || null;
const targetEditor = target?.closest?.("#chat-input");
if (targetEditor?.isContentEditable || target?.isContentEditable) {
return (
chatInputStore?._editorToMarkdown?.() ||
targetEditor?.textContent ||
target?.textContent ||
""
);
}
if (typeof target?.value === "string") return target.value;
const input = this.getInputElement();
if (input?.isContentEditable) {
return chatInputStore?._editorToMarkdown?.() ?? input.textContent ?? "";
}
if (typeof input?.value === "string") return input.value;
return chatInputStore?.message ?? "";
},
getContextId() {
return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
},
async loadCommands(force = false) {
const contextId = this.getContextId();
if (!force && this.commands.length && contextId === this.lastContextId) {
this.ensureSelection();
return;
}
this.loading = true;
try {
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "list_effective",
context_id: contextId,
});
this.commands = Array.isArray(response?.commands) ? response.commands : [];
this.contextScope = response?.scope || {
project_name: "",
};
this.lastContextId = contextId;
this.ensureSelection();
} catch (error) {
console.error("Failed to load effective commands:", error);
this.commands = [];
this.contextScope = { project_name: "" };
} finally {
this.loading = false;
}
},
handleInput(event = null) {
this.ensureBindings();
this.dismissed = false;
const message = this.getInputMessage(event);
const parsed = parseSlashInput(message);
this.active = parsed.active;
this.query = parsed.query;
this.rawArguments = parsed.rawArguments;
this.rawMessage = parsed.rawMessage;
if (!this.active) {
this.selectedIndex = 0;
return;
}
this.ensureSelection();
void this.loadCommands();
},
handleKeydown(event) {
const input = this.getInputElement();
if (!this.menuVisible || !input || document.activeElement !== input) return;
if (event.isComposing || event.keyCode === 229) return;
if (event.key === "ArrowDown") {
event.preventDefault();
event.stopPropagation();
this.moveSelection(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
this.moveSelection(-1);
return;
}
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.dismissed = true;
return;
}
if (event.key === "Enter" && this.selectedCommand) {
event.preventDefault();
event.stopPropagation();
void this.applySelection(this.selectedCommand);
}
},
ensureSelection() {
const commands = this.filteredCommands;
if (!commands.length) {
this.selectedIndex = 0;
return;
}
if (this.selectedIndex >= commands.length) {
this.selectedIndex = 0;
}
},
moveSelection(delta) {
const commands = this.filteredCommands;
if (!commands.length) return;
const nextIndex =
(this.selectedIndex + delta + commands.length) % commands.length;
this.selectedIndex = nextIndex;
this.scrollSelectedIntoView();
},
scrollSelectedIntoView() {
requestAnimationFrame(() => {
document
.querySelector(".commands-slash-results .commands-slash-item.active")
?.scrollIntoView({ block: "nearest" });
});
},
async applySelection(command) {
if (!command || this.applying) return;
const input = this.getInputElement();
if (!input) return;
this.applying = true;
try {
const contextId = this.getContextId();
const fallbackSlash = this.rawMessage?.trim()
? this.rawMessage
: this.rawArguments
? `/${command.name} ${this.rawArguments}`
: `/${command.name}`;
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "resolve",
path: command.path,
slash_text: fallbackSlash,
project_name: this.contextScope?.project_name || "",
context_id: contextId,
});
const applied = await this.applyResolution(response?.resolution, input);
if (!applied?.hadToast && !applied?.hadError) {
notifySuccess(`Applied /${command.name}`);
}
} catch (error) {
console.error("Failed to apply slash command:", error);
notifyError(error?.message || "Failed to apply slash command.");
} finally {
this.applying = false;
}
},
async applyResolution(resolution, input) {
const result = resolution?.result || {};
const hasText = typeof result.text === "string";
let nextText = hasText ? result.text : this.getInputMessage();
const effects = Array.isArray(result.effects) ? result.effects : [];
let hadToast = false;
let hadError = false;
let shouldSend = false;
for (const effect of effects) {
if (!effect || typeof effect !== "object") continue;
const type = String(effect.type || "").trim().toLowerCase();
if (type === "replace_input") {
nextText = String(effect.text || "");
continue;
}
if (type === "append_input") {
const chunk = String(effect.text || "");
nextText = nextText ? `${nextText}\n${chunk}` : chunk;
continue;
}
if (type === "send_message") {
nextText = String(effect.text || nextText || "");
shouldSend = true;
continue;
}
if (type === "toast") {
hadToast = true;
const level = String(effect.level || "info").toLowerCase();
const message = String(effect.message || "");
if (!message) continue;
if (level === "error") {
hadError = true;
notifyError(message);
} else {
notifySuccess(message);
}
continue;
}
if (type === "new_chat") {
await chatsStore?.newChat?.();
continue;
}
if (type === "select_chat") {
const contextId = String(effect.context_id || "").trim();
if (contextId) await chatsStore?.selectChat?.(contextId);
continue;
}
if (type === "reset_chat") {
await chatsStore?.resetChat?.(String(effect.context_id || "") || null);
continue;
}
if (type === "pause_agent") {
await chatInputStore?.pauseAgent?.(Boolean(effect.paused));
continue;
}
if (type === "nudge_agent") {
await chatInputStore?.nudge?.();
continue;
}
if (type === "open_modal") {
const path = String(effect.path || "").trim();
if (path) await window.openModal?.(path);
continue;
}
if (type === "show_markdown") {
hadToast = true;
notifyInfo(
String(effect.title || "Slash Command"),
String(effect.content || ""),
);
continue;
}
if (type === "goal_changed") {
window.dispatchEvent(new CustomEvent("goal:changed", { detail: effect }));
continue;
}
if (type === "open_plugin_config") {
const pluginName = String(effect.plugin || "").trim();
if (pluginName) {
const { store } = await import("/components/plugins/plugin-settings-store.js");
await store.openConfig(
pluginName,
String(effect.project_name || ""),
String(effect.agent_profile || ""),
);
}
continue;
}
if (type === "compact_chat") {
const { store } = await import("/plugins/_chat_compaction/webui/compact-store.js");
await store.fetchStats();
continue;
}
if (type === "attach_files") {
await this.openAttachmentPicker(effect);
continue;
}
if (type === "copy_transcript") {
await this.copyTranscript();
hadToast = true;
continue;
}
if (type === "clear_transcript") {
const history = document.getElementById("chat-history");
if (history) history.innerHTML = "";
continue;
}
}
if (typeof input.value === "string") input.value = nextText;
chatInputStore.message = nextText;
input.dispatchEvent(new Event("input", { bubbles: true }));
chatInputStore.adjustTextareaHeight();
input.focus();
if (typeof input.setSelectionRange === "function") {
input.setSelectionRange(nextText.length, nextText.length);
} else {
chatInputStore?._setEditorCaret?.(nextText.length);
}
this.active = false;
this.dismissed = false;
this.query = "";
this.rawArguments = "";
this.rawMessage = nextText;
this.selectedIndex = 0;
if (shouldSend && nextText.trim()) {
await chatInputStore?.sendMessage?.();
}
return { hadToast, hadError };
},
openAttachmentPicker(effect = {}) {
return new Promise((resolve) => {
const picker = document.createElement("input");
let settled = false;
const done = () => {
if (settled) return;
settled = true;
picker.remove();
resolve();
};
picker.type = "file";
picker.multiple = true;
picker.accept = String(effect.accept || "*");
picker.style.display = "none";
picker.addEventListener("change", () => {
attachmentsStore?.handleFiles?.(picker.files || []);
done();
}, { once: true });
window.addEventListener("focus", () => setTimeout(done, 500), { once: true });
document.body.appendChild(picker);
picker.click();
});
},
async copyTranscript() {
const text = document.getElementById("chat-history")?.innerText?.trim() || "";
if (!text) {
notifyError("No visible transcript to copy.");
return;
}
await navigator.clipboard.writeText(text);
notifySuccess("Transcript copied.");
},
openCreateCommand() {
commandsManagerStore.openManager({
projectName: this.contextScope?.project_name || "",
prefillName: sanitizeCommandName(this.query || ""),
openEditor: true,
});
this.dismissed = true;
},
};
export const store = createStore("commandsSlash", model);