| 1 | import { useEffect, useId, useState } from "react"; |
| 2 | import { |
| 3 | resolveOfficialAcpRegistryIconUrl, |
| 4 | officialAcpRegistryIconUrlForAgentId, |
| 5 | } from "@t3tools/contracts"; |
| 6 | |
| 7 | import { cn } from "../../lib/utils"; |
| 8 | import { ACPRegistryIcon } from "../Icons"; |
| 9 | |
| 10 | const ACP_REGISTRY_ICON_CACHE = "t3-acp-registry-icons-v1"; |
| 11 | const MAX_ICON_BYTES = 512 * 1_024; |
| 12 | const inFlightIcons = new Map<string, Promise<Blob>>(); |
| 13 | |
| 14 | export { officialAcpRegistryIconUrlForAgentId, resolveOfficialAcpRegistryIconUrl }; |
| 15 | |
| 16 | async function checkedIconBlob(response: Response): Promise<Blob> { |
| 17 | if (!response.ok || response.redirected) throw new Error("ACP registry icon request failed."); |
| 18 | const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim() ?? ""; |
| 19 | if (!contentType.startsWith("image/")) throw new Error("ACP registry icon was not an image."); |
| 20 | const contentLength = Number(response.headers.get("content-length")); |
| 21 | if (Number.isFinite(contentLength) && contentLength > MAX_ICON_BYTES) { |
| 22 | throw new Error("ACP registry icon was too large."); |
| 23 | } |
| 24 | const bytes = await response.arrayBuffer(); |
| 25 | if (bytes.byteLength > MAX_ICON_BYTES) throw new Error("ACP registry icon was too large."); |
| 26 | return new Blob([bytes], { type: contentType }); |
| 27 | } |
| 28 | |
| 29 | /** Fetches an official icon once, then serves it from persistent browser cache. */ |
| 30 | export function loadCachedAcpRegistryIcon(iconUrl: string): Promise<Blob> { |
| 31 | if (resolveOfficialAcpRegistryIconUrl(iconUrl) !== iconUrl) { |
| 32 | return Promise.reject(new Error("ACP registry icon URL was not allowed.")); |
| 33 | } |
| 34 | const pending = inFlightIcons.get(iconUrl); |
| 35 | if (pending) return pending; |
| 36 | const load = (async () => { |
| 37 | let cache: Cache | null = null; |
| 38 | if (typeof caches !== "undefined") { |
| 39 | try { |
| 40 | cache = await caches.open(ACP_REGISTRY_ICON_CACHE); |
| 41 | } catch { |
| 42 | // Some privacy modes expose CacheStorage but reject access. Icons can |
| 43 | // still load normally from the allowlisted registry CDN. |
| 44 | } |
| 45 | } |
| 46 | if (cache !== null) { |
| 47 | try { |
| 48 | const cached = await cache.match(iconUrl); |
| 49 | if (cached) return await checkedIconBlob(cached); |
| 50 | } catch { |
| 51 | // Ignore unreadable or invalid cache entries and repair from network. |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | const response = await fetch(iconUrl, { |
| 56 | credentials: "omit", |
| 57 | redirect: "error", |
| 58 | referrerPolicy: "no-referrer", |
| 59 | }); |
| 60 | const clone = response.clone(); |
| 61 | const blob = await checkedIconBlob(response); |
| 62 | await cache?.put(iconUrl, clone).catch(() => undefined); |
| 63 | return blob; |
| 64 | })(); |
| 65 | inFlightIcons.set(iconUrl, load); |
| 66 | void load.then( |
| 67 | () => inFlightIcons.delete(iconUrl), |
| 68 | () => inFlightIcons.delete(iconUrl), |
| 69 | ); |
| 70 | return load; |
| 71 | } |
| 72 | |
| 73 | export function AcpRegistryAgentIcon({ |
| 74 | icon, |
| 75 | className, |
| 76 | fallbackClassName, |
| 77 | }: { |
| 78 | readonly icon: string | null; |
| 79 | readonly className?: string; |
| 80 | readonly fallbackClassName?: string; |
| 81 | }) { |
| 82 | const colorFilterId = useId(); |
| 83 | const iconUrl = resolveOfficialAcpRegistryIconUrl(icon); |
| 84 | const [image, setImage] = useState<{ |
| 85 | readonly iconUrl: string; |
| 86 | readonly source: string; |
| 87 | readonly status: "loading" | "loaded" | "failed"; |
| 88 | } | null>(null); |
| 89 | const currentImage = image?.iconUrl === iconUrl ? image : null; |
| 90 | // Render only after the load pipeline resolves: the validated blob when the |
| 91 | // fetch succeeds, or the raw allowlisted CDN URL when it fails (the official |
| 92 | // CDN serves no CORS headers, so the validating fetch is often blocked in |
| 93 | // cross-origin browser contexts while native <img> loading still works). |
| 94 | const source = currentImage?.source ?? null; |
| 95 | const status = currentImage?.status ?? "loading"; |
| 96 | |
| 97 | useEffect(() => { |
| 98 | if (iconUrl === null) return; |
| 99 | let active = true; |
| 100 | let objectUrl: string | null = null; |
| 101 | void loadCachedAcpRegistryIcon(iconUrl) |
| 102 | .then((blob) => { |
| 103 | if (!active) return; |
| 104 | objectUrl = URL.createObjectURL(blob); |
| 105 | setImage({ iconUrl, source: objectUrl, status: "loading" }); |
| 106 | }) |
| 107 | .catch(() => { |
| 108 | if (!active) return; |
| 109 | setImage((current) => |
| 110 | current?.iconUrl === iconUrl ? current : { iconUrl, source: iconUrl, status: "loading" }, |
| 111 | ); |
| 112 | }); |
| 113 | return () => { |
| 114 | active = false; |
| 115 | if (objectUrl !== null) URL.revokeObjectURL(objectUrl); |
| 116 | }; |
| 117 | }, [iconUrl]); |
| 118 | |
| 119 | return ( |
| 120 | <span |
| 121 | aria-hidden |
| 122 | className={cn( |
| 123 | "relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground", |
| 124 | className, |
| 125 | )} |
| 126 | > |
| 127 | {/* Registry glyphs are monochrome. Recolor their alpha rather than |
| 128 | inverting RGB values; external images cannot inherit currentColor. |
| 129 | A local SVG filter also works when CDN CORS prevents CSS masks. */} |
| 130 | <svg aria-hidden className="pointer-events-none absolute size-0" focusable="false"> |
| 131 | <defs> |
| 132 | <filter id={colorFilterId} colorInterpolationFilters="sRGB"> |
| 133 | <feFlood floodColor="currentColor" /> |
| 134 | <feComposite in2="SourceAlpha" operator="in" /> |
| 135 | </filter> |
| 136 | </defs> |
| 137 | </svg> |
| 138 | {status !== "loaded" ? ( |
| 139 | <ACPRegistryIcon |
| 140 | className={cn("size-4", fallbackClassName)} |
| 141 | data-slot="acp-icon-fallback" |
| 142 | /> |
| 143 | ) : null} |
| 144 | {source !== null && status !== "failed" ? ( |
| 145 | <img |
| 146 | alt="" |
| 147 | className={cn( |
| 148 | "size-full object-contain", |
| 149 | status !== "loaded" && "invisible absolute inset-0", |
| 150 | )} |
| 151 | style={{ filter: `url(#${colorFilterId})` }} |
| 152 | decoding="async" |
| 153 | referrerPolicy="no-referrer" |
| 154 | src={source} |
| 155 | onError={() => { |
| 156 | if (iconUrl === null) return; |
| 157 | setImage((current) => |
| 158 | current?.iconUrl === iconUrl && current.source !== source |
| 159 | ? current |
| 160 | : { iconUrl, source, status: "failed" }, |
| 161 | ); |
| 162 | }} |
| 163 | onLoad={() => { |
| 164 | if (iconUrl !== null) setImage({ iconUrl, source, status: "loaded" }); |
| 165 | }} |
| 166 | /> |
| 167 | ) : null} |
| 168 | </span> |
| 169 | ); |
| 170 | } |