Add UI asset bundler, service worker, tests

Introduce a UI asset bundler and delivery flow: adds helpers/ui_bundler.py to discover same-origin HTML/CSS/JS (<=40KiB), build a versioned JSON bundle, and serialize it for an authenticated /ui/asset-bundle endpoint (handlers added in helpers/ui_server.py with gzip and ETag support). Add webui/sw.js service-worker to accept a preload bundle, populate a per-version cache, serve bundled text entries immediately, and perform runtime caching/cleanup for eligible assets. Update webui/index.html to fetch the bundle asynchronously, send it to the worker, register the worker by bundle version, inline a non-blocking startup splash, and convert stylesheets to non-blocking preload loads. Expose extension readiness from webui/js/extensions.js (webui-extensions-loaded) so startup waits for Alpine and HTML extension loading. Add tests covering bundler behavior, service-worker expectations, and startup ordering.

frdel committed Jul 28, 2026 at 16:10 UTC 0329003b659bd6d51c64745c3d2a72f668cae641
12 files changed +1062 -32
helpers/ui_bundler.py new
+394
@@ -0,0 +1,394 @@
1 +from __future__ import annotations
2 +
3 +import hashlib
4 +from html.parser import HTMLParser
5 +import json
6 +from pathlib import Path
7 +import re
8 +from typing import TYPE_CHECKING, Iterable
9 +from urllib.parse import quote, unquote, urljoin, urlsplit
10 +
11 +from helpers import cache, files
12 +
13 +if TYPE_CHECKING:
14 + from agent import Agent
15 +
16 +
17 +_CACHE_AREA = "ui_asset_bundle(extensions)(plugins)"
18 +_CACHE_KEY = "webui"
19 +_LOCAL_ORIGIN = "https://agent-zero.local"
20 +_BUNDLE_POLICY_VERSION = "text-assets-v1-40k"
21 +_BUNDLE_SUFFIXES = {".css", ".htm", ".html", ".js", ".mjs", ".xhtml"}
22 +_HTML_SUFFIXES = {".htm", ".html", ".xhtml"}
23 +_MAX_BUNDLE_FILE_BYTES = 40 * 1024
24 +
25 +_CSS_REFERENCE_RES = (
26 + re.compile(r"url\(\s*(?:[\"'])?([^\"')\s]+)", re.IGNORECASE),
27 + re.compile(
28 + r"@import\s+(?:url\(\s*)?[\"']([^\"']+)[\"']",
29 + re.IGNORECASE,
30 + ),
31 +)
32 +_JS_REFERENCE_RES = (
33 + re.compile(
34 + r"(?:import|export)\s+(?:[^\"';]*?\s+from\s+)?[\"']([^\"']+)[\"']",
35 + re.MULTILINE,
36 + ),
37 + re.compile(r"\bimport\(\s*[\"']([^\"']+)[\"']\s*\)"),
38 + re.compile(r"\b(?:Worker|SharedWorker)\(\s*[\"']([^\"']+)[\"']"),
39 + re.compile(r"\bimportScripts\(\s*[\"']([^\"']+)[\"']"),
40 +)
41 +_QUOTED_ASSET_RE = re.compile(
42 + r'''["']((?:/|\./|\.\./)[^"'`$?]+\.[a-zA-Z0-9]{1,12}(?:\?[^"'`]*)?)["']'''
43 +)
44 +
45 +
46 +class _HtmlAssetReferences(HTMLParser):
47 + _URL_ATTRIBUTES = {
48 + "link": ("href",),
49 + "script": ("src",),
50 + }
51 +
52 + def __init__(self) -> None:
53 + super().__init__()
54 + self.references: list[str] = []
55 + self.component_references: list[str] = []
56 +
57 + def handle_starttag(
58 + self, tag: str, attrs: list[tuple[str, str | None]]
59 + ) -> None:
60 + attributes = dict(attrs)
61 + tag = tag.lower()
62 + if tag == "x-component":
63 + path = attributes.get("path")
64 + if path:
65 + self.component_references.append(path)
66 + for attribute in self._URL_ATTRIBUTES.get(tag, ()):
67 + value = attributes.get(attribute)
68 + if value:
69 + self.references.append(value)
70 +
71 + def handle_startendtag(
72 + self, tag: str, attrs: list[tuple[str, str | None]]
73 + ) -> None:
74 + self.handle_starttag(tag, attrs)
75 +
76 +
77 +class _AssetRoot:
78 + def __init__(self, path: Path, url_prefix: str) -> None:
79 + self.path = path.resolve()
80 + self.url_prefix = "/" + url_prefix.strip("/") if url_prefix != "/" else "/"
81 +
82 + def __hash__(self) -> int:
83 + return hash((self.path, self.url_prefix))
84 +
85 + def __eq__(self, other: object) -> bool:
86 + return (
87 + isinstance(other, _AssetRoot)
88 + and self.path == other.path
89 + and self.url_prefix == other.url_prefix
90 + )
91 +
92 +
93 +def get_ui_asset_bundle(agent: "Agent | None" = None) -> dict:
94 + """Build a versioned URL-to-content bundle for the site-wide browser cache."""
95 + roots, extension_roots, plugin_webui_roots = _get_asset_roots(agent)
96 + signature = _asset_signature(roots)
97 + cached = cache.get(_CACHE_AREA, _CACHE_KEY)
98 + if cached is not None and cached.get("signature") == signature:
99 + return cached["bundle"]
100 +
101 + pending: list[str] = []
102 + queued: set[str] = set()
103 + entries: dict[str, list[str]] = {}
104 + raw_entries: dict[str, bytes] = {}
105 +
106 + def enqueue(url: str) -> None:
107 + normalized = _normalize_url(url)
108 + suffix = Path(unquote(urlsplit(normalized).path)).suffix.lower() if normalized else ""
109 + if (
110 + normalized
111 + and suffix in _BUNDLE_SUFFIXES
112 + and normalized not in queued
113 + and urlsplit(normalized).path != "/sw.js"
114 + ):
115 + queued.add(normalized)
116 + pending.append(normalized)
117 +
118 + index_path = Path(files.get_abs_path("webui/index.html"))
119 + if index_path.is_file():
120 + index_text = index_path.read_text(encoding="utf-8")
121 + for reference in _extract_references(index_text, "/", ".html"):
122 + enqueue(reference)
123 +
124 + component_root = Path(files.get_abs_path("webui/components")).resolve()
125 + for path in _iter_root_files(component_root, suffixes=_HTML_SUFFIXES):
126 + url = _url_for_path(path, roots)
127 + if url:
128 + enqueue(url)
129 +
130 + for root in extension_roots:
131 + for path in _iter_root_files(root.path, suffixes=_BUNDLE_SUFFIXES):
132 + url = _url_for_path(path, roots)
133 + if url:
134 + enqueue(url)
135 +
136 + for root in plugin_webui_roots:
137 + for path in _iter_root_files(
138 + root.path,
139 + suffixes=_HTML_SUFFIXES,
140 + recursive=False,
141 + ):
142 + url = _url_for_path(path, roots)
143 + if url:
144 + enqueue(url)
145 +
146 + while pending:
147 + url = pending.pop()
148 + path = _path_for_url(url, roots)
149 + if path is None:
150 + continue
151 + try:
152 + content = path.read_bytes()
153 + except OSError:
154 + continue
155 +
156 + text = _decode_text(content)
157 + if text is None:
158 + continue
159 + if len(content) <= _MAX_BUNDLE_FILE_BYTES:
160 + entries[url] = [_content_type(path), "text", text]
161 + raw_entries[url] = content
162 + for reference in _extract_references(text, url, path.suffix.lower()):
163 + enqueue(reference)
164 +
165 + digest = hashlib.sha256()
166 + digest.update(signature.encode("ascii"))
167 + digest.update(b"\0")
168 + for url in sorted(raw_entries):
169 + digest.update(url.encode("utf-8"))
170 + digest.update(b"\0")
171 + digest.update(raw_entries[url])
172 + digest.update(b"\0")
173 +
174 + result = {
175 + "version": digest.hexdigest()[:20],
176 + "files": {url: entries[url] for url in sorted(entries)},
177 + }
178 + cache.add(
179 + _CACHE_AREA,
180 + _CACHE_KEY,
181 + {"signature": signature, "bundle": result},
182 + )
183 + return result
184 +
185 +
186 +def serialize_ui_asset_bundle(bundle: dict) -> str:
187 + """Serialize a UI asset bundle for its JSON endpoint."""
188 + return json.dumps(
189 + bundle,
190 + ensure_ascii=False,
191 + separators=(",", ":"),
192 + )
193 +
194 +
195 +def _get_asset_roots(
196 + agent: "Agent | None",
197 +) -> tuple[list[_AssetRoot], list[_AssetRoot], list[_AssetRoot]]:
198 + from helpers import plugins, subagents
199 +
200 + webui_root = _AssetRoot(Path(files.get_abs_path("webui")), "/")
201 + extension_roots: list[_AssetRoot] = []
202 + plugin_webui_roots: list[_AssetRoot] = []
203 +
204 + for path in subagents.get_paths(agent, "extensions/webui"):
205 + root_path = Path(path).resolve()
206 + if not root_path.is_dir() or not files.is_in_base_dir(str(root_path)):
207 + continue
208 + relative = files.deabsolute_path(str(root_path)).replace("\\", "/")
209 + extension_roots.append(_AssetRoot(root_path, f"/{relative}"))
210 +
211 + for path in plugins.get_enabled_plugin_paths(agent, "webui"):
212 + root_path = Path(path).resolve()
213 + if not root_path.is_dir() or not files.is_in_base_dir(str(root_path)):
214 + continue
215 + relative = files.deabsolute_path(str(root_path)).replace("\\", "/")
216 + plugin_webui_roots.append(_AssetRoot(root_path, f"/{relative}"))
217 +
218 + extension_roots = list(dict.fromkeys(extension_roots))
219 + plugin_webui_roots = list(dict.fromkeys(plugin_webui_roots))
220 + roots = list(dict.fromkeys([*extension_roots, *plugin_webui_roots, webui_root]))
221 + roots.sort(key=lambda root: len(root.url_prefix), reverse=True)
222 + return roots, extension_roots, plugin_webui_roots
223 +
224 +
225 +def _iter_root_files(
226 + root: Path,
227 + suffixes: set[str] | None = None,
228 + recursive: bool = True,
229 +) -> Iterable[Path]:
230 + if not root.is_dir():
231 + return
232 + candidates = root.rglob("*") if recursive else root.glob("*")
233 + for path in sorted(candidates, key=lambda item: item.as_posix()):
234 + if not path.is_file() or (suffixes and path.suffix.lower() not in suffixes):
235 + continue
236 + resolved = path.resolve()
237 + try:
238 + resolved.relative_to(root)
239 + except ValueError:
240 + continue
241 + yield resolved
242 +
243 +
244 +def _asset_signature(roots: list[_AssetRoot]) -> str:
245 + digest = hashlib.sha256()
246 + digest.update(_BUNDLE_POLICY_VERSION.encode("ascii"))
247 + digest.update(b"\0")
248 + for root in roots:
249 + digest.update(root.url_prefix.encode("utf-8"))
250 + digest.update(b"\0")
251 + for path in _iter_root_files(root.path, suffixes=_BUNDLE_SUFFIXES):
252 + try:
253 + stat = path.stat()
254 + relative = path.relative_to(root.path).as_posix()
255 + except (OSError, ValueError):
256 + continue
257 + digest.update(relative.encode("utf-8"))
258 + digest.update(b"\0")
259 + digest.update(str(stat.st_mtime_ns).encode("ascii"))
260 + digest.update(b":")
261 + digest.update(str(stat.st_size).encode("ascii"))
262 + digest.update(b"\0")
263 + return digest.hexdigest()
264 +
265 +
266 +def _url_for_path(path: Path, roots: list[_AssetRoot]) -> str | None:
267 + resolved = path.resolve()
268 + for root in roots:
269 + try:
270 + relative = resolved.relative_to(root.path).as_posix()
271 + except ValueError:
272 + continue
273 + prefix = "" if root.url_prefix == "/" else root.url_prefix
274 + return f"{prefix}/{quote(relative, safe='/-._~')}"
275 + return None
276 +
277 +
278 +def _path_for_url(url: str, roots: list[_AssetRoot]) -> Path | None:
279 + url_path = unquote(urlsplit(url).path)
280 + for root in roots:
281 + prefix = root.url_prefix
282 + if prefix == "/":
283 + relative = url_path.lstrip("/")
284 + elif url_path.startswith(prefix + "/"):
285 + relative = url_path[len(prefix) + 1 :]
286 + else:
287 + continue
288 + candidate = (root.path / relative).resolve()
289 + try:
290 + candidate.relative_to(root.path)
291 + except ValueError:
292 + continue
293 + if candidate.is_file():
294 + return candidate
295 + return None
296 +
297 +
298 +def _extract_references(text: str, base_url: str, suffix: str) -> list[str]:
299 + references: list[str] = []
300 + suffix = suffix.lower()
301 +
302 + if suffix in {".html", ".htm", ".xhtml"}:
303 + parser = _HtmlAssetReferences()
304 + parser.feed(text)
305 + references.extend(
306 + resolved
307 + for reference in parser.references
308 + if (resolved := _resolve_reference(reference, base_url))
309 + )
310 + references.extend(
311 + resolved
312 + for reference in parser.component_references
313 + if (resolved := _resolve_component_reference(reference))
314 + )
315 + references.extend(_extract_css_references(text, base_url))
316 + references.extend(_extract_js_references(text, base_url))
317 + elif suffix == ".css":
318 + references.extend(_extract_css_references(text, base_url))
319 + elif suffix in {".js", ".mjs"}:
320 + references.extend(_extract_js_references(text, base_url))
321 + if suffix in {".html", ".htm", ".xhtml", ".js", ".mjs"}:
322 + references.extend(
323 + resolved
324 + for reference in _QUOTED_ASSET_RE.findall(text)
325 + if (resolved := _resolve_reference(reference, base_url))
326 + )
327 +
328 + return references
329 +
330 +
331 +def _extract_css_references(text: str, base_url: str) -> list[str]:
332 + references: list[str] = []
333 + for pattern in _CSS_REFERENCE_RES:
334 + references.extend(
335 + resolved
336 + for reference in pattern.findall(text)
337 + if (resolved := _resolve_reference(reference, base_url))
338 + )
339 + return references
340 +
341 +
342 +def _extract_js_references(text: str, base_url: str) -> list[str]:
343 + references: list[str] = []
344 + for pattern in _JS_REFERENCE_RES:
345 + references.extend(
346 + resolved
347 + for reference in pattern.findall(text)
348 + if (resolved := _resolve_reference(reference, base_url))
349 + )
350 + return references
351 +
352 +
353 +def _resolve_component_reference(reference: str) -> str | None:
354 + if reference.startswith("/"):
355 + return _normalize_url(reference)
356 + if reference.startswith("components/"):
357 + return _normalize_url(f"/{reference}")
358 + return _normalize_url(f"/components/{reference}")
359 +
360 +
361 +def _resolve_reference(reference: str, base_url: str) -> str | None:
362 + reference = reference.strip()
363 + if not reference or reference.startswith(("#", "data:", "blob:", "javascript:")):
364 + return None
365 + absolute = urljoin(f"{_LOCAL_ORIGIN}{base_url}", reference)
366 + parsed = urlsplit(absolute)
367 + if f"{parsed.scheme}://{parsed.netloc}" != _LOCAL_ORIGIN:
368 + return None
369 + query = f"?{parsed.query}" if parsed.query else ""
370 + return _normalize_url(f"{parsed.path}{query}")
371 +
372 +
373 +def _normalize_url(url: str) -> str | None:
374 + parsed = urlsplit(url)
375 + if parsed.scheme or parsed.netloc or not parsed.path.startswith("/"):
376 + return None
377 + query = f"?{parsed.query}" if parsed.query else ""
378 + return f"{quote(unquote(parsed.path), safe='/-._~')}{query}"
379 +
380 +
381 +def _content_type(path: Path) -> str:
382 + suffix = path.suffix.lower()
383 + if suffix == ".css":
384 + return "text/css; charset=utf-8"
385 + if suffix in {".js", ".mjs"}:
386 + return "text/javascript; charset=utf-8"
387 + return "text/html; charset=utf-8"
388 +
389 +
390 +def _decode_text(content: bytes) -> str | None:
391 + try:
392 + return content.decode("utf-8")
393 + except UnicodeDecodeError:
394 + return None
helpers/ui_bundler.py.dox.md new
+38
@@ -0,0 +1,38 @@
1 +# ui_bundler.py DOX
2 +
3 +## Purpose
4 +
5 +- Build the versioned static-asset payload used to prefill the WebUI service-worker cache.
6 +- Keep component and extension loaders independent from transport-level caching.
7 +
8 +## Ownership
9 +
10 +- `ui_bundler.py` owns same-origin text-asset discovery, recursive reference scanning, safe URL-to-file resolution, bundle hashing, and JSON serialization.
11 +- `ui_bundler.py.dox.md` owns the durable contracts for that behavior.
12 +- Top-level functions:
13 + - `get_ui_asset_bundle(agent: Agent | None = None) -> dict`
14 + - `serialize_ui_asset_bundle(bundle: dict) -> str`
15 +
16 +## Runtime Contracts
17 +
18 +- The bundle seeds core component HTML, eligible HTML/CSS/JavaScript under enabled extension roots, top-level enabled-plugin HTML entry pages, and recursively referenced HTML/CSS/JavaScript.
19 +- Asset URLs resolve only inside the WebUI static root or an enabled WebUI extension/plugin root; external URLs, traversal, and symlink escapes are excluded.
20 +- Only valid UTF-8 HTML, CSS, and JavaScript files no larger than 40 KiB are embedded. Oversized eligible text is still scanned for referenced dependencies, while images, audio, video, fonts, manifests, and other file types remain under normal browser HTTP caching.
21 +- Every entry carries the response content type required by browsers and native ES modules.
22 +- Bundle versions combine the cache-policy version, bundled content, and an HTML/CSS/JavaScript path/mtime/size inventory. Policy changes and changes to both preloaded and runtime-cached eligible files therefore advance the service-worker cache version, and activation removes caches created under the old policy. The helper cache area also participates in normal extension/plugin invalidation.
23 +- Serialized JSON is returned by the authenticated `/ui/asset-bundle` endpoint rather than inserted into `index.html`.
24 +
25 +## Work Guidance
26 +
27 +- Add reference extractors here rather than teaching component or extension loaders about preloading.
28 +- Keep API, WebSocket, navigation, authentication, and other dynamic responses outside the asset bundle.
29 +- Preserve URL identity so native module imports, component fetches, and stylesheet resolution use normal browser semantics.
30 +
31 +## Verification
32 +
33 +- Run `tests/test_ui_bundler.py` and the WebUI service-worker/startup tests.
34 +- Smoke-test a cold first load and a controlled reload; verify ordinary asset URLs remain visible while their responses come from the service-worker cache after activation.
35 +
36 +## Child DOX Index
37 +
38 +No child DOX files.
helpers/ui_server.py
+34
@@ -1,6 +1,7 @@
1 from dataclasses import dataclass, field
2 from datetime import timedelta
3 import asyncio
4 +import gzip
5 import json
6 import logging
7 import os
@@ -32,6 +33,7 @@ from helpers.extension import extensible
33 from helpers.files import get_abs_path
34 from helpers.print_style import PrintStyle
35 from helpers.server_startup import StartupMonitor
36 +from helpers.ui_bundler import get_ui_asset_bundle, serialize_ui_asset_bundle
37 from helpers import settings as settings_helper
38 from helpers.ws import register_ws_namespace, validate_ws_origin
39 from helpers.ws_manager import WsManager, set_shared_ws_manager
@@ -154,6 +156,12 @@ class UiServerRuntime:
156 handlers.serve_index,
157 methods=["GET"],
158 )
159 + self.webapp.add_url_rule(
160 + "/ui/asset-bundle",
161 + "serve_ui_asset_bundle",
162 + handlers.serve_ui_asset_bundle,
163 + methods=["GET"],
164 + )
165 self.webapp.add_url_rule(
166 "/plugins/<plugin_name>/<path:asset_path>",
167 "serve_builtin_plugin_asset",
@@ -284,6 +292,32 @@ class UiRouteHandlers:
292 user_ui_control_visibility=user_ui_control_visibility,
293 )
294
295 + @requires_auth
296 + async def serve_ui_asset_bundle(self):
297 + try:
298 + bundle = get_ui_asset_bundle(agent=None)
299 + payload = serialize_ui_asset_bundle(bundle).encode("utf-8")
300 + use_gzip = request.accept_encodings["gzip"] > 0
301 + response = Response(
302 + gzip.compress(payload) if use_gzip else payload,
303 + content_type="application/json; charset=utf-8",
304 + )
305 + if use_gzip:
306 + response.headers["Content-Encoding"] = "gzip"
307 + response.headers["Vary"] = "Accept-Encoding"
308 + response.set_etag(bundle["version"], weak=True)
309 + response.cache_control.private = True
310 + response.cache_control.no_cache = True
311 + return response.make_conditional(request)
312 + except Exception as error:
313 + PrintStyle.warning(f"Unable to build WebUI asset bundle: {error}")
314 + return Response(
315 + '{"error":"WebUI asset bundle unavailable"}',
316 + status=503,
317 + content_type="application/json; charset=utf-8",
318 + headers={"Cache-Control": "no-store"},
319 + )
320 +
321 @requires_auth
322 async def serve_builtin_plugin_asset(self, plugin_name, asset_path):
323 return await self._serve_plugin_asset(plugin_name, asset_path)
helpers/ui_server.py.dox.md
+2
@@ -22,6 +22,7 @@
22 - `async login_handler(self)`
23 - `async logout_handler(self)`
24 - `async serve_index(self)`
25 + - `async serve_ui_asset_bundle(self)`
26 - `async serve_builtin_plugin_asset(self, plugin_name, asset_path)`
27 - `async serve_plugin_asset(self, plugin_name, asset_path)`
28 - `async serve_extension_asset(self, asset_path)`
@@ -42,6 +43,7 @@
43
44 - Important called helpers/classes observed in the source: `logging.getLogger.setLevel`, `Localization.get.apply_process_timezone`, `_positive_int_env`, `field`, `Flask`, `threading.RLock`, `socketio.AsyncServer`, `WsManager`, `set_shared_ws_manager`, `cls`, `server_runtime.refresh_runtime_settings`, `settings_helper.get_settings`, `settings_helper.set_runtime_settings_snapshot`, `self.ws_manager.set_server_restart_broadcast`, `UiRouteHandlers`, `self.webapp.add_url_rule`, `register_api_route`, `register_ws_namespace`, `files.read_file`, `render_template_string`, `session.pop`.
45 - `serve_index()` bootstraps the normalized UI control visibility map alongside timezone and time-format preferences so controls render correctly before Settings is opened.
46 +- `serve_index()` returns the lightweight UI shell without waiting for bundle construction. The authenticated `serve_ui_asset_bundle()` endpoint builds the versioned payload asynchronously from the browser's perspective, supports gzip transfer and ETag revalidation, and keeps component, extension, and Alpine lifecycles unchanged.
47 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
48
49 ## Work Guidance
tests/test_ui_bundler.py new
+177
@@ -0,0 +1,177 @@
1 +from pathlib import Path
2 +import sys
3 +from types import ModuleType
4 +
5 +import helpers
6 +from helpers import cache, files, ui_bundler
7 +
8 +
9 +def configure_roots(
10 + tmp_path: Path,
11 + monkeypatch,
12 + extension_root: Path,
13 + plugin_root: Path,
14 +) -> None:
15 + stored: dict[tuple[str, str], dict] = {}
16 + monkeypatch.setattr(cache, "get", lambda area, key: stored.get((area, key)))
17 + monkeypatch.setattr(
18 + cache,
19 + "add",
20 + lambda area, key, value: stored.__setitem__((area, key), value),
21 + )
22 + monkeypatch.setattr(
23 + files,
24 + "get_abs_path",
25 + lambda *parts: str(tmp_path.joinpath(*parts)),
26 + )
27 + monkeypatch.setattr(
28 + files,
29 + "is_in_base_dir",
30 + lambda path: Path(path).resolve().is_relative_to(tmp_path),
31 + )
32 + monkeypatch.setattr(
33 + files,
34 + "deabsolute_path",
35 + lambda path: Path(path).resolve().relative_to(tmp_path).as_posix(),
36 + )
37 + subagents_module = ModuleType("helpers.subagents")
38 + subagents_module.get_paths = lambda *_args, **_kwargs: [str(extension_root)]
39 + plugins_module = ModuleType("helpers.plugins")
40 + plugins_module.get_enabled_plugin_paths = lambda *_args, **_kwargs: [str(plugin_root)]
41 + monkeypatch.setitem(sys.modules, "helpers.subagents", subagents_module)
42 + monkeypatch.setitem(sys.modules, "helpers.plugins", plugins_module)
43 + monkeypatch.setattr(helpers, "subagents", subagents_module, raising=False)
44 + monkeypatch.setattr(helpers, "plugins", plugins_module, raising=False)
45 +
46 +
47 +def test_bundle_discovers_assets_without_changing_frontend_loaders(
48 + tmp_path: Path, monkeypatch
49 +) -> None:
50 + webui = tmp_path / "webui"
51 + components = webui / "components"
52 + extension_root = tmp_path / "extensions" / "webui"
53 + plugin_root = tmp_path / "plugins" / "example" / "webui"
54 +
55 + assets = {
56 + webui / "index.html": (
57 + '<link rel="stylesheet" href="/index.css">'
58 + '<script type="module" src="/index.js"></script>'
59 + '<script type="module" src="/large.js"></script>'
60 + ).encode(),
61 + webui / "index.css": (
62 + b'@import url("./theme.css");'
63 + b'@font-face { src: url("/public/font.bin"); }'
64 + ),
65 + webui / "theme.css": b"body { color: black; }",
66 + webui / "index.js": b'import "./js/module.js";',
67 + webui / "large.js": (
68 + b'import "./js/large-child.js";' + b" " * (40 * 1024)
69 + ),
70 + webui / "js" / "large-child.js": b"export const child = true;",
71 + webui / "js" / "module.js": b'export { value } from "./value.js";',
72 + webui / "js" / "value.js": b"export const value = 1;",
73 + webui / "public" / "font.bin": b"\xff\x00\x81",
74 + components / "root.html": (
75 + '<x-component path="nested/child.html"></x-component>'
76 + '<img src="/public/image.png">'
77 + '<audio src="/public/audio.mp3"></audio>'
78 + '<video src="/public/video.mp4"></video>'
79 + ).encode(),
80 + components / "nested" / "child.html": b"<div>child</div>",
81 + webui / "public" / "image.png": b"image",
82 + webui / "public" / "audio.mp3": b"audio",
83 + webui / "public" / "video.mp4": b"video",
84 + extension_root / "sidebar" / "entry.html": (
85 + '<script type="module" src="/plugins/example/webui/feature.js"></script>'
86 + ).encode(),
87 + extension_root / "sidebar" / "entry.css": (
88 + b'@import "/plugins/example/webui/feature.css";'
89 + ),
90 + plugin_root / "main.html": (
91 + '<x-component path="/plugins/example/webui/nested/modal.html"></x-component>'
92 + ).encode(),
93 + plugin_root / "feature.js": b'import "./nested/helper.js";',
94 + plugin_root / "feature.css": b'body { background: url("./icon.svg"); }',
95 + plugin_root / "icon.svg": b"<svg></svg>",
96 + plugin_root / "nested" / "helper.js": b"export default true;",
97 + plugin_root / "nested" / "modal.html": b"<dialog>plugin</dialog>",
98 + plugin_root / "nested" / "runtime-only.css": b"runtime-one",
99 + }
100 + for path, content in assets.items():
101 + path.parent.mkdir(parents=True, exist_ok=True)
102 + path.write_bytes(content)
103 +
104 + configure_roots(tmp_path, monkeypatch, extension_root, plugin_root)
105 + bundle = ui_bundler.get_ui_asset_bundle(agent=None)
106 +
107 + assert bundle["version"]
108 + assert set(bundle["files"]) == {
109 + "/components/nested/child.html",
110 + "/components/root.html",
111 + "/extensions/webui/sidebar/entry.css",
112 + "/extensions/webui/sidebar/entry.html",
113 + "/index.css",
114 + "/index.js",
115 + "/js/large-child.js",
116 + "/js/module.js",
117 + "/js/value.js",
118 + "/plugins/example/webui/feature.css",
119 + "/plugins/example/webui/feature.js",
120 + "/plugins/example/webui/main.html",
121 + "/plugins/example/webui/nested/helper.js",
122 + "/plugins/example/webui/nested/modal.html",
123 + "/theme.css",
124 + }
125 + assert bundle["files"]["/index.js"][:2] == [
126 + "text/javascript; charset=utf-8",
127 + "text",
128 + ]
129 + assert "/large.js" not in bundle["files"]
130 + assert "/public/font.bin" not in bundle["files"]
131 + assert "/public/image.png" not in bundle["files"]
132 + assert "/public/audio.mp3" not in bundle["files"]
133 + assert "/public/video.mp4" not in bundle["files"]
134 + assert "/plugins/example/webui/icon.svg" not in bundle["files"]
135 + assert all(entry[1] == "text" for entry in bundle["files"].values())
136 + assert all(
137 + len(entry[2].encode("utf-8")) <= 40 * 1024
138 + for entry in bundle["files"].values()
139 + )
140 +
141 + previous_version = bundle["version"]
142 + assets[webui / "js" / "value.js"] = b"export const value = 22;"
143 + (webui / "js" / "value.js").write_bytes(assets[webui / "js" / "value.js"])
144 + rebuilt = ui_bundler.get_ui_asset_bundle(agent=None)
145 + assert rebuilt["version"] != previous_version
146 + assert rebuilt["files"]["/js/value.js"][2] == "export const value = 22;"
147 +
148 + selected_version = rebuilt["version"]
149 + runtime_only = plugin_root / "nested" / "runtime-only.css"
150 + runtime_only.write_bytes(b"runtime-two-is-newer")
151 + runtime_rebuilt = ui_bundler.get_ui_asset_bundle(agent=None)
152 + assert runtime_rebuilt["version"] != selected_version
153 + assert "/plugins/example/webui/nested/runtime-only.css" not in runtime_rebuilt["files"]
154 +
155 +
156 +def test_bundle_excludes_symlink_escapes_and_serializes_json(
157 + tmp_path: Path, monkeypatch
158 +) -> None:
159 + webui = tmp_path / "webui"
160 + component_root = webui / "components"
161 + extension_root = tmp_path / "extensions" / "webui"
162 + plugin_root = tmp_path / "plugins" / "example" / "webui"
163 + for root in (component_root, extension_root, plugin_root):
164 + root.mkdir(parents=True)
165 + (webui / "index.html").write_text("<main></main>", encoding="utf-8")
166 + (component_root / "safe.html").write_text("</script>&", encoding="utf-8")
167 + outside = tmp_path / "outside.html"
168 + outside.write_text("outside", encoding="utf-8")
169 + (component_root / "escape.html").symlink_to(outside)
170 +
171 + configure_roots(tmp_path, monkeypatch, extension_root, plugin_root)
172 + bundle = ui_bundler.get_ui_asset_bundle(agent=None)
173 + payload = ui_bundler.serialize_ui_asset_bundle(bundle)
174 +
175 + assert "/components/safe.html" in payload
176 + assert "/components/escape.html" not in payload
177 + assert "</script>&" in payload
tests/test_webui_service_worker.py new
+46
@@ -0,0 +1,46 @@
1 +from pathlib import Path
2 +
3 +
4 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
5 +
6 +
7 +def read(*parts: str) -> str:
8 + return PROJECT_ROOT.joinpath(*parts).read_text(encoding="utf-8")
9 +
10 +
11 +def test_root_service_worker_caches_only_small_text_assets() -> None:
12 + worker = read("webui", "sw.js")
13 +
14 + assert 'event.data?.type !== "preload-ui-bundle"' in worker
15 + assert "await cache.put(request, response);" in worker
16 + assert "activeBundleEntries.get(event.request.url)" in worker
17 + assert "responseFromEntry(bundledEntry)" in worker
18 + assert "const cached = await cache.match(event.request);" in worker
19 + assert "const MAX_CACHEABLE_FILE_BYTES = 40 * 1024;" in worker
20 + assert "CACHEABLE_FILE_PATTERN" in worker
21 + assert "cacheRuntimeResponse(" in worker
22 + assert 'entry[1] === "text"' in worker
23 + assert "decodeBase64" not in worker
24 + assert "body.byteLength > MAX_CACHEABLE_FILE_BYTES" in worker
25 + assert "cacheMarkerRequest(targetCacheName)" in worker
26 + assert "cleanupCaches(activeCacheName)" in worker
27 + assert 'url.pathname.startsWith("/api/")' in worker
28 + assert 'request.mode === "navigate"' in worker
29 +
30 +
31 +def test_index_registers_root_cache_without_frontend_loader_hooks() -> None:
32 + index = read("webui", "index.html")
33 + components = read("webui", "js", "components.js")
34 + extensions = read("webui", "js", "extensions.js")
35 + init_fw = read("webui", "js", "initFw.js")
36 +
37 + assert 'fetch("/ui/asset-bundle"' in index
38 + assert 'id="ui-asset-bundle"' not in index
39 + assert 'navigator.serviceWorker.register(' in index
40 + assert '{ scope: "/", updateViaCache: "none" }' in index
41 + assert "preload-ui-bundle" in index
42 + assert 'new Event("webui-bundle-loaded")' in index
43 + assert "webuiComponentCache" not in components
44 + assert "preload-ui-bundle" not in components
45 + assert "preload-ui-bundle" not in extensions
46 + assert "preload-ui-bundle" not in init_fw
tests/test_webui_startup_assets.py
+47
@@ -38,3 +38,50 @@ def test_classic_startup_scripts_are_deferred() -> None:
38 ]
39
40 assert blocking_scripts == []
41 +
42 +
43 +def test_ui_asset_bundle_fetch_precedes_frontend_assets() -> None:
44 + index_html = (PROJECT_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
45 + ui_server = (PROJECT_ROOT / "helpers" / "ui_server.py").read_text(encoding="utf-8")
46 +
47 + bundle_index = index_html.index('fetch("/ui/asset-bundle"')
48 + assert bundle_index < index_html.index('<link rel="preload" as="style" href="index.css"')
49 + assert bundle_index < index_html.index('<script type="module" src="index.js"></script>')
50 + assert 'id="ui-asset-bundle"' not in index_html
51 + assert '"/ui/asset-bundle"' in ui_server
52 + assert "handlers.serve_ui_asset_bundle" in ui_server
53 + assert 'response.headers["Content-Encoding"] = "gzip"' in ui_server
54 + assert 'response.set_etag(bundle["version"], weak=True)' in ui_server
55 +
56 +
57 +def test_initial_styles_load_without_blocking_splash_paint() -> None:
58 + index_html = (PROJECT_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
59 +
60 + assert '<link rel="stylesheet"' not in index_html
61 + assert index_html.count('rel="preload" as="style"') == 18
62 + assert index_html.count("onload=\"this.onload=null;this.rel='stylesheet'\"") == 18
63 + assert 'new Promise((resolve) => addEventListener("load", resolve' in index_html
64 + assert 'document.addEventListener("webui-bundle-loaded"' in index_html
65 +
66 +
67 +def test_startup_splash_is_inline_and_waits_for_extension_readiness() -> None:
68 + index_html = (PROJECT_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
69 + extensions_js = (PROJECT_ROOT / "webui" / "js" / "extensions.js").read_text(
70 + encoding="utf-8"
71 + )
72 +
73 + assert index_html.index("#startup-splash") < index_html.index('fetch("/ui/asset-bundle"')
74 + assert index_html.index('id="startup-splash"') < index_html.index('<div class="container">')
75 + assert 'data-splash-theme="dark"' in index_html
76 + assert 'localStorage.getItem("darkMode") === "false"' in index_html
77 + assert 'src="/public/a0-fullDark.svg"' in index_html
78 + assert "width: clamp(12rem, 34vw, 23rem)" in index_html
79 + assert 'document.addEventListener("webui-extensions-loaded"' in index_html
80 + assert 'export let initialHtmlExtensionsLoaded = false' in extensions_js
81 + assert 'const LOADING_SELECTOR = "x-component > .loading:empty, x-extension.loading"' in extensions_js
82 + assert 'targetElement.classList.add("loading")' in extensions_js
83 + assert 'targetElement.classList.remove("loading")' in extensions_js
84 + assert 'document.dispatchEvent(new Event("webui-extensions-loaded"))' in extensions_js
85 + assert "globalThis.Alpine.nextTick" in extensions_js
86 + assert "pendingHtmlImports" not in extensions_js
87 + assert "data-extension-loaded" not in extensions_js
webui/AGENTS.md
+2
@@ -22,6 +22,8 @@
22 - Use `/js/api.js` helpers so CSRF and auth behavior stays consistent.
23 - Component tags use `<x-component path="...">`; paths are resolved under `webui/components/` when not already prefixed.
24 - Frontend extension breakpoints use `<x-extension id="...">` and are loaded through `/js/extensions.js`.
25 +- `sw.js` owns same-origin HTML/CSS/JavaScript caching for files no larger than 40 KiB. The index fetches its versioned preload payload asynchronously from `/ui/asset-bundle`; the worker serves that payload immediately, persists it once per version, and removes obsolete version caches while component, extension, and Alpine loaders remain transport-agnostic. Media, images, fonts, manifests, and oversized text assets use normal browser HTTP caching.
26 +- The startup splash has inline critical styling in `index.html`, applies the persisted light/dark preference before first paint, and leaves only after the static page load, asynchronous bundle fetch, and initial component/extension tree report readiness. Initial stylesheets must remain non-render-blocking so they cannot delay the first splash paint.
27 - Component HTML loaded by the shared loader may include `<title>`, module scripts, body content, and scoped styles; modal content uses the same loader path.
28 - Do not bypass WebSocket origin/auth/CSRF assumptions from frontend code.
29 - Avoid editing vendored files unless intentionally updating the vendor asset.
webui/index.html
+115 -32
@@ -1,28 +1,118 @@
1 <!DOCTYPE html>
2 -<html lang="en">
2 +<html lang="en" data-splash-theme="dark">
3
4 <head>
5 <meta charset="UTF-8">
6 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
7 <title>Agent Zero</title>
8 +
9 + <script>
10 + try {
11 + if (localStorage.getItem("darkMode") === "false") document.documentElement.dataset.splashTheme = "light"
12 + } catch (_) {}
13 + Promise.all([
14 + new Promise((resolve) => addEventListener("load", resolve, { once: true })),
15 + new Promise((resolve) => document.addEventListener("webui-bundle-loaded", resolve, { once: true })),
16 + new Promise((resolve) => document.addEventListener("webui-extensions-loaded", resolve, { once: true })),
17 + ]).then(() => {
18 + const splash = document.getElementById("startup-splash")
19 + if (!splash) return
20 + splash.addEventListener("transitionend", () => splash.remove(), { once: true })
21 + splash.classList.add("startup-splash-loaded")
22 + document.documentElement.removeAttribute("data-splash-theme")
23 + })
24 + </script>
25 + <style>
26 + html { background: #131313; }
27 + html[data-splash-theme="light"] { background: #fafafa; }
28 + #startup-splash {
29 + position: fixed; inset: 0; z-index: 2147483647;
30 + display: grid; place-items: center;
31 + background: #131313;
32 + transition: opacity .32s ease, visibility 0s linear .32s;
33 + }
34 + html[data-splash-theme="light"] #startup-splash { background: #fafafa; }
35 + #startup-splash img {
36 + width: clamp(12rem, 34vw, 23rem); max-width: 72vw; height: auto;
37 + opacity: 0; filter: brightness(0) invert(1);
38 + animation: startup-logo-in .55s ease-out .05s forwards;
39 + }
40 + html[data-splash-theme="light"] #startup-splash img { filter: brightness(0); }
41 + #startup-splash.startup-splash-loaded {
42 + opacity: 0; visibility: hidden; pointer-events: none;
43 + }
44 + @keyframes startup-logo-in { to { opacity: 1; } }
45 + </style>
46 + <link rel="preload" href="/public/a0-fullDark.svg" as="image" type="image/svg+xml">
47 +
48 + <script>
49 + (async () => {
50 + let bundle
51 + try {
52 + const response = await fetch("/ui/asset-bundle", { credentials: "same-origin" })
53 + if (!response.ok) throw new Error(`Bundle request failed: ${response.status}`)
54 + bundle = await response.json()
55 + } catch (error) {
56 + console.warn("WebUI asset bundle unavailable.", error)
57 + return
58 + } finally {
59 + document.dispatchEvent(new Event("webui-bundle-loaded"))
60 + }
61 +
62 + if (!("serviceWorker" in navigator) || !bundle?.version || !bundle.files) return
63 + try {
64 + const sentWorkers = new WeakSet()
65 + const sendBundle = (worker) => {
66 + if (!worker || sentWorkers.has(worker)) return
67 + sentWorkers.add(worker)
68 + worker.postMessage({ type: "preload-ui-bundle", bundle })
69 + }
70 +
71 + sendBundle(navigator.serviceWorker.controller)
72 + const onControllerChange = () => {
73 + sendBundle(navigator.serviceWorker.controller)
74 + navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange)
75 + }
76 + navigator.serviceWorker.addEventListener("controllerchange", onControllerChange)
77 +
78 + const registrations = await navigator.serviceWorker.getRegistrations()
79 + await Promise.all(
80 + registrations
81 + .filter((registration) => new URL(registration.scope).pathname === "/js/")
82 + .map((registration) => registration.unregister()),
83 + )
84 + const registration = await navigator.serviceWorker.register(
85 + `/sw.js?version=${encodeURIComponent(bundle.version)}`,
86 + { scope: "/", updateViaCache: "none" },
87 + )
88 + sendBundle(registration.installing)
89 + sendBundle(registration.waiting)
90 + sendBundle(registration.active)
91 + sendBundle((await navigator.serviceWorker.ready).active)
92 + } catch (error) {
93 + console.warn("WebUI asset cache unavailable.", error)
94 + }
95 + })()
96 + </script>
97 +
98 <link rel="icon" type="image/svg+xml" href="public/favicon.svg">
9 - <link rel="stylesheet" href="index.css">
10 - <link rel="stylesheet" href="css/messages.css">
11 - <link rel="stylesheet" href="components/messages/action-buttons/simple-action-buttons.css">
12 - <link rel="stylesheet" href="components/messages/process-group/process-group.css">
13 - <link rel="stylesheet" href="css/toast.css">
14 - <link rel="stylesheet" href="css/settings.css">
15 - <link rel="stylesheet" href="css/modals.css">
16 - <link rel="stylesheet" href="css/surfaces.css">
17 - <link rel="stylesheet" href="css/scheduler-datepicker.css">
18 - <link rel="stylesheet" href="css/scheduler.css">
19 - <link rel="stylesheet" href="css/notification.css">
20 - <link rel="stylesheet" href="css/buttons.css">
21 - <link rel="stylesheet" href="css/tables.css">
22 - <link rel="stylesheet" href="components/canvas/right-canvas.css">
99 + <link rel="preload" as="style" href="index.css" onload="this.onload=null;this.rel='stylesheet'">
100 + <link rel="preload" as="style" href="css/messages.css" onload="this.onload=null;this.rel='stylesheet'">
101 + <link rel="preload" as="style" href="components/messages/action-buttons/simple-action-buttons.css" onload="this.onload=null;this.rel='stylesheet'">
102 + <link rel="preload" as="style" href="components/messages/process-group/process-group.css" onload="this.onload=null;this.rel='stylesheet'">
103 + <link rel="preload" as="style" href="css/toast.css" onload="this.onload=null;this.rel='stylesheet'">
104 + <link rel="preload" as="style" href="css/settings.css" onload="this.onload=null;this.rel='stylesheet'">
105 + <link rel="preload" as="style" href="css/modals.css" onload="this.onload=null;this.rel='stylesheet'">
106 + <link rel="preload" as="style" href="css/surfaces.css" onload="this.onload=null;this.rel='stylesheet'">
107 + <link rel="preload" as="style" href="css/scheduler-datepicker.css" onload="this.onload=null;this.rel='stylesheet'">
108 + <link rel="preload" as="style" href="css/scheduler.css" onload="this.onload=null;this.rel='stylesheet'">
109 + <link rel="preload" as="style" href="css/notification.css" onload="this.onload=null;this.rel='stylesheet'">
110 + <link rel="preload" as="style" href="css/buttons.css" onload="this.onload=null;this.rel='stylesheet'">
111 + <link rel="preload" as="style" href="css/tables.css" onload="this.onload=null;this.rel='stylesheet'">
112 + <link rel="preload" as="style" href="components/canvas/right-canvas.css" onload="this.onload=null;this.rel='stylesheet'">
113
114 <!-- Flatpickr for datetime picker -->
25 - <link rel="stylesheet" href="vendor/flatpickr/flatpickr.min.css">
115 + <link rel="preload" as="style" href="vendor/flatpickr/flatpickr.min.css" onload="this.onload=null;this.rel='stylesheet'">
116 <script defer src="vendor/flatpickr/flatpickr.min.js"></script>
117
118 <script>
@@ -42,16 +132,16 @@
132 <script defer src="vendor/qrcode.min.js"></script>
133 <script type="module" src="js/initFw.js"></script>
134
45 - <link href="vendor/ace-min/ace.min.css" rel="stylesheet">
135 + <link rel="preload" as="style" href="vendor/ace-min/ace.min.css" onload="this.onload=null;this.rel='stylesheet'">
136 <!-- KaTeX CSS -->
47 - <link rel="stylesheet" href="vendor/katex/katex.min.css" crossorigin="anonymous">
137 + <link rel="preload" as="style" href="vendor/katex/katex.min.css" crossorigin="anonymous" onload="this.onload=null;this.rel='stylesheet'">
138
139 <!-- KaTeX javascript -->
140 <script defer src="vendor/katex/katex.min.js" crossorigin="anonymous"></script>
141 <script defer src="vendor/katex/katex.auto-render.min.js" crossorigin="anonymous"></script>
142
143 <!-- Google Icons -->
54 - <link rel="stylesheet" href="vendor/google/google-icons.css" />
144 + <link rel="preload" as="style" href="vendor/google/google-icons.css" onload="this.onload=null;this.rel='stylesheet'">
145
146 <!-- Link the PWA manifest file -->
147 <link rel="manifest" href="js/manifest.json">
@@ -76,6 +166,12 @@
166 </head>
167
168 <body class="dark-mode device-pointer" x-data>
169 + <script>
170 + if (document.documentElement.dataset.splashTheme === "light") document.body.classList.replace("dark-mode", "light-mode")
171 + </script>
172 + <div id="startup-splash" aria-hidden="true">
173 + <img src="/public/a0-fullDark.svg" alt="">
174 + </div>
175 <div class="container">
176 <!-- Sidebar Overlay -->
177 <div id="sidebar-overlay" x-data>
@@ -153,19 +249,6 @@
249 <!-- Drag and Drop Overlay Component -->
250 <x-component path="chat/attachments/dragDropOverlay.html"></x-component>
251
156 - <!-- Register Service Worker for offline support and caching -->
157 - <script>
158 - if ('serviceWorker' in navigator) {
159 - window.addEventListener('load', () => {
160 - navigator.serviceWorker.register('js/sw.js').then(registration => {
161 - console.log('SW registered: ', registration);
162 - }).catch(registrationError => {
163 - console.log('SW registration failed: ', registrationError);
164 - });
165 - });
166 - }
167 - </script>
168 -
252 </body>
253
254 </html>
webui/js/AGENTS.md
+2
@@ -37,6 +37,8 @@
37 - `scrollModal(id)` scrolls inside the top modal's `.modal-scroll`.
38 - Keep extension loader cache keys and extension point names stable for plugins.
39 - HTML extension loading turns discovered HTML files into `<x-component>` tags; JavaScript extensions must export a default function.
40 +- `extensions.js` exposes `initialHtmlExtensionsLoaded` and emits `webui-extensions-loaded` once after Alpine and the initial recursive component/extension loading placeholders have cleared.
41 +- Transport-level preloading must remain outside `components.js`, `extensions.js`, and `initFw.js`; cache hits flow through their ordinary asynchronous requests.
42 - `<x-component>` loading must process component `style`, `script`, and stylesheet-link assets only once, even when a component keeps its scoped `<style>` inside `<body>`.
43 - Every `<x-component>` instance must await cached module-load promises before markup is appended so Alpine bindings only run after imported stores exist.
44 - Frontend extension hooks such as `confirm_dialog_after_render` and `get_tool_message_handler` must preserve their mutable context contracts.
webui/js/extensions.js
+23
@@ -20,6 +20,19 @@ import * as cache from "./cache.js";
20
21 const JS_CACHE_AREA = "frontend_extensions_js(extensions)(plugins)";
22 const HTML_CACHE_AREA = "frontend_extensions_html(extensions)(plugins)";
23 +let alpineInitialized = false;
24 +const LOADING_SELECTOR = "x-component > .loading:empty, x-extension.loading";
25 +
26 +export let initialHtmlExtensionsLoaded = false;
27 +
28 +function checkInitialLoadComplete() {
29 + if (initialHtmlExtensionsLoaded || !alpineInitialized || document.querySelector(LOADING_SELECTOR)) return;
30 + globalThis.Alpine.nextTick(() => {
31 + if (initialHtmlExtensionsLoaded || document.querySelector(LOADING_SELECTOR)) return;
32 + initialHtmlExtensionsLoaded = true;
33 + document.dispatchEvent(new Event("webui-extensions-loaded"));
34 + });
35 +}
36
37 export const API_EXTENSION_EXCLUDED_ENDPOINTS = new Set([
38 "/api/load_webui_extensions",
@@ -159,6 +172,7 @@ export async function reloadHtmlExtensions(roots = [document.documentElement]) {
172 * @returns {Promise<void>}
173 */
174 export async function importHtmlExtensions(extensionPoint, targetElement) {
175 + targetElement.classList.add("loading");
176 try {
177 const cachedHtml = cache.get(HTML_CACHE_AREA, extensionPoint, null);
178 if (cachedHtml != null) {
@@ -181,6 +195,9 @@ export async function importHtmlExtensions(extensionPoint, targetElement) {
195 } catch (error) {
196 console.error("Error importing HTML extensions:", error);
197 return;
198 + } finally {
199 + targetElement.classList.remove("loading");
200 + checkInitialLoadComplete();
201 }
202 }
203
@@ -210,12 +227,18 @@ const extensionObserverCallback = (mutations) => {
227 }
228 }
229 }
230 + checkInitialLoadComplete();
231 };
232
233 /** @type {MutationObserver} */
234 const extensionObserver = new MutationObserver(extensionObserverCallback);
235 extensionObserver.observe(document.body, { childList: true, subtree: true });
236
237 +document.addEventListener("alpine:initialized", () => {
238 + alpineInitialized = true;
239 + checkInitialLoadComplete();
240 +}, { once: true });
241 +
242 // Do an initial scan for static x-extension tags
243 // that already exist in the DOM (index.html), then rely on
244 // the observer for dynamically inserted nodes coming from components.
webui/sw.js new
+182
@@ -0,0 +1,182 @@
1 +const CACHE_PREFIX = "agent-zero-ui-assets-";
2 +const SCRIPT_VERSION = new URL(self.location.href).searchParams.get("version") || "runtime";
3 +const MAX_CACHEABLE_FILE_BYTES = 40 * 1024;
4 +const CACHEABLE_FILE_PATTERN = /\.(?:css|html?|xhtml|m?js)$/i;
5 +
6 +let activeCacheName = cacheName(SCRIPT_VERSION);
7 +let activeBundleEntries = new Map();
8 +let cachePopulation = { name: "", promise: Promise.resolve() };
9 +
10 +self.addEventListener("install", () => {
11 + self.skipWaiting();
12 +});
13 +
14 +self.addEventListener("activate", (event) => {
15 + event.waitUntil(
16 + Promise.all([
17 + cleanupCaches(activeCacheName),
18 + self.clients.claim(),
19 + ]),
20 + );
21 +});
22 +
23 +self.addEventListener("message", (event) => {
24 + if (event.data?.type !== "preload-ui-bundle") return;
25 + const bundle = event.data.bundle;
26 + if (!bundle?.version || !bundle.files || typeof bundle.files !== "object") return;
27 +
28 + activeCacheName = cacheName(bundle.version);
29 + activeBundleEntries = bundleEntries(bundle.files);
30 +
31 + if (cachePopulation.name !== activeCacheName) {
32 + const targetCacheName = activeCacheName;
33 + const promise = preloadBundle(bundle, targetCacheName).catch((error) => {
34 + if (cachePopulation.promise === promise) {
35 + cachePopulation = { name: "", promise: Promise.resolve() };
36 + }
37 + throw error;
38 + });
39 + cachePopulation = { name: targetCacheName, promise };
40 + }
41 +
42 + event.waitUntil(cachePopulation.promise);
43 +});
44 +
45 +self.addEventListener("fetch", (event) => {
46 + if (!isCacheableRequest(event.request)) return;
47 +
48 + const bundledEntry = activeBundleEntries.get(event.request.url);
49 + if (bundledEntry) {
50 + event.respondWith(Promise.resolve(responseFromEntry(bundledEntry)));
51 + return;
52 + }
53 +
54 + let cacheWrite = Promise.resolve();
55 + const response = (async () => {
56 + const cache = await caches.open(activeCacheName);
57 + const cached = await cache.match(event.request);
58 + if (cached) return cached;
59 +
60 + const networkResponse = await fetch(event.request);
61 + if (isCacheableResponse(networkResponse)) {
62 + cacheWrite = cacheRuntimeResponse(
63 + cache,
64 + event.request,
65 + networkResponse.clone(),
66 + );
67 + }
68 + return networkResponse;
69 + })();
70 +
71 + event.respondWith(response);
72 + event.waitUntil(response.then(() => cacheWrite).catch(() => undefined));
73 +});
74 +
75 +function cacheName(version) {
76 + const safeVersion = String(version).replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64);
77 + return `${CACHE_PREFIX}${safeVersion || "runtime"}`;
78 +}
79 +
80 +async function preloadBundle(bundle, targetCacheName) {
81 + await cleanupCaches(targetCacheName);
82 + const cache = await caches.open(targetCacheName);
83 + const marker = cacheMarkerRequest(targetCacheName);
84 + if (await cache.match(marker)) return;
85 +
86 + await Promise.all(
87 + Object.entries(bundle.files).map(async ([url, entry]) => {
88 + const response = responseFromEntry(entry);
89 + if (!response) return;
90 + const request = new Request(new URL(url, self.location.origin), {
91 + credentials: "same-origin",
92 + });
93 + await cache.put(request, response);
94 + }),
95 + );
96 + await cache.put(marker, new Response(bundle.version));
97 +}
98 +
99 +async function cleanupCaches(targetCacheName) {
100 + const names = await caches.keys();
101 + await Promise.all(
102 + names
103 + .filter((name) => name.startsWith(CACHE_PREFIX) && name !== targetCacheName)
104 + .map((name) => caches.delete(name)),
105 + );
106 +}
107 +
108 +function bundleEntries(files) {
109 + const entries = new Map();
110 + for (const [url, entry] of Object.entries(files)) {
111 + try {
112 + const absoluteUrl = new URL(url, self.location.origin);
113 + if (absoluteUrl.origin === self.location.origin && isBundleEntry(entry)) {
114 + entries.set(absoluteUrl.href, entry);
115 + }
116 + } catch (_error) {
117 + continue;
118 + }
119 + }
120 + return entries;
121 +}
122 +
123 +function isBundleEntry(entry) {
124 + return (
125 + Array.isArray(entry) &&
126 + entry.length === 3 &&
127 + entry[1] === "text" &&
128 + typeof entry[2] === "string"
129 + );
130 +}
131 +
132 +function responseFromEntry(entry) {
133 + if (!isBundleEntry(entry)) return null;
134 + const [contentType, _encoding, content] = entry;
135 + return new Response(content, {
136 + headers: {
137 + "Content-Type": contentType || "application/octet-stream",
138 + "X-Agent-Zero-Cache": "preloaded",
139 + },
140 + });
141 +}
142 +
143 +function cacheMarkerRequest(targetCacheName) {
144 + return new Request(
145 + new URL(`/.agent-zero-cache/${encodeURIComponent(targetCacheName)}`, self.location.origin),
146 + );
147 +}
148 +
149 +function isCacheableRequest(request) {
150 + if (request.method !== "GET" || request.headers.has("range")) return false;
151 + const url = new URL(request.url);
152 + if (url.origin !== self.location.origin || request.mode === "navigate") return false;
153 + if (
154 + url.pathname === "/" ||
155 + url.pathname === "/login" ||
156 + url.pathname === "/logout" ||
157 + url.pathname.startsWith("/api/") ||
158 + url.pathname.startsWith("/ws") ||
159 + url.pathname.startsWith("/socket.io/") ||
160 + url.pathname.startsWith("/mcp/") ||
161 + url.pathname.startsWith("/a2a/")
162 + ) {
163 + return false;
164 + }
165 + return CACHEABLE_FILE_PATTERN.test(url.pathname);
166 +}
167 +
168 +function isCacheableResponse(response) {
169 + return response.ok && (response.type === "basic" || response.type === "default");
170 +}
171 +
172 +async function cacheRuntimeResponse(cache, request, response) {
173 + const contentLength = response.headers.get("content-length");
174 + if (contentLength !== null) {
175 + const size = Number(contentLength);
176 + if (!Number.isFinite(size) || size > MAX_CACHEABLE_FILE_BYTES) return;
177 + } else {
178 + const body = await response.clone().arrayBuffer();
179 + if (body.byteLength > MAX_CACHEABLE_FILE_BYTES) return;
180 + }
181 + await cache.put(request, response);
182 +}