| 1 | import { createStore } from "/js/AlpineStore.js"; |
| 2 | import { store as chatsStore } from "/components/sidebar/chats/chats-store.js"; |
| 3 | import { store as memoryStore } from "/plugins/_memory/webui/memory-dashboard-store.js"; |
| 4 | import { store as projectsStore } from "/components/projects/projects-store.js"; |
| 5 | import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; |
| 6 | import * as API from "/js/api.js"; |
| 7 | import { getCurrentUserISOString } from "/js/time-utils.js"; |
| 8 | |
| 9 | const model = { |
| 10 | // State |
| 11 | banners: [], |
| 12 | bannersLoading: false, |
| 13 | lastBannerRefresh: 0, |
| 14 | hasDismissedBanners: false, |
| 15 | _initialized: false, |
| 16 | |
| 17 | get isVisible() { |
| 18 | return !chatsStore.selected; |
| 19 | }, |
| 20 | |
| 21 | init() { |
| 22 | if (this._initialized) return; |
| 23 | this._initialized = true; |
| 24 | |
| 25 | // Reload banners when a modal closes while the welcome screen is visible. |
| 26 | document.addEventListener("modal-closed", () => { |
| 27 | if (this.isVisible) { |
| 28 | this.refreshBanners(true); |
| 29 | } |
| 30 | }); |
| 31 | }, |
| 32 | |
| 33 | onCreate() { |
| 34 | if (this.isVisible) { |
| 35 | this.refreshBanners(); |
| 36 | } |
| 37 | }, |
| 38 | |
| 39 | // Build frontend context to send to backend |
| 40 | buildFrontendContext() { |
| 41 | return { |
| 42 | url: window.location.href, |
| 43 | protocol: window.location.protocol, |
| 44 | hostname: window.location.hostname, |
| 45 | port: window.location.port, |
| 46 | browser: navigator.userAgent, |
| 47 | timestamp: getCurrentUserISOString(), |
| 48 | }; |
| 49 | }, |
| 50 | |
| 51 | // Frontend banner checks (most checks are on backend; add browser-only checks here) |
| 52 | runFrontendBannerChecks() { |
| 53 | return []; |
| 54 | }, |
| 55 | |
| 56 | // Call backend API for additional banners |
| 57 | async runBackendBannerChecks(frontendBanners, frontendContext) { |
| 58 | try { |
| 59 | const response = await API.callJsonApi("/banners", { |
| 60 | banners: frontendBanners, |
| 61 | context: frontendContext, |
| 62 | }); |
| 63 | return response?.banners || []; |
| 64 | } catch (error) { |
| 65 | console.error("Failed to fetch backend banners:", error); |
| 66 | return []; |
| 67 | } |
| 68 | }, |
| 69 | |
| 70 | // Get list of dismissed banner IDs from storage |
| 71 | getDismissedBannerIds() { |
| 72 | const permanent = JSON.parse( |
| 73 | localStorage.getItem("dismissed_banners") || "[]", |
| 74 | ); |
| 75 | const temporary = JSON.parse( |
| 76 | sessionStorage.getItem("dismissed_banners") || "[]", |
| 77 | ); |
| 78 | return new Set([...permanent, ...temporary]); |
| 79 | }, |
| 80 | |
| 81 | // Merge and filter banners: deduplicate by ID, skip dismissed, sort by priority |
| 82 | mergeBanners(frontendBanners, backendBanners) { |
| 83 | const dismissed = this.getDismissedBannerIds(); |
| 84 | const bannerMap = new Map(); |
| 85 | |
| 86 | for (const banner of frontendBanners) { |
| 87 | if ( |
| 88 | banner.id && |
| 89 | (banner.dismissible === false || !dismissed.has(banner.id)) |
| 90 | ) { |
| 91 | bannerMap.set(banner.id, banner); |
| 92 | } |
| 93 | } |
| 94 | for (const banner of backendBanners) { |
| 95 | if ( |
| 96 | banner.id && |
| 97 | (banner.dismissible === false || !dismissed.has(banner.id)) |
| 98 | ) { |
| 99 | bannerMap.set(banner.id, banner); |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | return Array.from(bannerMap.values()).sort( |
| 104 | (a, b) => (b.priority || 0) - (a.priority || 0), |
| 105 | ); |
| 106 | }, |
| 107 | |
| 108 | // Refresh banners: frontend checks → backend checks → merge |
| 109 | async refreshBanners(force = false) { |
| 110 | const now = Date.now(); |
| 111 | if (!force && now - this.lastBannerRefresh < 1000) return; |
| 112 | this.lastBannerRefresh = now; |
| 113 | this.bannersLoading = true; |
| 114 | |
| 115 | try { |
| 116 | const frontendContext = this.buildFrontendContext(); |
| 117 | const frontendBanners = this.runFrontendBannerChecks(); |
| 118 | const backendBanners = await this.runBackendBannerChecks( |
| 119 | frontendBanners, |
| 120 | frontendContext, |
| 121 | ); |
| 122 | |
| 123 | const dismissed = this.getDismissedBannerIds(); |
| 124 | const loadIds = new Set( |
| 125 | [...frontendBanners, ...backendBanners] |
| 126 | .filter((b) => b?.id && b.dismissible !== false) |
| 127 | .map((b) => b.id), |
| 128 | ); |
| 129 | this.hasDismissedBanners = Array.from(loadIds).some((id) => |
| 130 | dismissed.has(id), |
| 131 | ); |
| 132 | |
| 133 | this.banners = this.mergeBanners(frontendBanners, backendBanners); |
| 134 | } catch (error) { |
| 135 | console.error("Failed to refresh banners:", error); |
| 136 | this.banners = this.runFrontendBannerChecks(); |
| 137 | this.hasDismissedBanners = false; |
| 138 | } finally { |
| 139 | this.bannersLoading = false; |
| 140 | } |
| 141 | }, |
| 142 | |
| 143 | get sortedBanners() { |
| 144 | return [...this.banners] |
| 145 | .filter((b) => b.id !== "system-resources") |
| 146 | .filter((b) => b.id !== "missing-api-key") |
| 147 | .filter((b) => b.type !== "hero" && b.type !== "feature") |
| 148 | .sort((a, b) => (b.priority || 0) - (a.priority || 0)); |
| 149 | }, |
| 150 | |
| 151 | get systemResourceBanner() { |
| 152 | return this.banners.find((b) => b.id === "system-resources") || null; |
| 153 | }, |
| 154 | |
| 155 | get heroSubtitle() { |
| 156 | return "How can I help you today?"; |
| 157 | }, |
| 158 | |
| 159 | executeBannerAction(action) { |
| 160 | if (!action) return; |
| 161 | |
| 162 | if (action.startsWith("open-modal:")) { |
| 163 | const path = action.slice("open-modal:".length); |
| 164 | this.openModalPath(path); |
| 165 | return; |
| 166 | } |
| 167 | |
| 168 | if (action.startsWith("open-url:")) { |
| 169 | const url = action.slice("open-url:".length); |
| 170 | if (url) window.open(url, "_blank", "noopener,noreferrer"); |
| 171 | } |
| 172 | }, |
| 173 | |
| 174 | handleBannerHtmlClick(event) { |
| 175 | const actionTarget = event?.target?.closest?.("[data-banner-action]"); |
| 176 | if (!actionTarget) return; |
| 177 | const action = actionTarget.getAttribute("data-banner-action"); |
| 178 | if (!action) return; |
| 179 | |
| 180 | event.preventDefault(); |
| 181 | event.stopPropagation(); |
| 182 | this.executeBannerAction(action); |
| 183 | }, |
| 184 | |
| 185 | openModalPath(path) { |
| 186 | if (!path) return; |
| 187 | |
| 188 | let modalPath = path; |
| 189 | let hash = ""; |
| 190 | const hashIndex = path.indexOf("#"); |
| 191 | if (hashIndex !== -1) { |
| 192 | modalPath = path.slice(0, hashIndex); |
| 193 | hash = path.slice(hashIndex + 1); |
| 194 | } |
| 195 | |
| 196 | if (hash) { |
| 197 | history.replaceState(null, "", `#${hash}`); |
| 198 | } |
| 199 | if (modalPath) window.openModal(modalPath); |
| 200 | }, |
| 201 | |
| 202 | /** |
| 203 | * Dismiss a banner by ID. |
| 204 | * |
| 205 | * Usage: |
| 206 | * dismissBanner('banner-id') - Temporary dismiss (sessionStorage, cleared on browser close) |
| 207 | * dismissBanner('banner-id', true) - Permanent dismiss (localStorage, persists across sessions) |
| 208 | * |
| 209 | * Dismissed banners are filtered out in mergeBanners() and won't appear until storage is cleared. |
| 210 | * |
| 211 | * @param {string} bannerId - The unique ID of the banner to dismiss |
| 212 | * @param {boolean} permanent - If true, store in localStorage; if false, store in sessionStorage |
| 213 | */ |
| 214 | dismissBanner(bannerId, permanent = false) { |
| 215 | this.banners = this.banners.filter((b) => b.id !== bannerId); |
| 216 | |
| 217 | const storage = permanent ? localStorage : sessionStorage; |
| 218 | const dismissed = JSON.parse(storage.getItem("dismissed_banners") || "[]"); |
| 219 | if (!dismissed.includes(bannerId)) { |
| 220 | dismissed.push(bannerId); |
| 221 | storage.setItem("dismissed_banners", JSON.stringify(dismissed)); |
| 222 | } |
| 223 | |
| 224 | this.hasDismissedBanners = this.getDismissedBannerIds().size > 0; |
| 225 | }, |
| 226 | |
| 227 | undismissBanners() { |
| 228 | localStorage.removeItem("dismissed_banners"); |
| 229 | sessionStorage.removeItem("dismissed_banners"); |
| 230 | this.hasDismissedBanners = false; |
| 231 | this.refreshBanners(true); |
| 232 | }, |
| 233 | |
| 234 | getBannerClass(type) { |
| 235 | const classes = { |
| 236 | info: "banner-info", |
| 237 | warning: "banner-warning", |
| 238 | error: "banner-error", |
| 239 | }; |
| 240 | return classes[type] || "banner-info"; |
| 241 | }, |
| 242 | |
| 243 | getBannerIcon(type) { |
| 244 | const icons = { |
| 245 | info: "info", |
| 246 | warning: "warning", |
| 247 | error: "error", |
| 248 | }; |
| 249 | return icons[type] || "info"; |
| 250 | }, |
| 251 | |
| 252 | // Execute an action by ID |
| 253 | executeAction(actionId) { |
| 254 | switch (actionId) { |
| 255 | case "new-chat": |
| 256 | chatsStore.newChat(); |
| 257 | break; |
| 258 | case "scheduler": |
| 259 | window.openModal("modals/scheduler/scheduler-modal.html"); |
| 260 | break; |
| 261 | case "settings": |
| 262 | window.openModal("settings/settings.html"); |
| 263 | break; |
| 264 | case "plugins": |
| 265 | window.openModal("components/plugins/list/plugin-list.html"); |
| 266 | break; |
| 267 | case "projects": |
| 268 | projectsStore.openProjectsModal(); |
| 269 | break; |
| 270 | case "memory": |
| 271 | memoryStore.openModal(); |
| 272 | break; |
| 273 | case "files": |
| 274 | fileBrowserStore.open(); |
| 275 | break; |
| 276 | case "website": |
| 277 | window.open("https://agent-zero.ai", "_blank"); |
| 278 | break; |
| 279 | } |
| 280 | }, |
| 281 | }; |
| 282 | |
| 283 | // Create and export the store |
| 284 | const store = createStore("welcomeStore", model); |
| 285 | export { store }; |