diff --git a/apps/web/package.json b/apps/web/package.json index 705b6776cfc01be3745a28ee4e492971a2ac08bd..4d56b6be5db1b32b6d9ae9977ffc83013698f574 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,12 +22,10 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", - "highlight.js": "^11.11.1", "lucide-react": "^0.564.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", - "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0" }, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2676da0d21bd44570fadc02f37382a329412357b..9b84386624523307a62eb7345b6832b10c4a5c5e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,18 +1,215 @@ -import { memo, useMemo } from "react"; +import { getSharedHighlighter } from "@pierre/diffs"; +import { + Children, + isValidElement, + memo, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; -import rehypeHighlight from "rehype-highlight"; import remarkGfm from "remark-gfm"; +import { LRUCache } from "../lib/lruCache"; import { readNativeApi } from "../nativeApi"; +import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffThemes"; +import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { preferredTerminalEditor } from "../terminal-links"; interface ChatMarkdownProps { text: string; cwd: string | undefined; + isStreaming?: boolean; } -function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { +const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; +const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; +const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; +const highlightedCodeCache = new LRUCache( + MAX_HIGHLIGHT_CACHE_ENTRIES, + MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, +); +const inFlightHighlights = new Map>(); + +function hashString(input: string): string { + // FNV-1a 32-bit hash. + let hash = 0x811c9dc5; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); +} + +function extractFenceLanguage(className: string | undefined): string { + const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); + return match?.[1] ?? "text"; +} + +function nodeToPlainText(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") { + return String(node); + } + if (Array.isArray(node)) { + return node.map((child) => nodeToPlainText(child)).join(""); + } + if (isValidElement<{ children?: ReactNode }>(node)) { + return nodeToPlainText(node.props.children); + } + return ""; +} + +function extractCodeBlock( + children: ReactNode, +): { className: string | undefined; code: string } | null { + const childNodes = Children.toArray(children); + if (childNodes.length !== 1) { + return null; + } + + const onlyChild = childNodes[0]; + if ( + !isValidElement<{ className?: string; children?: ReactNode }>(onlyChild) || + onlyChild.type !== "code" + ) { + return null; + } + + return { + className: onlyChild.props.className, + code: nodeToPlainText(onlyChild.props.children), + }; +} + +async function highlightCodeBlock(code: string, language: string, themeName: DiffThemeName) { + try { + const highlighter = await getSharedHighlighter({ + themes: [themeName], + langs: [language], + }); + return highlighter.codeToHtml(code, { lang: language, theme: themeName }); + } catch { + const highlighter = await getSharedHighlighter({ + themes: [themeName], + langs: ["text"], + }); + return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); + } +} + +function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { + return `${hashString(code)}:${code.length}:${language}:${themeName}`; +} + +function estimateHighlightedSize(html: string, code: string): number { + return Math.max(html.length * 2, code.length * 3); +} + +function getCachedOrCreateHighlight({ + cacheKey, + code, + language, + themeName, + useCache, +}: { + cacheKey: string; + code: string; + language: string; + themeName: DiffThemeName; + useCache: boolean; +}): Promise { + if (useCache) { + const cached = highlightedCodeCache.get(cacheKey); + if (cached) { + return Promise.resolve(cached); + } + } + + const existingInFlight = inFlightHighlights.get(cacheKey); + if (existingInFlight) { + return existingInFlight; + } + + const request = highlightCodeBlock(code, language, themeName) + .then((html) => { + if (useCache) { + highlightedCodeCache.set(cacheKey, html, estimateHighlightedSize(html, code)); + } + return html; + }) + .finally(() => { + inFlightHighlights.delete(cacheKey); + }); + + inFlightHighlights.set(cacheKey, request); + return request; +} + +interface ShikiCodeBlockProps { + className: string | undefined; + code: string; + themeName: DiffThemeName; + isStreaming: boolean; + fallback: ReactNode; +} + +function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) { + const [highlightedHtml, setHighlightedHtml] = useState(null); + + useEffect(() => { + let cancelled = false; + const language = extractFenceLanguage(className); + const cacheKey = createHighlightCacheKey(code, language, themeName); + + if (!isStreaming) { + const cached = highlightedCodeCache.get(cacheKey); + if (cached) { + setHighlightedHtml(cached); + return () => { + cancelled = true; + }; + } + } + + setHighlightedHtml(null); + void getCachedOrCreateHighlight({ + cacheKey, + code, + language, + themeName, + useCache: !isStreaming, + }) + .then((html) => { + if (cancelled) { + return; + } + setHighlightedHtml(html); + }) + .catch(() => { + if (!cancelled) { + setHighlightedHtml(null); + } + }); + + return () => { + cancelled = true; + }; + }, [className, code, themeName, isStreaming]); + + if (!highlightedHtml) { + return fallback; + } + + return ( +
+ ); +} + +function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { + const { resolvedTheme } = useTheme(); + const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownComponents = useMemo( () => ({ a({ node: _node, href, ...props }) { @@ -38,17 +235,29 @@ function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { /> ); }, + pre({ node: _node, children, ...props }) { + const codeBlock = extractCodeBlock(children); + if (!codeBlock) { + return
{children}
; + } + + return ( + {children}} + /> + ); + }, }), - [cwd], + [cwd, diffThemeName, isStreaming], ); return (
- + {text}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 697fd67e6af95a4718c457bf54c87d7723cbd44c..597d067c8fc83262d3cd8ea64b24a3794894a709 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2767,7 +2767,11 @@ const MessagesTimeline = memo(function MessagesTimeline({
)}
- + {(() => { const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); if (!turnSummary) return null; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 61243fc64931d4c176bfe337edcf7f61643b779f..acf07f94f4846a5761cb7af614f18f6e0851952c 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -11,6 +11,7 @@ import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch" import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { buildPatchCacheKey } from "../lib/diffRendering"; +import { resolveDiffThemeName } from "../lib/diffThemes"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useStore } from "../store"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; @@ -18,10 +19,6 @@ import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; -function resolveDiffThemeName(theme: "light" | "dark") { - return theme === "dark" ? "pierre-dark" : "pierre-light"; -} - type RenderablePatch = | { kind: "files"; diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index f5930a19b772fc0f16e230f2cd1048aac9ac828a..47f91efcb9e264540447e0f6ee222f9806f89487 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -2,12 +2,9 @@ import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; import { useEffect, useMemo, type ReactNode } from "react"; import { useTheme } from "../hooks/useTheme"; +import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffThemes"; -function resolveDiffThemeName(theme: "light" | "dark") { - return theme === "dark" ? "pierre-dark" : "pierre-light"; -} - -function DiffWorkerThemeSync({ themeName }: { themeName: "pierre-light" | "pierre-dark" }) { +function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { const workerPool = useWorkerPool(); useEffect(() => { diff --git a/apps/web/src/lib/diffThemes.ts b/apps/web/src/lib/diffThemes.ts new file mode 100644 index 0000000000000000000000000000000000000000..c38fa8d0005983da909b7a7ec3558e8cc16803cb --- /dev/null +++ b/apps/web/src/lib/diffThemes.ts @@ -0,0 +1,10 @@ +export const DIFF_THEME_NAMES = { + light: "pierre-light", + dark: "pierre-dark", +} as const; + +export type DiffThemeName = (typeof DIFF_THEME_NAMES)[keyof typeof DIFF_THEME_NAMES]; + +export function resolveDiffThemeName(theme: "light" | "dark"): DiffThemeName { + return theme === "dark" ? DIFF_THEME_NAMES.dark : DIFF_THEME_NAMES.light; +} diff --git a/apps/web/src/lib/lruCache.test.ts b/apps/web/src/lib/lruCache.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f53db2607ed3c31c3908fd963673d82c300484b --- /dev/null +++ b/apps/web/src/lib/lruCache.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { LRUCache } from "./lruCache"; + +describe("LRUCache", () => { + it("returns null for missing keys", () => { + const cache = new LRUCache(2, 100); + expect(cache.get("missing")).toBeNull(); + }); + + it("evicts oldest by max entries", () => { + const cache = new LRUCache(2, 1_000); + cache.set("a", "A", 10); + cache.set("b", "B", 10); + cache.set("c", "C", 10); + + expect(cache.get("a")).toBeNull(); + expect(cache.get("b")).toBe("B"); + expect(cache.get("c")).toBe("C"); + }); + + it("promotes on get and evicts least recently used", () => { + const cache = new LRUCache(2, 1_000); + cache.set("a", "A", 10); + cache.set("b", "B", 10); + expect(cache.get("a")).toBe("A"); + + cache.set("c", "C", 10); + expect(cache.get("a")).toBe("A"); + expect(cache.get("b")).toBeNull(); + expect(cache.get("c")).toBe("C"); + }); + + it("evicts by memory budget", () => { + const cache = new LRUCache(10, 25); + cache.set("a", "A", 10); + cache.set("b", "B", 10); + cache.set("c", "C", 10); + + expect(cache.get("a")).toBeNull(); + expect(cache.get("b")).toBe("B"); + expect(cache.get("c")).toBe("C"); + }); +}); diff --git a/apps/web/src/lib/lruCache.ts b/apps/web/src/lib/lruCache.ts new file mode 100644 index 0000000000000000000000000000000000000000..141c796e737e6688bf6fbfb38676532b5ad76c31 --- /dev/null +++ b/apps/web/src/lib/lruCache.ts @@ -0,0 +1,64 @@ +interface CacheEntry { + value: T; + approximateSize: number; +} + +export class LRUCache { + private cache = new Map>(); + private totalSize = 0; + + constructor( + private readonly maxEntries: number, + private readonly maxMemoryBytes: number, + ) {} + + get(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) { + return null; + } + + this.promote(key, entry); + return entry.value; + } + + set(key: string, value: T, approximateSize: number): void { + const existing = this.cache.get(key); + if (existing) { + this.totalSize -= existing.approximateSize; + this.cache.delete(key); + } + + this.evictIfNeeded(approximateSize); + this.cache.set(key, { value, approximateSize }); + this.totalSize += approximateSize; + } + + clear(): void { + this.cache.clear(); + this.totalSize = 0; + } + + private promote(key: string, entry: CacheEntry): void { + this.cache.delete(key); + this.cache.set(key, entry); + } + + private evictIfNeeded(incomingSize: number): void { + while ( + (this.cache.size >= this.maxEntries || this.totalSize + incomingSize > this.maxMemoryBytes) && + this.cache.size > 0 + ) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey === undefined) { + break; + } + + const oldestEntry = this.cache.get(oldestKey); + if (oldestEntry) { + this.totalSize -= oldestEntry.approximateSize; + } + this.cache.delete(oldestKey); + } + } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 0de3ae3c4e7eaa62816048e3cf971aa30e84cea5..b735484b2b874710fa0c81436648bf91da8edcee 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,7 +6,6 @@ import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack import { StoreProvider } from "./store"; import "@xterm/xterm/css/xterm.css"; -import "highlight.js/styles/github-dark.css"; import "./index.css"; import { APP_DISPLAY_NAME } from "./branding"; diff --git a/bun.lock b/bun.lock index 053e976756790712edd965cc7671c2f9b135bc4d..952f4c09e4f1bf518dcc05d445de1ed801ee79c4 100644 --- a/bun.lock +++ b/bun.lock @@ -71,12 +71,10 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", - "highlight.js": "^11.11.1", "lucide-react": "^0.564.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", - "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", }, @@ -863,20 +861,14 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], - "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], - "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], - "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], - "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], - "hookable": ["hookable@6.0.1", "", {}, "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -983,8 +975,6 @@ "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], @@ -1197,8 +1187,6 @@ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], - "rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="], - "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -1361,8 +1349,6 @@ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], - "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], - "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],