diff --git a/packages/ui/src/components/chat/MarkdownImageGallery.tsx b/packages/ui/src/components/chat/MarkdownImageGallery.tsx index faffc569..ba529d95 100644 --- a/packages/ui/src/components/chat/MarkdownImageGallery.tsx +++ b/packages/ui/src/components/chat/MarkdownImageGallery.tsx @@ -1,25 +1,81 @@ import React from 'react'; +import { toast } from 'sonner'; import { Icon } from '@/components/icon/Icon'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useI18n } from '@/lib/i18n'; +import { + acquireRuntimeUrlAuthToken, + refreshRuntimeUrlAuthToken, + subscribeRuntimeUrlAuthToken, +} from '@/lib/runtime-auth'; +import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import type { ToolPopupContent } from './message/types'; import { extractMarkdownImageCandidates, MAX_MARKDOWN_IMAGE_COUNT, type MarkdownImageCandidate, } from './markdown/markdownCore'; -import { resolveMarkdownImageSource } from './markdown/markdownImageAssets'; +import { + getPreparedMarkdownImageUrl, + isLocalMarkdownImageSource, + prepareLocalMarkdownImages, + resolveMarkdownImageSource, + type PreparedMarkdownImage, +} from './markdown/markdownImageAssets'; + +const useAssetAuth = (enabled: boolean): { ready: boolean; nonce: number } => { + const [ready, setReady] = React.useState(false); + const [nonce, setNonce] = React.useState(0); + const apiBaseUrl = getRuntimeApiBaseUrl(); + + React.useEffect(() => { + if (!enabled) { + setReady(false); + return; + } + let cancelled = false; + let retryTimer: ReturnType | undefined; + const release = acquireRuntimeUrlAuthToken(apiBaseUrl); + const unsubscribe = subscribeRuntimeUrlAuthToken(() => { + if (!cancelled) setNonce((current) => current + 1); + }); + const refresh = () => { + void refreshRuntimeUrlAuthToken(apiBaseUrl) + .then(() => { + if (!cancelled) setReady(true); + }) + .catch(() => { + if (!cancelled) retryTimer = setTimeout(refresh, 1000); + }); + }; + refresh(); + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + release(); + unsubscribe(); + }; + }, [apiBaseUrl, enabled]); + + return { ready: !enabled || ready, nonce }; +}; const MarkdownImageThumbnail: React.FC<{ candidate: MarkdownImageCandidate; + preparation?: PreparedMarkdownImage; directory: string; + assetAuthReady: boolean; + assetAuthNonce: number; onShowPopup?: (content: ToolPopupContent) => void; -}> = ({ candidate, directory, onShowPopup }) => { +}> = ({ candidate, preparation, directory, assetAuthReady, assetAuthNonce, onShowPopup }) => { + const { t } = useI18n(); const thumbnailRef = React.useRef(null); const [shouldLoad, setShouldLoad] = React.useState(false); - const [image, setImage] = React.useState<{ - url: string; - status: 'loading' | 'ready' | 'error'; - }>({ url: '', status: 'loading' }); + const [image, setImage] = React.useState<{ url: string; status: 'loading' | 'ready' | 'error' }>({ + url: '', + status: 'loading', + }); + const local = isLocalMarkdownImageSource(candidate.source); React.useEffect(() => { const thumbnail = thumbnailRef.current; @@ -28,7 +84,6 @@ const MarkdownImageThumbnail: React.FC<{ setShouldLoad(true); return; } - const observer = new IntersectionObserver((entries) => { if (!entries.some((entry) => entry.isIntersecting)) return; setShouldLoad(true); @@ -39,20 +94,33 @@ const MarkdownImageThumbnail: React.FC<{ }, [shouldLoad]); React.useEffect(() => { - if (!shouldLoad) return; + if (!shouldLoad || (local && !preparation)) return; + if (local) { + if (preparation?.status !== 'ready') { + setImage({ url: '', status: 'error' }); + return; + } + if (!assetAuthReady) return; + setImage({ url: getPreparedMarkdownImageUrl(preparation, directory), status: 'loading' }); + return; + } const controller = new AbortController(); setImage({ url: '', status: 'loading' }); - void resolveMarkdownImageSource(candidate.source, directory, controller.signal) - .then((url) => { - if (!controller.signal.aborted) setImage({ url, status: 'loading' }); - }) - .catch(() => { - if (!controller.signal.aborted) setImage({ url: '', status: 'error' }); - }); + void resolveMarkdownImageSource(candidate.source, controller.signal).then((url) => { + if (controller.signal.aborted) return; + setImage({ url, status: 'loading' }); + }).catch(() => { + if (controller.signal.aborted) return; + setImage({ url: '', status: 'error' }); + }); return () => controller.abort(); - }, [candidate.source, directory, shouldLoad]); + }, [assetAuthNonce, assetAuthReady, candidate.source, directory, local, preparation, shouldLoad]); const openPreview = React.useCallback(() => { + if (image.status === 'error') { + toast.error(t('filesView.error.previewUnavailable')); + return; + } if (image.status !== 'ready' || !onShowPopup) return; onShowPopup({ open: true, @@ -61,7 +129,7 @@ const MarkdownImageThumbnail: React.FC<{ metadata: { tool: 'markdown-image-preview', filename: candidate.filename }, image: { url: image.url, filename: candidate.filename }, }); - }, [candidate.filename, image, onShowPopup]); + }, [candidate.filename, image, onShowPopup, t]); return (