main
js 285 lines 8.03 KB
Raw
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 };