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