main
js 438 lines 12.4 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import {
4 toastFrontendError,
5 toastFrontendSuccess,
6 } from "/components/notifications/notification-store.js";
7 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
8 import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
9
10 const COMMANDS_API_PATH = "/plugins/_commands/commands";
11 const MAIN_MODAL_PATH = "/plugins/_commands/webui/main.html";
12 const EDITOR_MODAL_PATH = "/plugins/_commands/webui/editor.html";
13
14 function createEmptyEditor() {
15 return {
16 mode: "create",
17 existingPath: "",
18 path: "",
19 name: "",
20 description: "",
21 argumentHint: "",
22 commandType: "text",
23 includeHistory: false,
24 body: "",
25 extraFrontmatter: {},
26 };
27 }
28
29 function safeStringify(value) {
30 try {
31 return JSON.stringify(value ?? {});
32 } catch {
33 return "";
34 }
35 }
36
37 function sanitizeCommandName(rawName) {
38 return (rawName || "")
39 .trim()
40 .toLowerCase()
41 .replace(/\s+/g, "-")
42 .replace(/[^a-z0-9_-]+/g, "-")
43 .replace(/-{2,}/g, "-")
44 .replace(/^[-_]+|[-_]+$/g, "");
45 }
46
47 function buildDefaultBody(commandType = "text") {
48 if (commandType === "script") {
49 return [
50 "def run(payload):",
51 " args = payload.get('arguments', {})",
52 " flags = args.get('flags', {})",
53 " positional = args.get('positional', [])",
54 " return {",
55 " 'text': f\"Script command received args: {positional} flags: {flags}\",",
56 " 'effects': [],",
57 " }",
58 "",
59 ].join("\n");
60 }
61 return "Describe the work to perform here.\n\n{raw}";
62 }
63
64 function notifyError(message) {
65 void toastFrontendError(message, "Commands");
66 }
67
68 function notifySuccess(message) {
69 void toastFrontendSuccess(message, "Commands");
70 }
71
72 function emitCommandsUpdated() {
73 window.dispatchEvent(new CustomEvent("commands:updated"));
74 }
75
76 const model = {
77 loading: false,
78 saving: false,
79 projects: [],
80 projectName: "",
81 scope: null,
82 contextScope: { project_name: "" },
83 commands: [],
84 builtinCommands: [],
85 pendingScope: null,
86 pendingCreate: null,
87 editor: createEmptyEditor(),
88 editorSnapshot: "",
89
90 get selectedScopeLabel() {
91 return this.scope?.scope_label || "Global";
92 },
93
94 get hasCommands() {
95 return (this.commands || []).length > 0;
96 },
97
98 get editorTitle() {
99 return this.editor.mode === "edit" ? "Edit Slash Command" : "Create Slash Command";
100 },
101
102 get editorDirty() {
103 return this._serializeEditor() !== this.editorSnapshot;
104 },
105
106 get editorBodyLabel() {
107 return this.editor.commandType === "script" ? "Python hook" : "Text template";
108 },
109
110 openManager(options = {}) {
111 const hasExplicitScope = Object.prototype.hasOwnProperty.call(options, "projectName");
112
113 this.pendingScope = hasExplicitScope
114 ? {
115 projectName: options.projectName || "",
116 }
117 : null;
118
119 this.pendingCreate =
120 options.openEditor || options.prefillName
121 ? {
122 name: options.prefillName || "",
123 }
124 : null;
125
126 return window.openModal?.(MAIN_MODAL_PATH);
127 },
128
129 async onOpen() {
130 await this.loadProjects();
131
132 try {
133 await this.resolveInitialScope();
134 await this.loadCommands();
135 } catch (error) {
136 console.error("Failed to initialize commands manager:", error);
137 this.scope = null;
138 this.commands = [];
139 this.builtinCommands = [];
140 notifyError(error?.message || "Failed to open the commands manager.");
141 }
142
143 if (this.pendingCreate) {
144 const pendingCreate = { ...this.pendingCreate };
145 this.pendingCreate = null;
146 await this.openCreateCommand({ name: pendingCreate.name });
147 }
148 },
149
150 cleanup() {
151 this.loading = false;
152 this.saving = false;
153 this.projects = [];
154 this.projectName = "";
155 this.scope = null;
156 this.contextScope = { project_name: "" };
157 this.commands = [];
158 this.builtinCommands = [];
159 this.pendingScope = null;
160 this.pendingCreate = null;
161 this.resetEditor();
162 },
163
164 async loadProjects() {
165 try {
166 const response = await callJsonApi("projects", { action: "list_options" });
167 this.projects = Array.isArray(response?.data) ? response.data : [];
168 } catch {
169 this.projects = [];
170 }
171 },
172
173 normalizeProject(projectName) {
174 if (!projectName) return "";
175 return (this.projects || []).some((project) => project?.key === projectName)
176 ? projectName
177 : "";
178 },
179
180 async resolveInitialScope() {
181 const contextId =
182 chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
183 const scopeInfo = await callJsonApi(COMMANDS_API_PATH, {
184 action: "scope_info",
185 context_id: contextId,
186 });
187
188 this.contextScope = scopeInfo?.context_scope || {
189 project_name: "",
190 };
191
192 const preferredScope = this.pendingScope || scopeInfo?.scope || {};
193 this.projectName = this.normalizeProject(preferredScope.project_name || "");
194 this.pendingScope = null;
195 },
196
197 async loadCommands() {
198 this.loading = true;
199
200 try {
201 const response = await callJsonApi(COMMANDS_API_PATH, {
202 action: "list_scope",
203 project_name: this.projectName || "",
204 });
205
206 this.commands = Array.isArray(response?.commands) ? response.commands : [];
207 this.builtinCommands = Array.isArray(response?.builtin_commands)
208 ? response.builtin_commands
209 : [];
210 this.scope = response?.scope || null;
211 } catch (error) {
212 console.error("Failed to load commands:", error);
213 this.commands = [];
214 this.builtinCommands = [];
215 this.scope = null;
216 notifyError(error?.message || "Failed to load commands.");
217 } finally {
218 this.loading = false;
219 }
220 },
221
222 async refresh() {
223 await this.loadCommands();
224 },
225
226 async onScopeChanged() {
227 this.projectName = this.normalizeProject(this.projectName);
228 await this.loadCommands();
229 },
230
231 scopedOverride(command) {
232 return (this.commands || []).find((item) => item.name === command?.name);
233 },
234
235 async editBuiltinCommand(command) {
236 const existing = this.scopedOverride(command);
237 if (existing) {
238 await this.openEditCommand(existing);
239 return;
240 }
241
242 try {
243 const response = await callJsonApi(COMMANDS_API_PATH, {
244 action: "duplicate",
245 path: command.path,
246 project_name: this.projectName || "",
247 });
248 await this.loadCommands();
249 emitCommandsUpdated();
250 notifySuccess(`Created ${this.selectedScopeLabel} override for /${command.name}`);
251 if (response?.command) await this.openEditCommand(response.command);
252 } catch (error) {
253 console.error("Failed to create command override:", error);
254 notifyError(error?.message || "Failed to create command override.");
255 }
256 },
257
258 async browseScopeFolder() {
259 try {
260 const response = await callJsonApi(COMMANDS_API_PATH, {
261 action: "scope_info",
262 project_name: this.projectName || "",
263 ensure_directory: true,
264 });
265 if (response?.scope?.directory_path) {
266 await fileBrowserStore.open(response.scope.directory_path);
267 }
268 } catch (error) {
269 console.error("Failed to open scope folder:", error);
270 notifyError(error?.message || "Failed to open scope folder.");
271 }
272 },
273
274 async openCreateCommand(options = {}) {
275 if (Object.prototype.hasOwnProperty.call(options, "projectName")) {
276 this.projectName = this.normalizeProject(options.projectName || "");
277 await this.loadCommands();
278 }
279
280 const suggestedName = sanitizeCommandName(options.name || "");
281 this.editor = {
282 ...createEmptyEditor(),
283 mode: "create",
284 name: suggestedName,
285 commandType: "text",
286 body: buildDefaultBody("text"),
287 };
288 this.editorSnapshot = this._serializeEditor();
289 await this.openEditorModal();
290 },
291
292 async openEditCommand(command) {
293 if (!command?.path) return;
294
295 try {
296 const response = await callJsonApi(COMMANDS_API_PATH, {
297 action: "get",
298 path: command.path,
299 project_name: this.projectName || "",
300 });
301 const loaded = response?.command || command;
302 this.editor = {
303 mode: "edit",
304 existingPath: loaded.path || "",
305 path: loaded.path || "",
306 name: loaded.name || "",
307 description: loaded.description || "",
308 argumentHint: loaded.argument_hint || "",
309 commandType: loaded.command_type || "text",
310 includeHistory: Boolean(loaded.include_history),
311 body: loaded.body || "",
312 extraFrontmatter: loaded.frontmatter_extra || {},
313 };
314 this.editorSnapshot = this._serializeEditor();
315 await this.openEditorModal();
316 } catch (error) {
317 console.error("Failed to load command:", error);
318 notifyError(error?.message || "Failed to load command.");
319 }
320 },
321
322 async duplicateCommand(command) {
323 if (!command?.path) return;
324
325 try {
326 const response = await callJsonApi(COMMANDS_API_PATH, {
327 action: "duplicate",
328 path: command.path,
329 project_name: this.projectName || "",
330 });
331 await this.loadCommands();
332 emitCommandsUpdated();
333 notifySuccess(`Duplicated /${response?.command?.name || command.name}`);
334 if (response?.command) {
335 await this.openEditCommand(response.command);
336 }
337 } catch (error) {
338 console.error("Failed to duplicate command:", error);
339 notifyError(error?.message || "Failed to duplicate command.");
340 }
341 },
342
343 async deleteCommand(command) {
344 if (!command?.path) return;
345
346 try {
347 await callJsonApi(COMMANDS_API_PATH, {
348 action: "delete",
349 path: command.path,
350 project_name: this.projectName || "",
351 });
352 await this.loadCommands();
353 emitCommandsUpdated();
354 notifySuccess(`Deleted /${command.name}`);
355 } catch (error) {
356 console.error("Failed to delete command:", error);
357 notifyError(error?.message || "Failed to delete command.");
358 }
359 },
360
361 async openEditorModal() {
362 await window.openModal?.(EDITOR_MODAL_PATH, () => this.confirmCloseEditor());
363 this.resetEditor();
364 },
365
366 confirmCloseEditor() {
367 if (!this.editorDirty) return true;
368 return window.confirm("Discard unsaved slash command changes?");
369 },
370
371 async closeEditor() {
372 await window.closeModal?.(EDITOR_MODAL_PATH);
373 },
374
375 setEditorType(nextType) {
376 const normalizedType = nextType === "script" ? "script" : "text";
377 if (this.editor.commandType === normalizedType) return;
378 this.editor.commandType = normalizedType;
379 this.editor.includeHistory =
380 normalizedType === "script" ? this.editor.includeHistory : false;
381 this.editor.body = buildDefaultBody(normalizedType);
382 },
383
384 async saveEditor() {
385 this.saving = true;
386
387 try {
388 const response = await callJsonApi(COMMANDS_API_PATH, {
389 action: "save",
390 project_name: this.projectName || "",
391 existing_path: this.editor.existingPath || "",
392 name: this.editor.name || "",
393 description: this.editor.description || "",
394 argument_hint: this.editor.argumentHint || "",
395 command_type: this.editor.commandType || "text",
396 include_history:
397 this.editor.commandType === "script" ? Boolean(this.editor.includeHistory) : false,
398 body: this.editor.body || "",
399 extra_frontmatter: this.editor.extraFrontmatter || {},
400 });
401
402 this.editor.path = response?.command?.path || "";
403 this.editor.existingPath = response?.command?.path || "";
404 this.editorSnapshot = this._serializeEditor();
405 await this.loadCommands();
406 emitCommandsUpdated();
407 notifySuccess(
408 `${this.editor.mode === "edit" ? "Updated" : "Saved"} /${response?.command?.name || this.editor.name}`,
409 );
410 await window.closeModal?.(EDITOR_MODAL_PATH);
411 } catch (error) {
412 console.error("Failed to save command:", error);
413 notifyError(error?.message || "Failed to save command.");
414 } finally {
415 this.saving = false;
416 }
417 },
418
419 resetEditor() {
420 this.editor = createEmptyEditor();
421 this.editorSnapshot = this._serializeEditor();
422 },
423
424 _serializeEditor() {
425 return safeStringify({
426 existingPath: this.editor.existingPath || "",
427 name: this.editor.name || "",
428 description: this.editor.description || "",
429 argumentHint: this.editor.argumentHint || "",
430 commandType: this.editor.commandType || "text",
431 includeHistory: Boolean(this.editor.includeHistory),
432 body: this.editor.body || "",
433 extraFrontmatter: this.editor.extraFrontmatter || {},
434 });
435 },
436 };
437
438 export const store = createStore("commandsManager", model);