main
js 444 lines 15.5 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import * as api from "/js/api.js";
3 import { fetchApi } from "/js/api.js";
4 import { showConfirmDialog } from "/js/confirmDialog.js";
5 import { store as pluginToggleStore } from "/components/plugins/toggle/plugin-toggle-store.js";
6
7 const model = {
8 // which plugin this modal is showing
9 pluginName: null,
10 pluginMeta: null,
11
12 // context selectors (mirrors skills list pattern)
13 projects: [],
14 agentProfiles: [],
15 projectName: "",
16 agentProfileKey: "",
17
18 // plugin settings data (plugins bind their fields here)
19 settings: {},
20 wizardFooter: null,
21
22 settingsSnapshotJson: "",
23 previousProjectName: "",
24 previousAgentProfileKey: "",
25 openOptions: {},
26
27 _toComparableJson(value) {
28 try {
29 return JSON.stringify(value ?? {});
30 } catch {
31 return "";
32 }
33 },
34
35 get hasUnsavedChanges() {
36 return this._toComparableJson(this.settings) !== (this.settingsSnapshotJson || "");
37 },
38
39 get pluginTitle() {
40 return (
41 this.pluginMeta?.display_name ||
42 this.pluginMeta?.name ||
43 this.pluginName ||
44 "Plugin"
45 );
46 },
47
48 get modalTitle() {
49 if (this.openOptions?.title) return this.openOptions.title;
50 if (this.openOptions?.focus === "chat" && this.pluginName === "_skills") {
51 return "Skills";
52 }
53 return `${this.pluginTitle} Settings`;
54 },
55
56 get hideSettingsActions() {
57 return !!this.openOptions?.hideSettingsActions || this.openOptions?.focus === "chat";
58 },
59
60 confirmDiscardUnsavedChanges() {
61 if (!this.hasUnsavedChanges) return true;
62 return window.confirm("You have unsaved changes that will be lost. Continue?");
63 },
64
65 async _loadPluginMeta(pluginName) {
66 const response = await api.callJsonApi("plugins_list", {
67 filter: { custom: true, builtin: true, search: "" },
68 });
69 const plugins = Array.isArray(response?.plugins) ? response.plugins : [];
70 return plugins.find((plugin) => plugin?.name === pluginName) || null;
71 },
72
73 _hasProject(projectName) {
74 if (!projectName) return false;
75 return (this.projects || []).some((project) => project?.key === projectName);
76 },
77
78 _hasAgentProfile(agentProfileKey) {
79 if (!agentProfileKey) return false;
80 return (this.agentProfiles || []).some((profile) => profile?.key === agentProfileKey);
81 },
82
83 _resolveScope(pluginMeta, projectName = "", agentProfileKey = "") {
84 const resolvedProjectName =
85 pluginMeta?.per_project_config && this._hasProject(projectName)
86 ? projectName
87 : "";
88 const resolvedAgentProfileKey =
89 pluginMeta?.per_agent_config && this._hasAgentProfile(agentProfileKey)
90 ? agentProfileKey
91 : "";
92
93 return {
94 projectName: resolvedProjectName,
95 agentProfileKey: resolvedAgentProfileKey,
96 };
97 },
98
99 _applyPluginState(
100 pluginMeta,
101 { projectName = "", agentProfileKey = "" } = {},
102 openOptions = {},
103 ) {
104 this.pluginName = pluginMeta?.name || null;
105 this.pluginMeta = pluginMeta || null;
106 this.settings = {};
107 this.settingsSnapshotJson = "";
108 this.wizardFooter = null;
109 this.openOptions = openOptions && typeof openOptions === "object" ? openOptions : {};
110 this.error = null;
111 this.projectName = projectName;
112 this.agentProfileKey = agentProfileKey;
113 this.previousProjectName = projectName;
114 this.previousAgentProfileKey = agentProfileKey;
115 this.loadedPath = "";
116 this.loadedProjectName = "";
117 this.loadedAgentProfile = "";
118 this.perProjectConfig = !!pluginMeta?.per_project_config;
119 this.perAgentConfig = !!pluginMeta?.per_agent_config;
120 },
121
122 async _syncToggleScope(projectName = "", agentProfileKey = "") {
123 if (!pluginToggleStore?.loadToggleStatus) return;
124 pluginToggleStore.projectName = projectName;
125 pluginToggleStore.agentProfileKey = agentProfileKey;
126 await pluginToggleStore.loadToggleStatus();
127 },
128
129 async setPluginEnabled(enabled) {
130 if (!pluginToggleStore?.setEnabled) return;
131 this.error = null;
132 try {
133 await pluginToggleStore.setEnabled(enabled, {
134 projectName: this.projectName || "",
135 agentProfileKey: this.agentProfileKey || "",
136 });
137 } catch (e) {
138 this.error = e?.message || "Failed to save activation state";
139 }
140 },
141
142 async onScopeChanged() {
143 const nextProject = this.projectName || "";
144 const nextProfile = this.agentProfileKey || "";
145 const prevProject = this.previousProjectName || "";
146 const prevProfile = this.previousAgentProfileKey || "";
147
148 if (nextProject === prevProject && nextProfile === prevProfile) return;
149
150 if (!this.confirmDiscardUnsavedChanges()) {
151 this.projectName = prevProject;
152 this.agentProfileKey = prevProfile;
153 return;
154 }
155
156 await this.loadSettings();
157 await this._syncToggleScope(nextProject, nextProfile);
158 },
159
160 // where the settings were actually loaded from
161 loadedPath: "",
162 loadedProjectName: "",
163 loadedAgentProfile: "",
164
165 projectLabel(key) {
166 if (!key) return "Global";
167 const found = (this.projects || []).find((p) => p.key === key);
168 return found?.label || key;
169 },
170
171 agentProfileLabel(key) {
172 if (!key) return "All profiles";
173 const found = (this.agentProfiles || []).find((p) => p.key === key);
174 return found?.label || key;
175 },
176
177 get scopeMismatchMessage() {
178 const selectedProject = this.projectName || "";
179 const selectedProfile = this.agentProfileKey || "";
180 const loadedProject = this.loadedProjectName || "";
181 const loadedProfile = this.loadedAgentProfile || "";
182
183 if (!this.loadedPath) return "";
184 if (selectedProject === loadedProject && selectedProfile === loadedProfile) return "";
185
186 return `Settings do not yet exist for this combination, settings from ${this.projectLabel(loadedProject)}, ${this.agentProfileLabel(loadedProfile)} (${this.loadedPath}) will apply.`;
187 },
188
189 configs: [],
190 isListingConfigs: false,
191 configsError: null,
192
193 async openConfigListModal() {
194 await window.openModal?.("/components/plugins/plugin-configs.html");
195 },
196
197 async loadConfigList() {
198 if (!this.pluginName) return;
199 this.isListingConfigs = true;
200 this.configsError = null;
201 try {
202 const response = await fetchApi("/plugins", {
203 method: "POST",
204 headers: { "Content-Type": "application/json" },
205 body: JSON.stringify({
206 action: "list_configs",
207 plugin_name: this.pluginName,
208 }),
209 });
210 const result = await response.json().catch(() => ({}));
211 this.configs = result.ok ? (result.data || []) : [];
212 if (!result.ok) this.configsError = result.error || "Failed to load configurations";
213 } catch (e) {
214 this.configsError = e?.message || "Failed to load configurations";
215 this.configs = [];
216 } finally {
217 this.isListingConfigs = false;
218 }
219 },
220
221 async switchToConfig(projectName, agentProfile) {
222 if (!this.confirmDiscardUnsavedChanges()) return;
223 this.projectName = projectName || "";
224 this.agentProfileKey = agentProfile || "";
225 await this.loadSettings();
226 await this._syncToggleScope(this.projectName, this.agentProfileKey);
227 await window.closeModal?.();
228 },
229
230 async deleteConfig(projectName, agentProfile) {
231 if (!this.pluginName) return;
232 try {
233 const cfg = (this.configs || []).find(
234 (c) => (c?.project_name || "") === (projectName || "") && (c?.agent_profile || "") === (agentProfile || "")
235 );
236 const path = cfg?.path || "";
237 if (!path) {
238 this.configsError = "Configuration path not found";
239 return;
240 }
241
242 const response = await fetchApi("/plugins", {
243 method: "POST",
244 headers: { "Content-Type": "application/json" },
245 body: JSON.stringify({
246 action: "delete_config",
247 plugin_name: this.pluginName,
248 path,
249 }),
250 });
251 const result = await response.json().catch(() => ({}));
252 if (!result.ok) {
253 this.configsError = result.error || "Delete failed";
254 return;
255 }
256
257 this.configsError = null;
258 await this.loadConfigList();
259 } catch (e) {
260 this.configsError = e?.message || "Delete failed";
261 }
262 },
263
264 perProjectConfig: true,
265 perAgentConfig: true,
266
267 isLoading: false,
268 isSaving: false,
269 error: null,
270
271 async openConfig(pluginName, projectName = "", agentProfile = "", openOptions = {}) {
272 if (!pluginName) {
273 throw new Error("Missing plugin name.");
274 }
275
276 this.cleanup();
277 const pluginMeta = await this._loadPluginMeta(pluginName);
278 if (!pluginMeta) {
279 throw new Error(`Plugin "${pluginName}" not found.`);
280 }
281 if (
282 !pluginMeta.has_config_screen &&
283 !pluginMeta.per_project_config &&
284 !pluginMeta.per_agent_config
285 ) {
286 throw new Error(`Plugin "${pluginName}" has no configurable scope.`);
287 }
288
289 await Promise.all([this.loadProjects(), this.loadAgentProfiles()]);
290 const resolvedScope = this._resolveScope(pluginMeta, projectName || "", agentProfile || "");
291 this._applyPluginState(pluginMeta, resolvedScope, openOptions);
292 await this.loadSettings();
293
294 if (!pluginToggleStore?.open) {
295 throw new Error("Plugin toggle store is unavailable.");
296 }
297 await pluginToggleStore.open(pluginMeta, resolvedScope);
298 await window.openModal?.("/components/plugins/plugin-settings.html");
299 },
300
301 async loadAgentProfiles() {
302 try {
303 const response = await fetchApi("/agents", {
304 method: "POST",
305 headers: { "Content-Type": "application/json" },
306 body: JSON.stringify({ action: "list" }),
307 });
308 const data = await response.json().catch(() => ({}));
309 this.agentProfiles = data.ok ? (data.data || []) : [];
310 } catch {
311 this.agentProfiles = [];
312 }
313 },
314
315 async loadProjects() {
316 try {
317 const response = await fetchApi("/projects", {
318 method: "POST",
319 headers: { "Content-Type": "application/json" },
320 body: JSON.stringify({ action: "list_options" }),
321 });
322 const data = await response.json().catch(() => ({}));
323 this.projects = data.ok ? (data.data || []) : [];
324 } catch {
325 this.projects = [];
326 }
327 },
328
329 async loadSettings() {
330 if (!this.pluginName) return;
331 this.isLoading = true;
332 this.error = null;
333 try {
334 const response = await fetchApi("/plugins", {
335 method: "POST",
336 headers: { "Content-Type": "application/json" },
337 body: JSON.stringify({
338 action: "get_config",
339 plugin_name: this.pluginName,
340 project_name: this.projectName || "",
341 agent_profile: this.agentProfileKey || "",
342 }),
343 });
344 const result = await response.json().catch(() => ({}));
345 this.settings = result.ok ? (result.data || {}) : {};
346 this.loadedPath = result.loaded_path || "";
347 this.loadedProjectName = result.loaded_project_name || "";
348 this.loadedAgentProfile = result.loaded_agent_profile || "";
349 if (!result.ok) this.error = result.error || "Failed to load settings";
350 } catch (e) {
351 this.error = e?.message || "Failed to load settings";
352 this.settings = {};
353 } finally {
354 this.settingsSnapshotJson = this._toComparableJson(this.settings);
355 this.previousProjectName = this.projectName || "";
356 this.previousAgentProfileKey = this.agentProfileKey || "";
357 this.isLoading = false;
358 }
359 },
360
361 async resetToDefault() {
362 if (!this.pluginName) return;
363 const confirmed = await showConfirmDialog({
364 title: "Reset to default",
365 message: "This will replace the current settings with the plugin defaults. Any unsaved changes will be lost.",
366 confirmText: "Reset",
367 type: "warning",
368 });
369 if (!confirmed) return;
370 const response = await fetchApi("/plugins", {
371 method: "POST",
372 headers: { "Content-Type": "application/json" },
373 body: JSON.stringify({ action: "get_default_config", plugin_name: this.pluginName }),
374 });
375 const result = await response.json().catch(() => ({}));
376 if (result.ok) {
377 this.settings = result.data || {};
378 globalThis.justToast?.("Settings reset to default.", "info");
379 }
380 },
381
382 async save() {
383 if (!this.pluginName) return;
384 this.isSaving = true;
385 this.error = null;
386 try {
387 const response = await fetchApi("/plugins", {
388 method: "POST",
389 headers: { "Content-Type": "application/json" },
390 body: JSON.stringify({
391 action: "save_config",
392 plugin_name: this.pluginName,
393 project_name: this.projectName || "",
394 agent_profile: this.agentProfileKey || "",
395 settings: this.settings,
396 }),
397 });
398 const result = await response.json().catch(() => ({}));
399 if (!result.ok) this.error = result.error || "Save failed";
400 else {
401 this.settingsSnapshotJson = this._toComparableJson(this.settings);
402 window.closeModal?.();
403 }
404 } catch (e) {
405 this.error = e?.message || "Save failed";
406 } finally {
407 this.isSaving = false;
408 }
409 },
410
411 cleanup() {
412 this.pluginName = null;
413 this.pluginMeta = null;
414 this.projects = [];
415 this.agentProfiles = [];
416 this.projectName = "";
417 this.agentProfileKey = "";
418 this.settings = {};
419 this.settingsSnapshotJson = "";
420 this.openOptions = {};
421 this.wizardFooter = null;
422 this.previousProjectName = "";
423 this.previousAgentProfileKey = "";
424 this.loadedPath = "";
425 this.loadedProjectName = "";
426 this.loadedAgentProfile = "";
427 this.error = null;
428 this.isLoading = false;
429 this.isSaving = false;
430 this.isListingConfigs = false;
431 this.configsError = null;
432 this.configs = [];
433 this.perProjectConfig = true;
434 this.perAgentConfig = true;
435 },
436
437 // Reactive URL for the plugin's settings component (used with x-html injection)
438 get settingsComponentHtml() {
439 if (!this.pluginName || !this.pluginMeta?.has_config_screen) return "";
440 return `<x-component path="/plugins/${this.pluginName}/webui/config.html"></x-component>`;
441 },
442 };
443
444 export const store = createStore("pluginSettingsPrototype", model);