main
js 534 lines 19.4 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { showConfirmDialog } from "/js/confirmDialog.js";
4
5 const BROWSER_EXTENSIONS_API = "/plugins/_browser/extensions";
6 const BROWSER_STATUS_API = "/plugins/_browser/status";
7 const RUNTIME_BACKENDS = new Set(["container", "host_required"]);
8 const BROWSER_TAB_SCOPES = new Set(["per_context", "shared"]);
9 const HOST_PRIVACY_POLICIES = new Set(["enforce_local", "warn", "allow"]);
10 const HOST_PROFILE_MODES = new Set(["existing", "agent"]);
11 const DEFAULT_MAX_OPEN_TABS = 32;
12 const MIN_MAX_OPEN_TABS = 1;
13 const HARD_MAX_OPEN_TABS = 50;
14 const HOST_BROWSER_STATUS_REFRESH_MS = 1000;
15 const CUSTOM_HOST_BROWSER_SELECTION = "__custom_endpoint__";
16
17 function normalizePathList(value) {
18 const source = Array.isArray(value)
19 ? value
20 : String(value || "").split(/\r?\n/);
21 const seen = new Set();
22 const paths = [];
23 for (const item of source) {
24 const path = String(item || "").trim();
25 if (!path || seen.has(path)) continue;
26 seen.add(path);
27 paths.push(path);
28 }
29 return paths;
30 }
31
32 function ensureConfig(config) {
33 if (!config || typeof config !== "object") return null;
34 config.extension_paths = normalizePathList(config.extension_paths);
35 config.default_homepage = String(config.default_homepage || "about:blank").trim() || "about:blank";
36 config.autofocus_active_page = normalizeBoolean(config.autofocus_active_page, true);
37 config.browser_tab_scope = normalizeChoice(config.browser_tab_scope, BROWSER_TAB_SCOPES, "per_context");
38 config.max_open_tabs = normalizeInt(config.max_open_tabs, DEFAULT_MAX_OPEN_TABS, MIN_MAX_OPEN_TABS, HARD_MAX_OPEN_TABS);
39 config.runtime_backend = normalizeRuntimeBackend(config.runtime_backend);
40 config.proxy_server = String(config.proxy_server || "").trim();
41 config.proxy_bypass = String(config.proxy_bypass || "").trim();
42 config.proxy_username = String(config.proxy_username || "");
43 config.proxy_password = String(config.proxy_password || "");
44 config.keyboard_layout = normalizeXkbToken(config.keyboard_layout);
45 config.keyboard_variant = normalizeXkbToken(config.keyboard_variant);
46 config.host_browser_privacy_policy = normalizeChoice(
47 config.host_browser_privacy_policy,
48 HOST_PRIVACY_POLICIES,
49 "allow",
50 );
51 config.host_browser_profile_mode = normalizeChoice(
52 config.host_browser_profile_mode,
53 HOST_PROFILE_MODES,
54 "existing",
55 );
56 config.host_browser_selection = normalizeHostBrowserSelection(config.host_browser_selection);
57 config.model_preset = String(config.model_preset || "").trim();
58 delete config.model;
59 return config;
60 }
61
62 function normalizeChoice(value, allowed, fallback) {
63 const normalized = String(value || "").trim().toLowerCase().replace(/-/g, "_");
64 return allowed.has(normalized) ? normalized : fallback;
65 }
66
67 function normalizeInt(value, fallback, minimum, maximum) {
68 const number = Number.parseInt(value, 10);
69 if (!Number.isFinite(number)) return fallback;
70 return Math.max(minimum, Math.min(maximum, number));
71 }
72
73 function normalizeXkbToken(value) {
74 return String(value || "")
75 .trim()
76 .toLowerCase()
77 .replace(/[^a-z0-9_-]/g, "")
78 .slice(0, 32);
79 }
80
81 function normalizeRuntimeBackend(value) {
82 const normalized = String(value || "").trim().toLowerCase().replace(/-/g, "_");
83 if (normalized === "host_when_available") return "host_required";
84 return RUNTIME_BACKENDS.has(normalized) ? normalized : "container";
85 }
86
87 function normalizeHostBrowserSelection(value) {
88 const raw = String(value || "").trim();
89 if (raw.includes("://") || /^(?:\[[^\]]+\]|[^/:\s]+):\d+$/.test(raw)) {
90 return raw.replace(/\s+/g, "").slice(0, 2048);
91 }
92 return raw.toLowerCase().replace(/\s+/g, "_").slice(0, 200);
93 }
94
95 function normalizeCustomHostBrowserEndpoint(value) {
96 const raw = String(value || "").trim();
97 if (!raw) return "";
98 const candidate = raw.includes("://") ? raw : `http://${raw}`;
99 try {
100 const url = new URL(candidate);
101 if (!url.host) return "";
102 if (["http:", "https:"].includes(url.protocol)) {
103 if (!["/", "/json/version"].includes(url.pathname)) return "";
104 const path = url.pathname === "/" ? "" : url.pathname;
105 return normalizeHostBrowserSelection(`${url.protocol}//${url.host}${path}${url.search || ""}`);
106 }
107 if (!["ws:", "wss:"].includes(url.protocol)) return "";
108 return normalizeHostBrowserSelection(`${url.protocol}//${url.host}${url.pathname === "/" ? "" : url.pathname}${url.search || ""}`);
109 } catch (_error) {
110 return "";
111 }
112 }
113
114 function isCustomHostBrowserEndpoint(value) {
115 return Boolean(normalizeCustomHostBrowserEndpoint(value));
116 }
117
118 function stableHostBrowserSelection(value, status) {
119 const selection = normalizeHostBrowserSelection(value);
120 if (!selection) return "";
121 const connectors = Array.isArray(status?.connectors) ? status.connectors : [];
122 for (const connector of connectors) {
123 const candidates = [
124 ...(Array.isArray(connector?.available_browsers) ? connector.available_browsers : []),
125 connector,
126 ];
127 for (const candidate of candidates) {
128 const endpoint = normalizeCustomHostBrowserEndpoint(candidate?.cdp_endpoint);
129 const browserId = normalizeHostBrowserSelection(candidate?.id || candidate?.browser_id);
130 if (endpoint && endpoint === selection && browserId) return browserId;
131 }
132 }
133 return selection;
134 }
135
136 function normalizeBoolean(value, fallback = true) {
137 if (value === undefined || value === null || value === "") return fallback;
138 if (typeof value === "boolean") return value;
139 if (typeof value === "number") return Boolean(value);
140 const normalized = String(value).trim().toLowerCase();
141 if (["1", "true", "yes", "on", "enabled"].includes(normalized)) return true;
142 if (["0", "false", "no", "off", "disabled"].includes(normalized)) return false;
143 return fallback;
144 }
145
146 function hostBrowserFamilyLabel(value) {
147 const family = String(value || "").trim().toLowerCase();
148 const a0Profile = family.endsWith("-a0");
149 const remoteDebugging = family.endsWith("-cdp");
150 const base = a0Profile ? family.slice(0, -3) : remoteDebugging ? family.slice(0, -4) : family;
151 const labels = {
152 chrome: "Chrome",
153 chromium: "Chromium",
154 edge: "Edge",
155 "edge-dev": "Edge Dev",
156 brave: "Brave",
157 opera: "Opera",
158 vivaldi: "Vivaldi",
159 };
160 const label = labels[base] || "Host browser";
161 if (remoteDebugging) return `${label} (allowed)`;
162 return a0Profile ? `${label} (A0 profile)` : label;
163 }
164
165 function hostBrowserStatusLabel(value) {
166 const status = String(value || "").trim().toLowerCase();
167 if (status === "active") return "open";
168 if (status === "ready") return "ready";
169 if (status === "disabled") return "will open on first use";
170 if (status === "relaunch_required") return "close browser and retry";
171 if (status === "unsupported") return "unavailable";
172 return status || "ready";
173 }
174
175 export const store = createStore("browserConfig", {
176 config: null,
177 extensionsList: [],
178 extensionsLoading: false,
179 extensionsError: "",
180 extensionsMessage: "",
181 extensionDeleteLoadingPath: "",
182 hostBrowserStatus: null,
183 hostBrowserStatusLoading: false,
184 hostBrowserStatusRefreshTimer: null,
185 hostBrowserCustomEndpoint: "",
186 hostBrowserCustomMode: false,
187
188 async init(config) {
189 this.bindConfig(config);
190 await Promise.all([this.loadExtensionsList(), this.loadHostBrowserStatus()]);
191 this.startHostBrowserStatusRefresh();
192 },
193
194 cleanup() {
195 this.stopHostBrowserStatusRefresh();
196 this.config = null;
197 this.extensionsList = [];
198 this.extensionsError = "";
199 this.extensionsMessage = "";
200 this.extensionDeleteLoadingPath = "";
201 this.hostBrowserStatus = null;
202 this.hostBrowserStatusLoading = false;
203 this.hostBrowserCustomEndpoint = "";
204 this.hostBrowserCustomMode = false;
205 },
206
207 startHostBrowserStatusRefresh() {
208 this.stopHostBrowserStatusRefresh();
209 this.hostBrowserStatusRefreshTimer = window.setInterval(
210 () => this.loadHostBrowserStatus(),
211 HOST_BROWSER_STATUS_REFRESH_MS,
212 );
213 },
214
215 stopHostBrowserStatusRefresh() {
216 if (!this.hostBrowserStatusRefreshTimer) return;
217 window.clearInterval(this.hostBrowserStatusRefreshTimer);
218 this.hostBrowserStatusRefreshTimer = null;
219 },
220
221 bindConfig(config) {
222 const safeConfig = ensureConfig(config);
223 if (!safeConfig) return;
224 if (this.config === safeConfig) return;
225 this.config = safeConfig;
226 if (isCustomHostBrowserEndpoint(safeConfig.host_browser_selection)) {
227 this.hostBrowserCustomEndpoint = safeConfig.host_browser_selection;
228 }
229 },
230
231 setAutofocusActivePage(enabled) {
232 const safeConfig = ensureConfig(this.config);
233 if (!safeConfig) return;
234 safeConfig.autofocus_active_page = Boolean(enabled);
235 },
236
237 autofocusLabel() {
238 return this.config?.autofocus_active_page === false ? "Off" : "On";
239 },
240
241 setBrowserTabScope(value) {
242 const safeConfig = ensureConfig(this.config);
243 if (!safeConfig) return;
244 safeConfig.browser_tab_scope = normalizeChoice(value, BROWSER_TAB_SCOPES, "per_context");
245 },
246
247 browserTabScopeLabel() {
248 return this.config?.browser_tab_scope === "shared" ? "Shared" : "Per chat";
249 },
250
251 normalizeMaxOpenTabs() {
252 const safeConfig = ensureConfig(this.config);
253 if (!safeConfig) return;
254 safeConfig.max_open_tabs = normalizeInt(
255 safeConfig.max_open_tabs,
256 DEFAULT_MAX_OPEN_TABS,
257 MIN_MAX_OPEN_TABS,
258 HARD_MAX_OPEN_TABS,
259 );
260 },
261
262 runtimeBackendLabel() {
263 const value = this.config?.runtime_backend || "container";
264 if (value === "host_required") return "Bring Your Own Browser";
265 return "Docker Browser";
266 },
267
268 privacyPolicyLabel() {
269 const value = this.config?.host_browser_privacy_policy || "allow";
270 if (value === "warn") return "Warn When Using Cloud";
271 if (value === "allow") return "Allow";
272 return "Local Models Only";
273 },
274
275 hostBrowserOptions() {
276 const connectors = Array.isArray(this.hostBrowserStatus?.connectors)
277 ? this.hostBrowserStatus.connectors
278 : [];
279 const options = [{ value: "", label: "Automatic (A0 CLI chooses)" }];
280 const seen = new Set([""]);
281 for (const connector of connectors) {
282 const advertised = Array.isArray(connector?.available_browsers)
283 ? connector.available_browsers
284 : [];
285 for (const browser of advertised) {
286 const endpoint = normalizeCustomHostBrowserEndpoint(browser?.cdp_endpoint);
287 const value = endpoint
288 ? normalizeHostBrowserSelection(browser?.id) || endpoint
289 : "";
290 if (!value || seen.has(value)) continue;
291 seen.add(value);
292 const label = browser?.label || hostBrowserFamilyLabel(browser?.family || value);
293 const status = browser?.status ? ` - ${hostBrowserStatusLabel(browser.status)}` : "";
294 options.push({ value, label: `${label}${status}` });
295 }
296 const fallbackEndpoint = normalizeCustomHostBrowserEndpoint(connector?.cdp_endpoint);
297 const fallbackValue = fallbackEndpoint
298 ? normalizeHostBrowserSelection(connector?.browser_id) || fallbackEndpoint
299 : "";
300 if (fallbackValue && !seen.has(fallbackValue)) {
301 seen.add(fallbackValue);
302 const label = connector?.browser_label || hostBrowserFamilyLabel(connector?.browser_family || fallbackValue);
303 options.push({ value: fallbackValue, label });
304 }
305 }
306 const selected = normalizeHostBrowserSelection(this.config?.host_browser_selection);
307 if (selected && !seen.has(selected) && !isCustomHostBrowserEndpoint(selected)) {
308 seen.add(selected);
309 options.push({ value: selected, label: `Saved: ${selected}` });
310 }
311 options.push({ value: CUSTOM_HOST_BROWSER_SELECTION, label: "Custom endpoint" });
312 return options;
313 },
314
315 hostBrowserSelectValue() {
316 if (this.hostBrowserCustomMode) return CUSTOM_HOST_BROWSER_SELECTION;
317 const selected = normalizeHostBrowserSelection(this.config?.host_browser_selection);
318 if (!selected) return "";
319 if (this.hostBrowserOptions().some((option) => option.value === selected)) return selected;
320 if (isCustomHostBrowserEndpoint(selected)) return CUSTOM_HOST_BROWSER_SELECTION;
321 return selected;
322 },
323
324 setHostBrowserSelection(value) {
325 const safeConfig = ensureConfig(this.config);
326 if (!safeConfig) return;
327 if (value === CUSTOM_HOST_BROWSER_SELECTION) {
328 this.hostBrowserCustomMode = true;
329 if (isCustomHostBrowserEndpoint(safeConfig.host_browser_selection)) {
330 this.hostBrowserCustomEndpoint = safeConfig.host_browser_selection;
331 } else {
332 safeConfig.host_browser_selection = "";
333 }
334 return;
335 }
336 this.hostBrowserCustomMode = false;
337 safeConfig.host_browser_selection = normalizeHostBrowserSelection(value);
338 },
339
340 showCustomHostBrowserEndpoint() {
341 return this.hostBrowserSelectValue() === CUSTOM_HOST_BROWSER_SELECTION;
342 },
343
344 setCustomHostBrowserEndpoint(value) {
345 this.hostBrowserCustomMode = true;
346 this.hostBrowserCustomEndpoint = String(value || "").trim();
347 const safeConfig = ensureConfig(this.config);
348 if (!safeConfig) return;
349 const endpoint = normalizeCustomHostBrowserEndpoint(this.hostBrowserCustomEndpoint);
350 safeConfig.host_browser_selection = endpoint
351 || normalizeHostBrowserSelection(this.hostBrowserCustomEndpoint);
352 },
353
354 customHostBrowserEndpointDiagnostic() {
355 if (!this.hostBrowserCustomEndpoint) {
356 return "Paste a ws://.../devtools/browser/... endpoint from the browser inspect page.";
357 }
358 const endpoint = normalizeCustomHostBrowserEndpoint(this.hostBrowserCustomEndpoint);
359 if (endpoint) return `Using ${endpoint}`;
360 return "Use host:port, an http(s):// discovery address, or a ws(s):// browser endpoint.";
361 },
362
363 hostBrowserProfileModeLabel() {
364 const value = this.config?.host_browser_profile_mode || "existing";
365 if (value === "agent") return "Clean Agent Profile";
366 return "Existing Browser Profile";
367 },
368
369 async loadHostBrowserStatus() {
370 if (this.hostBrowserStatusLoading) return;
371 this.hostBrowserStatusLoading = true;
372 try {
373 const response = await callJsonApi(BROWSER_STATUS_API, {});
374 this.hostBrowserStatus = response?.host_browser || { connectors: [] };
375 const safeConfig = ensureConfig(this.config);
376 if (safeConfig) {
377 const stable = stableHostBrowserSelection(
378 safeConfig.host_browser_selection,
379 this.hostBrowserStatus,
380 );
381 if (stable !== safeConfig.host_browser_selection) {
382 safeConfig.host_browser_selection = stable;
383 this.hostBrowserCustomEndpoint = "";
384 this.hostBrowserCustomMode = false;
385 }
386 }
387 } catch (_error) {
388 this.hostBrowserStatus = { connectors: [] };
389 } finally {
390 this.hostBrowserStatusLoading = false;
391 }
392 },
393
394 hostBrowserConnectorLabel() {
395 const connectors = Array.isArray(this.hostBrowserStatus?.connectors)
396 ? this.hostBrowserStatus.connectors
397 : [];
398 const active = connectors.find((item) => item?.supported && item?.enabled);
399 if (active) {
400 const profile = active.profile_label ? ` - ${active.profile_label}` : "";
401 return `${hostBrowserFamilyLabel(active.browser_family)}${profile}: ${hostBrowserStatusLabel(active.status)}`;
402 }
403 const preparable = connectors.find((item) => item?.can_prepare || item?.supported);
404 if (preparable) return "A0 CLI connected - browser will open on first use";
405 if (connectors.length) return "A0 CLI connected - host browser unavailable";
406 return "Connect A0 CLI to use a host browser";
407 },
408
409 browserRuntimeStatusLabel() {
410 if (this.config?.runtime_backend !== "host_required") {
411 return "Docker browser runs inside Agent Zero; A0 CLI host-browser status does not affect it.";
412 }
413 const label = this.hostBrowserConnectorLabel();
414 if (label.startsWith("Connect A0 CLI") || label.includes("unavailable")) {
415 return `${label}. Switch Browser location to Internal Docker browser to browse without A0 CLI.`;
416 }
417 return label;
418 },
419
420 hasPaths() {
421 return this.pathCount() > 0;
422 },
423
424 pathCount() {
425 return normalizePathList(this.config?.extension_paths).length;
426 },
427
428 pathCountLabel() {
429 const count = this.pathCount();
430 if (!count) return "No extensions enabled";
431 return `${count} extension${count === 1 ? "" : "s"} enabled`;
432 },
433
434 extensionModeReady() {
435 return this.pathCount() > 0;
436 },
437
438 async loadExtensionsList() {
439 if (this.extensionsLoading) return;
440 this.extensionsLoading = true;
441 this.extensionsError = "";
442 try {
443 const response = await callJsonApi(BROWSER_EXTENSIONS_API, { action: "list" });
444 if (!response?.ok) {
445 throw new Error(response?.error || "Could not load browser extensions.");
446 }
447 this.applyExtensionPayload(response);
448 } catch (error) {
449 this.extensionsList = [];
450 this.extensionsError = error instanceof Error ? error.message : String(error);
451 } finally {
452 this.extensionsLoading = false;
453 }
454 },
455
456 applyExtensionPayload(response = {}) {
457 this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
458 if (Array.isArray(response.extension_paths) && this.config) {
459 this.config.extension_paths = normalizePathList(response.extension_paths);
460 }
461 },
462
463 extensionEnabled(extension) {
464 const path = typeof extension === "string" ? extension : extension?.path;
465 return normalizePathList(this.config?.extension_paths).includes(String(path || ""));
466 },
467
468 setExtensionEnabled(extension, enabled) {
469 const path = String((typeof extension === "string" ? extension : extension?.path) || "").trim();
470 if (!path) return;
471 const safeConfig = ensureConfig(this.config);
472 if (!safeConfig) return;
473 const paths = normalizePathList(safeConfig.extension_paths);
474 if (enabled && !paths.includes(path)) {
475 paths.push(path);
476 } else if (!enabled) {
477 const index = paths.indexOf(path);
478 if (index >= 0) paths.splice(index, 1);
479 }
480 safeConfig.extension_paths = paths;
481 },
482
483 extensionCanDelete(extension) {
484 return Boolean(extension?.can_delete);
485 },
486
487 extensionDeleteTitle(extension) {
488 return this.extensionCanDelete(extension)
489 ? "Delete extension"
490 : "Only Browser-managed extensions can be deleted";
491 },
492
493 async deleteExtension(extension) {
494 const path = String(extension?.path || "").trim();
495 if (!path) return;
496 this.extensionsError = "";
497 this.extensionsMessage = "";
498 if (!this.extensionCanDelete(extension)) {
499 this.extensionsError = "Only Browser-managed extensions can be deleted.";
500 return;
501 }
502 const name = String(extension?.name || "this extension").trim();
503 const safeName = name.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
504 const confirmed = await showConfirmDialog({
505 title: "Delete extension",
506 message: `Delete ${safeName}? This removes the extension folder from Browser.`,
507 confirmText: "Delete",
508 type: "danger",
509 });
510 if (!confirmed) return;
511
512 this.extensionDeleteLoadingPath = path;
513 try {
514 const response = await callJsonApi(BROWSER_EXTENSIONS_API, {
515 action: "uninstall_extension",
516 path,
517 });
518 if (!response?.ok) {
519 throw new Error(response?.error || "Could not delete extension.");
520 }
521 this.applyExtensionPayload(response);
522 this.extensionsMessage = `Deleted ${response.name || name}.`;
523 } catch (error) {
524 this.extensionsError = error instanceof Error ? error.message : String(error);
525 } finally {
526 this.extensionDeleteLoadingPath = "";
527 }
528 },
529
530 extensionVersionLabel(extension) {
531 const version = String(extension?.version || "").trim();
532 return version ? `v${version}` : "Unpacked extension";
533 },
534 });