main
py 394 lines 12.8 KB
Raw
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_PREFIX = "webui"
19 _LOCAL_ORIGIN = "https://agent-zero.local"
20 _BUNDLE_POLICY_VERSION = "text-startup-v4-512k"
21 _BUNDLE_SUFFIXES = {".css", ".htm", ".html", ".js", ".mjs", ".xhtml"}
22 _WEBUI_EXTENSION_ENTRY_SUFFIXES = {".htm", ".html", ".js", ".mjs", ".xhtml"}
23 _MAX_BUNDLE_FILE_BYTES = 512 * 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(
94 entry_urls: Iterable[str],
95 agent: "Agent | None" = None,
96 ) -> dict:
97 """Build a versioned recursive text-asset bundle from the supplied entries."""
98 entries = list(dict.fromkeys(entry_urls))
99 cache_key = _cache_key(entries)
100 cached = cache.get(_CACHE_AREA, cache_key)
101 if cached is not None:
102 return cached["bundle"]
103
104 roots, extension_roots = _get_asset_roots(agent)
105
106 # WebUI extension paths are injected into the rendered application document
107 # at runtime, so they cannot be discovered from the supplied source alone.
108 # Include those actual extension entry files, then let the same recursive
109 # scan discover their component, stylesheet, and module dependencies.
110 # Unrelated files stay lazy and use the service worker's ordinary
111 # fetch-and-cache fallback.
112 for root in extension_roots:
113 for path in _iter_root_files(
114 root.path,
115 suffixes=_WEBUI_EXTENSION_ENTRY_SUFFIXES,
116 ):
117 url = _url_for_path(path, roots)
118 if url:
119 entries.append(url)
120
121 signature = _bundle_signature(roots, entries)
122 result = _build_asset_bundle(entries, roots, signature)
123 cache.add(
124 _CACHE_AREA,
125 cache_key,
126 {"signature": signature, "bundle": result},
127 )
128 return result
129
130
131 def _cache_key(entry_urls: Iterable[str]) -> str:
132 digest = hashlib.sha256()
133 for entry_url in sorted(set(entry_urls)):
134 digest.update(entry_url.encode("utf-8"))
135 digest.update(b"\0")
136 return f"{_CACHE_KEY_PREFIX}:{digest.hexdigest()[:20]}"
137
138
139 def _build_asset_bundle(
140 entry_urls: Iterable[str],
141 roots: list[_AssetRoot],
142 signature: str,
143 ) -> dict:
144 pending: list[str] = []
145 queued: set[str] = set()
146 entries: dict[str, list[str]] = {}
147
148 def enqueue(url: str) -> None:
149 normalized = _normalize_url(url)
150 suffix = Path(unquote(urlsplit(normalized).path)).suffix.lower() if normalized else ""
151 if normalized and suffix in _BUNDLE_SUFFIXES:
152 if normalized not in queued:
153 queued.add(normalized)
154 pending.append(normalized)
155
156 for entry_url in entry_urls:
157 enqueue(entry_url)
158
159 while pending:
160 url = pending.pop()
161 path = _path_for_url(url, roots)
162 if path is None:
163 continue
164 try:
165 content = path.read_bytes()
166 except OSError:
167 continue
168
169 text = _decode_text(content)
170 if text is None:
171 continue
172 if len(content) <= _MAX_BUNDLE_FILE_BYTES:
173 entries[url] = [_content_type(path), "text", text]
174 for reference in _extract_references(text, url, path.suffix.lower()):
175 enqueue(reference)
176
177 return {
178 "version": signature[:20],
179 "files": {url: entries[url] for url in sorted(entries)},
180 }
181
182
183 def serialize_ui_asset_bundle(bundle: dict) -> str:
184 """Serialize a UI asset bundle for its JSON endpoint."""
185 return json.dumps(
186 bundle,
187 ensure_ascii=False,
188 separators=(",", ":"),
189 )
190
191
192 def _get_asset_roots(
193 agent: "Agent | None",
194 ) -> tuple[list[_AssetRoot], list[_AssetRoot]]:
195 from helpers import plugins, subagents
196
197 webui_root = _AssetRoot(Path(files.get_abs_path("webui")), "/")
198 extension_roots: list[_AssetRoot] = []
199 plugin_webui_roots: list[_AssetRoot] = []
200
201 for path in subagents.get_paths(agent, "extensions/webui"):
202 root_path = Path(path).resolve()
203 if not root_path.is_dir() or not files.is_in_base_dir(str(root_path)):
204 continue
205 relative = files.deabsolute_path(str(root_path)).replace("\\", "/")
206 extension_roots.append(_AssetRoot(root_path, f"/{relative}"))
207
208 for path in plugins.get_enabled_plugin_paths(agent, "webui"):
209 root_path = Path(path).resolve()
210 if not root_path.is_dir() or not files.is_in_base_dir(str(root_path)):
211 continue
212 relative = files.deabsolute_path(str(root_path)).replace("\\", "/")
213 plugin_webui_roots.append(_AssetRoot(root_path, f"/{relative}"))
214
215 extension_roots = list(dict.fromkeys(extension_roots))
216 plugin_webui_roots = list(dict.fromkeys(plugin_webui_roots))
217 roots = list(dict.fromkeys([*extension_roots, *plugin_webui_roots, webui_root]))
218 roots.sort(key=lambda root: len(root.url_prefix), reverse=True)
219 return roots, extension_roots
220
221
222 def _iter_root_files(
223 root: Path,
224 suffixes: set[str] | None = None,
225 recursive: bool = True,
226 ) -> Iterable[Path]:
227 if not root.is_dir():
228 return
229 candidates = root.rglob("*") if recursive else root.glob("*")
230 for path in sorted(candidates, key=lambda item: item.as_posix()):
231 if not path.is_file() or (suffixes and path.suffix.lower() not in suffixes):
232 continue
233 resolved = path.resolve()
234 try:
235 resolved.relative_to(root)
236 except ValueError:
237 continue
238 yield resolved
239
240
241 def _bundle_signature(roots: list[_AssetRoot], entry_urls: Iterable[str]) -> str:
242 digest = hashlib.sha256()
243 digest.update(_BUNDLE_POLICY_VERSION.encode("ascii"))
244 digest.update(b"\0")
245 for entry_url in sorted(set(entry_urls)):
246 digest.update(entry_url.encode("utf-8"))
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