main
js 712 lines 21.4 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import * as API from "/js/api.js";
3 import { store as notificationStore } from "/components/notifications/notification-store.js";
4 import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
5 import {
6 getBrowserTimezone,
7 setConfiguredTimeFormat,
8 setConfiguredTimezone,
9 } from "/js/time-utils.js";
10
11 // Constants
12 const VIEW_MODE_STORAGE_KEY = "settingsActiveTab";
13 const DEFAULT_TAB = "agent";
14 const UPDATE_STATUS_REFRESH_COOLDOWN_MS = 60 * 1000;
15 // Match the modal header/padding breathing room before promoting a section link.
16 const SECTION_ACTIVATION_OFFSET = 56;
17
18 const UI_CONTROLS = Object.freeze([
19 { id: "projectSelector", label: "Project selector", icon: "folder_open" },
20 { id: "time", label: "Time", icon: "schedule" },
21 { id: "connectionStatus", label: "Connection status", icon: "wifi" },
22 { id: "rightCanvasRail", label: "Right canvas rail", icon: "dock_to_right" },
23 ]);
24
25 const TAB_ITEMS = Object.freeze([
26 {
27 id: "agent",
28 label: "Agent Settings",
29 icon: "smart_toy",
30 sections: [
31 { id: "section-agent-config", label: "Agent Config", icon: "settings" },
32 { id: "section-models-summary", label: "Models", icon: "forum" },
33 { id: "section-voice", label: "Voice", icon: "mic" },
34 { id: "section-workdir", label: "Workdir", icon: "folder" },
35 { id: "section-locale", label: "Locale", icon: "language" },
36 { id: "section-interface", label: "Interface", icon: "dashboard_customize" },
37 { id: "section-agent-plugins", label: "Plugins", icon: "extension" },
38 ],
39 },
40 {
41 id: "skills",
42 label: "Skills",
43 icon: "school",
44 sections: [
45 { id: "section-skills-list", label: "List Skills", icon: "view_list" },
46 { id: "section-skills-import", label: "Import Skills", icon: "upload_file" },
47 { id: "section-skills-scan", label: "Scan Skills", icon: "radar" },
48 ],
49 },
50 {
51 id: "external",
52 label: "External Services",
53 icon: "cloud_sync",
54 sections: [
55 { id: "section-api-keys", label: "API Keys", icon: "key" },
56 { id: "section-litellm", label: "LiteLLM", icon: "tune" },
57 { id: "section-secrets", label: "Secrets", icon: "lock" },
58 { id: "section-auth", label: "Authentication", icon: "passkey" },
59 { id: "section-external-api", label: "External API", icon: "api" },
60 { id: "section-tunnel", label: "Remote Control", icon: "share" },
61 { id: "section-external-plugins", label: "Plugins", icon: "extension" },
62 ],
63 },
64 {
65 id: "mcp",
66 label: "MCP/A2A",
67 icon: "hub",
68 sections: [
69 { id: "section-mcp-client", label: "External MCP Servers", icon: "hub" },
70 { id: "section-mcp-server", label: "A0 MCP Server", icon: "settings_input_antenna" },
71 { id: "section-a2a-server", label: "A0 A2A Server", icon: "conversion_path" },
72 ],
73 },
74 {
75 id: "developer",
76 label: "Developer",
77 icon: "code",
78 sections: [
79 { id: "section-dev", label: "Development", icon: "terminal" },
80 ],
81 },
82 {
83 id: "backup",
84 label: "Check for updates",
85 icon: "system_update_alt",
86 sections: [
87 { id: "section-self-update", label: "Self Update", icon: "system_update_alt" },
88 { id: "section-backup-restore", label: "Backup & Restore", icon: "backup" },
89 ],
90 },
91 ]);
92
93 // Field button actions (field id -> modal path)
94 const FIELD_BUTTON_MODAL_BY_ID = Object.freeze({
95 mcp_servers_config: "settings/mcp/client/mcp-servers.html",
96 backup_create: "settings/backup/backup.html",
97 backup_restore: "settings/backup/restore.html",
98 show_a2a_connection: "settings/a2a/a2a-connection.html",
99 external_api_examples: "settings/external/api-examples.html",
100 });
101
102 // Helper for toasts
103 function toast(text, type = "info", timeout = 5000) {
104 notificationStore.addFrontendToastOnly(type, text, "", timeout / 1000);
105 }
106
107 // Settings Store
108 const model = {
109 // State
110 isLoading: false,
111 error: null,
112 settings: null,
113 additional: null,
114 workdirFileStructureTestOutput: "",
115 _activeSection: null,
116 _paneScrollHandler: null,
117 _paneScrollPane: null,
118 _scrollSyncFrame: null,
119 _updateStatusRefreshedAt: 0,
120 expandedNavGroups: {},
121 searchQuery: "",
122 uiVisibility: null,
123
124 // Tab state
125 _activeTab: DEFAULT_TAB,
126 get activeTab() {
127 return this._activeTab;
128 },
129 set activeTab(value) {
130 const previous = this._activeTab;
131 this._activeTab = this.normalizeTabId(value);
132 this.applyActiveTab(previous, this._activeTab);
133 },
134
135 get activeSection() {
136 return this._activeSection || this.getFirstSectionId(this.activeTab);
137 },
138
139 // Lifecycle
140 init() {
141 // Restore persisted tab
142 try {
143 const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY);
144 if (saved) this._activeTab = this.normalizeTabId(saved);
145 } catch {}
146 this._activeSection = this.getFirstSectionId(this._activeTab);
147 this.expandedNavGroups = this.createDefaultExpandedNavGroups(this._activeTab);
148 },
149
150 async onOpen() {
151 this.error = null;
152 this.isLoading = true;
153 this.uiVisibility = preferencesStore.uiVisibilitySnapshot();
154
155 try {
156 const response = await API.callJsonApi("settings_get", null);
157 if (response && response.settings) {
158 this.settings = response.settings;
159 this.additional = response.additional || null;
160 this.applyLocaleRuntime(this.settings);
161 preferencesStore.setUiVisibility(this.settings.ui_control_visibility);
162 this.uiVisibility = preferencesStore.uiVisibilitySnapshot();
163 } else {
164 throw new Error("Invalid settings response");
165 }
166 } catch (e) {
167 console.error("Failed to load settings:", e);
168 this.error = e.message || "Failed to load settings";
169 toast("Failed to load settings", "error");
170 } finally {
171 this.isLoading = false;
172 }
173
174 this.refreshUpdateStatus();
175
176 const hashSectionId = this.getHashSectionId();
177 const openedHashSection = hashSectionId
178 ? this.activateSection(hashSectionId, { persist: false })
179 : false;
180
181 // Trigger tab activation for current tab
182 this._activeTab = this.normalizeTabId(this._activeTab);
183 this.applyActiveTab(null, this._activeTab);
184 this.bindPaneScroll();
185
186 if (openedHashSection) {
187 this.scrollToSection(hashSectionId);
188 }
189 },
190
191 cleanup() {
192 this.unbindPaneScroll();
193 this.settings = null;
194 this.additional = null;
195 this.error = null;
196 this.isLoading = false;
197 this.searchQuery = "";
198 this.uiVisibility = null;
199 },
200
201 get uiControls() {
202 return UI_CONTROLS;
203 },
204
205 isUiControlVisible(control, device) {
206 return this.uiVisibility?.[control]?.[device] !== false;
207 },
208
209 toggleUiControl(control, device) {
210 this.uiVisibility = {
211 ...this.uiVisibility,
212 [control]: {
213 ...this.uiVisibility?.[control],
214 [device]: !this.isUiControlVisible(control, device),
215 },
216 };
217 },
218
219 uiControlVisibilityLabel(control) {
220 const mobile = this.isUiControlVisible(control, "mobile");
221 const desktop = this.isUiControlVisible(control, "desktop");
222 if (mobile && desktop) return "Shown everywhere";
223 if (mobile) return "Mobile only";
224 if (desktop) return "Desktop only";
225 return "Hidden everywhere";
226 },
227
228 // Tab management
229 applyActiveTab(previous, current) {
230 if (!this.sectionBelongsToTab(this._activeSection, current)) {
231 this._activeSection = this.getFirstSectionId(current);
232 }
233
234 // Persist
235 try {
236 localStorage.setItem(VIEW_MODE_STORAGE_KEY, current);
237 } catch {}
238
239 this.setNavGroupExpanded(current, true);
240 this.bindPaneScroll();
241 },
242
243 switchTab(tabName) {
244 this.activeTab = tabName;
245 },
246
247 normalizeTabId(tabName) {
248 return TAB_ITEMS.some((item) => item.id === tabName) ? tabName : DEFAULT_TAB;
249 },
250
251 get navItems() {
252 return TAB_ITEMS;
253 },
254
255 get normalizedSearchQuery() {
256 return String(this.searchQuery || "").trim().toLowerCase();
257 },
258
259 get hasSearchQuery() {
260 return this.normalizedSearchQuery.length > 0;
261 },
262
263 get filteredNavItems() {
264 const query = this.normalizedSearchQuery;
265 if (!query) return TAB_ITEMS;
266
267 return TAB_ITEMS
268 .map((item) => {
269 const itemMatches = this.getNavSearchText(item).includes(query);
270 const sections = itemMatches
271 ? item.sections
272 : item.sections.filter((section) => this.getNavSearchText(section).includes(query));
273 return sections.length ? { ...item, sections } : null;
274 })
275 .filter(Boolean);
276 },
277
278 get activeTabItem() {
279 return TAB_ITEMS.find((item) => item.id === this.activeTab) || TAB_ITEMS[0];
280 },
281
282 get sectionItems() {
283 return this.activeTabItem?.sections || [];
284 },
285
286 getFirstSectionId(tabName = this.activeTab) {
287 const tab = TAB_ITEMS.find((item) => item.id === tabName) || TAB_ITEMS[0];
288 return tab?.sections?.[0]?.id || null;
289 },
290
291 getNavSearchText(item) {
292 return `${item?.label || ""} ${item?.id || ""}`.toLowerCase();
293 },
294
295 createDefaultExpandedNavGroups(activeTab = this.activeTab) {
296 return TAB_ITEMS.reduce((groups, item) => {
297 groups[item.id] = item.id === activeTab;
298 return groups;
299 }, {});
300 },
301
302 isNavGroupExpanded(tabName) {
303 if (this.hasSearchQuery) return true;
304 const tabId = this.normalizeTabId(tabName);
305 return Boolean(this.expandedNavGroups?.[tabId]);
306 },
307
308 setNavGroupExpanded(tabName, expanded) {
309 const tabId = this.normalizeTabId(tabName);
310 this.expandedNavGroups = {
311 ...(this.expandedNavGroups || {}),
312 [tabId]: Boolean(expanded),
313 };
314 },
315
316 toggleNavGroup(tabName) {
317 const tabId = this.normalizeTabId(tabName);
318 if (this.hasSearchQuery) {
319 this.enterTab(tabId);
320 return;
321 }
322 if (this.activeTab !== tabId) {
323 this.enterTab(tabId);
324 this.setNavGroupExpanded(tabId, true);
325 return;
326 }
327 this.setNavGroupExpanded(tabId, !this.isNavGroupExpanded(tabId));
328 },
329
330 clearSearch() {
331 this.searchQuery = "";
332 },
333
334 openFirstSearchResult() {
335 const item = this.filteredNavItems[0];
336 const section = item?.sections?.[0];
337 if (section?.id) {
338 this.scrollToSection(section.id);
339 } else if (item?.id) {
340 this.enterTab(item.id);
341 }
342 },
343
344 get browserTimezone() {
345 return getBrowserTimezone();
346 },
347
348 get effectiveTimezone() {
349 if (!this.settings) return this.browserTimezone;
350 return this.settings.timezone === "auto"
351 ? this.browserTimezone
352 : this.settings.timezone || this.browserTimezone;
353 },
354
355 applyTimezoneRuntime(timezone) {
356 setConfiguredTimezone(timezone || "auto");
357 },
358
359 applyTimeFormatRuntime(timeFormat) {
360 setConfiguredTimeFormat(timeFormat || "12h");
361 },
362
363 applyLocaleRuntime(settings) {
364 this.applyTimezoneRuntime(settings?.timezone);
365 this.applyTimeFormatRuntime(settings?.time_format);
366 },
367
368 getTabIdForSection(sectionId) {
369 if (!sectionId) return null;
370 const tab = TAB_ITEMS.find((item) =>
371 item.sections?.some((section) => section.id === sectionId)
372 );
373 return tab?.id || null;
374 },
375
376 sectionBelongsToTab(sectionId, tabName = this.activeTab) {
377 if (!sectionId) return false;
378 return this.getTabIdForSection(sectionId) === tabName;
379 },
380
381 getHashSectionId() {
382 const rawHash = window.location.hash || "";
383 if (!rawHash.startsWith("#section-")) return null;
384 try {
385 return decodeURIComponent(rawHash.slice(1));
386 } catch {
387 return rawHash.slice(1);
388 }
389 },
390
391 activateSection(sectionId, { persist = true } = {}) {
392 const tabId = this.getTabIdForSection(sectionId);
393 if (!tabId) return false;
394
395 const previous = this._activeTab;
396 this._activeTab = tabId;
397 this._activeSection = sectionId;
398 if (persist) {
399 this.applyActiveTab(previous, tabId);
400 }
401 if (tabId === "backup") this.refreshUpdateStatus();
402 return true;
403 },
404
405 enterTab(tabName) {
406 this.activeTab = tabName;
407 this._activeSection = this.getFirstSectionId(this.activeTab);
408 this.setNavGroupExpanded(this.activeTab, true);
409 this.resetPaneScroll();
410 if (tabName === "backup") this.refreshUpdateStatus();
411 },
412
413 resetPaneScroll() {
414 requestAnimationFrame(() => {
415 const pane = this.getSettingsPane();
416 if (pane) {
417 pane.scrollTop = 0;
418 this.updateActiveSectionFromScroll();
419 }
420 });
421 },
422
423 getSettingsPane() {
424 return document.querySelector(".modal-inner.settings-modal .settings-pane");
425 },
426
427 bindPaneScroll() {
428 requestAnimationFrame(() => {
429 const pane = this.getSettingsPane();
430 if (!pane || this._paneScrollPane === pane) {
431 if (pane) this.updateActiveSectionFromScroll();
432 return;
433 }
434
435 this.unbindPaneScroll();
436 this._paneScrollPane = pane;
437 this._paneScrollHandler = () => this.updateActiveSectionFromScroll();
438 pane.addEventListener("scroll", this._paneScrollHandler, { passive: true });
439 this.updateActiveSectionFromScroll();
440 });
441 },
442
443 unbindPaneScroll() {
444 if (this._paneScrollPane && this._paneScrollHandler) {
445 this._paneScrollPane.removeEventListener("scroll", this._paneScrollHandler);
446 }
447 if (this._scrollSyncFrame) {
448 cancelAnimationFrame(this._scrollSyncFrame);
449 }
450 this._paneScrollPane = null;
451 this._paneScrollHandler = null;
452 this._scrollSyncFrame = null;
453 },
454
455 updateActiveSectionFromScroll() {
456 if (this._scrollSyncFrame) return;
457 this._scrollSyncFrame = requestAnimationFrame(() => {
458 this._scrollSyncFrame = null;
459 const pane = this.getSettingsPane();
460 if (!pane) return;
461
462 const paneRect = pane.getBoundingClientRect();
463 const activationTop = paneRect.top + SECTION_ACTIVATION_OFFSET;
464 let activeId = this.getFirstSectionId(this.activeTab);
465
466 for (const section of this.sectionItems) {
467 const target = this.getSectionTarget(section.id, pane);
468 if (!target || target.offsetParent === null) continue;
469 if (target.getBoundingClientRect().top <= activationTop) {
470 activeId = section.id;
471 }
472 }
473
474 this._activeSection = activeId;
475 });
476 },
477
478 get selfUpdate() {
479 return globalThis.Alpine?.store?.("selfUpdateStore") || null;
480 },
481
482 getSectionTarget(sectionId, pane = this.getSettingsPane()) {
483 if (!sectionId) return null;
484 const escapedId = window.CSS?.escape ? window.CSS.escape(sectionId) : sectionId;
485 const selector = `#${escapedId}`;
486 const activePanel = pane?.querySelector(`.settings-tab-panel[data-settings-tab="${this.activeTab}"]`);
487 return activePanel?.querySelector(selector) || pane?.querySelector(selector) || document.getElementById(sectionId);
488 },
489
490 scrollToSection(sectionId, event = null) {
491 event?.preventDefault?.();
492 if (!this.activateSection(sectionId)) {
493 this._activeSection = sectionId;
494 }
495
496 const performScroll = () => {
497 const pane = this.getSettingsPane();
498 const target = this.getSectionTarget(sectionId, pane);
499 if (!target) {
500 history.replaceState(null, "", `#${sectionId}`);
501 return;
502 }
503 if (!pane) {
504 target.scrollIntoView({ behavior: "smooth", block: "start", inline: "nearest" });
505 history.replaceState(null, "", `#${sectionId}`);
506 return;
507 }
508 const paneRect = pane.getBoundingClientRect();
509 const targetRect = target.getBoundingClientRect();
510 pane.scrollTo({
511 top: Math.max(0, pane.scrollTop + targetRect.top - paneRect.top - 12),
512 behavior: "smooth",
513 });
514 history.replaceState(null, "", `#${sectionId}`);
515 this.updateActiveSectionFromScroll();
516 };
517
518 requestAnimationFrame(() => requestAnimationFrame(performScroll));
519 },
520
521 refreshUpdateStatus(force = false) {
522 const selfUpdate = this.selfUpdate;
523 if (typeof selfUpdate?.refresh !== "function") return;
524 const now = Date.now();
525 if (!force && now - this._updateStatusRefreshedAt < UPDATE_STATUS_REFRESH_COOLDOWN_MS) {
526 return;
527 }
528 this._updateStatusRefreshedAt = now;
529 selfUpdate.refresh().catch((error) => {
530 console.warn("Failed to refresh self-update status:", error);
531 });
532 },
533
534 isUpdateNotification(notification) {
535 if (!notification) return false;
536 const group = String(notification.group || "").toLowerCase();
537 const id = String(notification.id || "").toLowerCase();
538 return (
539 group === "update_check" ||
540 group.startsWith("self-update") ||
541 id.startsWith("update_check") ||
542 id.includes("self-update")
543 );
544 },
545
546 get latestUpdateNotification() {
547 return notificationStore.notifications.find((item) => this.isUpdateNotification(item)) || null;
548 },
549
550 get hasUpdateNotification() {
551 return Boolean(this.latestUpdateNotification);
552 },
553
554 get hasUpdateAttention() {
555 const selfUpdate = this.selfUpdate;
556 return Boolean(
557 selfUpdate?.info?.pending ||
558 selfUpdate?.quickUpdateAvailable ||
559 selfUpdate?.hasMajorUpgrade ||
560 this.hasUpdateNotification
561 );
562 },
563
564 get updateAttentionLabel() {
565 const selfUpdate = this.selfUpdate;
566 if (selfUpdate?.info?.pending) return "Scheduled";
567 if (selfUpdate?.quickUpdateAvailable) return "Update available";
568 if (selfUpdate?.hasMajorUpgrade) return "New release line";
569 if (this.hasUpdateNotification) return "Update notice";
570 return selfUpdate?.quickStatusLabel || "Ready";
571 },
572
573 get updateAttentionTitle() {
574 const selfUpdate = this.selfUpdate;
575 if (selfUpdate?.info?.pending) return "Update scheduled";
576 if (selfUpdate?.quickUpdateAvailable) return "Update available";
577 if (selfUpdate?.hasMajorUpgrade) return "New release line available";
578 if (this.hasUpdateNotification) return "Update notice";
579 return "Self Update";
580 },
581
582 get updateAttentionMessage() {
583 const notification = this.latestUpdateNotification;
584 if (notification?.message) {
585 return this.toPlainText(notification.message);
586 }
587 const selfUpdate = this.selfUpdate;
588 if (selfUpdate?.info?.pending) {
589 return "Agent Zero has a self-update request ready for the next restart.";
590 }
591 return selfUpdate?.quickStatusMessage || "Review versions, backups, and update readiness in one place.";
592 },
593
594 navItemHasAttention(item) {
595 return item?.id === "backup" && this.hasUpdateAttention;
596 },
597
598 sectionItemHasAttention(item) {
599 return item?.id === "section-self-update" && this.hasUpdateAttention;
600 },
601
602 toPlainText(value) {
603 const container = document.createElement("div");
604 container.innerHTML = String(value || "");
605 return (container.textContent || container.innerText || "").trim();
606 },
607
608
609
610 get apiKeyProviders() {
611 const seen = new Set();
612 const options = [];
613 const addProvider = (prov) => {
614 if (!prov?.value) return;
615 const key = prov.value.toLowerCase();
616 if (seen.has(key)) return;
617 seen.add(key);
618 options.push({ value: prov.value, label: prov.label || prov.value });
619 };
620 (this.additional?.chat_providers || []).forEach(addProvider);
621 (this.additional?.embedding_providers || []).forEach(addProvider);
622 options.sort((a, b) => a.label.localeCompare(b.label));
623 return options;
624 },
625
626 // Save settings
627 async saveSettings() {
628 if (!this.settings) {
629 toast("No settings to save", "warning");
630 return false;
631 }
632
633 this.settings.ui_control_visibility = this.uiVisibility;
634 this.isLoading = true;
635 try {
636 const response = await API.callJsonApi("settings_set", {
637 settings: this.settings,
638 browser_timezone: this.browserTimezone,
639 });
640 if (response && response.settings) {
641 this.settings = response.settings;
642 this.additional = response.additional || this.additional;
643 this.applyLocaleRuntime(this.settings);
644 preferencesStore.setUiVisibility(response.settings.ui_control_visibility);
645 toast("Settings saved successfully", "success");
646 document.dispatchEvent(
647 new CustomEvent("settings-updated", { detail: response.settings })
648 );
649 return true;
650 } else {
651 throw new Error("Failed to save settings");
652 }
653 } catch (e) {
654 console.error("Failed to save settings:", e);
655 toast("Failed to save settings: " + e.message, "error");
656 return false;
657 } finally {
658 this.isLoading = false;
659 }
660 },
661
662 // Close the modal
663 closeSettings() {
664 window.closeModal("settings/settings.html");
665 },
666
667 // Save and close
668 async saveAndClose() {
669 const success = await this.saveSettings();
670 if (success) {
671 this.closeSettings();
672 }
673 },
674
675 async testWorkdirFileStructure() {
676 if (!this.settings) return;
677 try {
678 const response = await API.callJsonApi("settings_workdir_file_structure", {
679 workdir_path: this.settings.workdir_path,
680 workdir_max_depth: this.settings.workdir_max_depth,
681 workdir_max_files: this.settings.workdir_max_files,
682 workdir_max_folders: this.settings.workdir_max_folders,
683 workdir_max_lines: this.settings.workdir_max_lines,
684 workdir_gitignore: this.settings.workdir_gitignore,
685 });
686 this.workdirFileStructureTestOutput = response?.data || "";
687 window.openModal("settings/agent/workdir-file-structure-test.html");
688 } catch (e) {
689 console.error("Error testing workdir file structure:", e);
690 toast("Error testing workdir file structure", "error");
691 }
692 },
693
694 // Field helpers for external components
695 // Handle button field clicks (opens sub-modals)
696 async handleFieldButton(field) {
697 const modalPath = FIELD_BUTTON_MODAL_BY_ID[field?.id];
698 if (modalPath) window.openModal(modalPath);
699 },
700
701 // Open settings modal from external callers
702 async open(initialTab = null) {
703 if (initialTab) {
704 this._activeTab = initialTab;
705 }
706 await window.openModal("settings/settings.html");
707 },
708 };
709
710 const store = createStore("settings", model);
711
712 export { store };