diff --git a/ui/web/src/components/chat/media-gallery.tsx b/ui/web/src/components/chat/media-gallery.tsx index b1493731..ae6ade69 100644 --- a/ui/web/src/components/chat/media-gallery.tsx +++ b/ui/web/src/components/chat/media-gallery.tsx @@ -2,7 +2,8 @@ import { useState, useCallback } from "react"; import { Download, FileText, FileCode, Music, Film, File } from "lucide-react"; import { ImageLightbox } from "@/components/shared/image-lightbox"; import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; -import { formatSize } from "@/lib/file-helpers"; +import { formatSize, toDownloadUrl } from "@/lib/file-helpers"; +import { useMediaUrl } from "@/hooks/use-media-url"; import { Dialog, DialogContent, @@ -34,6 +35,15 @@ function isMediaKind(kind: string): "image" | "audio" | "video" | null { return null; } +/** Image with blob-cached src to prevent flickering on session switch. */ +function CachedImage({ src, alt, className, loading, onClick }: { + src: string; alt: string; className?: string; loading?: "lazy" | "eager"; + onClick?: () => void; +}) { + const cachedSrc = useMediaUrl(src); + return {alt}; +} + export function MediaGallery({ items }: MediaGalleryProps) { const [lightboxIdx, setLightboxIdx] = useState(null); const [preview, setPreview] = useState<{ @@ -78,7 +88,7 @@ export function MediaGallery({ items }: MediaGalleryProps) { onClick={() => setLightboxIdx(i)} className="block w-full cursor-pointer" > - {item.fileName e.stopPropagation()} className="shrink-0 rounded-lg bg-white/90 dark:bg-neutral-800/90 p-1.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 hover:bg-white dark:hover:bg-neutral-700 transition-colors cursor-pointer" @@ -124,7 +134,7 @@ export function MediaGallery({ items }: MediaGalleryProps) { )} e.stopPropagation()} @@ -158,7 +168,7 @@ export function MediaGallery({ items }: MediaGalleryProps) { {preview.name} diff --git a/ui/web/src/components/shared/image-lightbox.tsx b/ui/web/src/components/shared/image-lightbox.tsx index 525c0b94..cbdbeead 100644 --- a/ui/web/src/components/shared/image-lightbox.tsx +++ b/ui/web/src/components/shared/image-lightbox.tsx @@ -1,6 +1,7 @@ import { useEffect, useCallback } from "react"; import { X, Download } from "lucide-react"; -import { formatSize } from "@/lib/file-helpers"; +import { formatSize, toDownloadUrl } from "@/lib/file-helpers"; +import { useMediaUrl } from "@/hooks/use-media-url"; interface ImageLightboxProps { src: string; @@ -24,6 +25,7 @@ export function ImageLightbox({ src, alt, fileName, size, onClose }: ImageLightb }, [handleKeyDown]); const displayName = fileName || alt || "image"; + const cachedSrc = useMediaUrl(src); return (
e.stopPropagation()} className="rounded-full bg-white/90 dark:bg-neutral-800/90 p-2.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 hover:bg-white dark:hover:bg-neutral-700 transition-colors cursor-pointer" @@ -49,7 +51,7 @@ export function ImageLightbox({ src, alt, fileName, size, onClose }: ImageLightb
{alt e.stopPropagation()} diff --git a/ui/web/src/components/shared/markdown-renderer.tsx b/ui/web/src/components/shared/markdown-renderer.tsx index 76fa469f..1f619454 100644 --- a/ui/web/src/components/shared/markdown-renderer.tsx +++ b/ui/web/src/components/shared/markdown-renderer.tsx @@ -8,7 +8,8 @@ import rehypeHighlight from "rehype-highlight"; const remarkPlugins = [remarkGfm]; const rehypePlugins = [rehypeHighlight]; import { useClipboard } from "@/hooks/use-clipboard"; -import { toFileUrl } from "@/lib/file-helpers"; +import { toFileUrl, toDownloadUrl } from "@/lib/file-helpers"; +import { useMediaUrl } from "@/hooks/use-media-url"; import { Check, Copy, Download, FileText } from "lucide-react"; import { ImageLightbox } from "./image-lightbox"; import { @@ -85,6 +86,43 @@ function fileNameFromHref(href: string): string { return segments[segments.length - 1] ?? "file"; } +/** Extracted from useMemo components so useMediaUrl hook is valid. */ +function CachedMarkdownImg({ src, alt, openLightbox, ...props }: { + src?: string; alt?: string; + openLightbox: (src: string, alt: string) => void; + [key: string]: unknown; +}) { + const resolvedSrc = isFileLink(src) ? toFileUrl(src!) : src; + const cachedSrc = useMediaUrl(resolvedSrc); + const displayName = alt || fileNameFromHref(src ?? ""); + return ( + + {alt { + e.preventDefault(); + if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image"); + }} + {...props} + /> + {resolvedSrc && ( + e.stopPropagation()} + className="absolute top-2 right-2 flex items-center justify-center rounded-lg bg-white/90 dark:bg-neutral-800/90 p-1.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 opacity-0 transition-opacity group-hover/img:opacity-100 hover:bg-white dark:hover:bg-neutral-700 cursor-pointer" + title="Download" + > + + + )} + + ); +} + export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) { const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); const openLightbox = useCallback((src: string, alt: string) => setLightbox({ src, alt }), []); @@ -154,7 +192,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN {children} e.stopPropagation()} @@ -171,34 +209,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN ); }, img({ src, alt, ...props }: any) { - const resolvedSrc = isFileLink(src) ? toFileUrl(src!) : src; - const displayName = alt || fileNameFromHref(src ?? ""); - return ( - - {alt { - e.preventDefault(); - if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image"); - }} - {...props} - /> - {resolvedSrc && ( - e.stopPropagation()} - className="absolute top-2 right-2 flex items-center justify-center rounded-lg bg-white/90 dark:bg-neutral-800/90 p-1.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 opacity-0 transition-opacity group-hover/img:opacity-100 hover:bg-white dark:hover:bg-neutral-700 cursor-pointer" - title="Download" - > - - - )} - - ); + return ; }, table({ children, ...props }) { return ( @@ -262,7 +273,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN {filePreview.name} diff --git a/ui/web/src/hooks/use-media-url.ts b/ui/web/src/hooks/use-media-url.ts new file mode 100644 index 00000000..c669dcfb --- /dev/null +++ b/ui/web/src/hooks/use-media-url.ts @@ -0,0 +1,27 @@ +import { useState, useEffect } from "react"; +import { getOrFetchUrl } from "@/lib/media-cache"; + +/** Returns a cached blob ObjectURL for the given signed media URL. + * On first render returns the signed URL as-is (no blank frame), + * then swaps to blob URL once fetched. On subsequent renders with + * the same clean path, returns the cached blob instantly. */ +export function useMediaUrl(signedUrl: string | undefined): string | undefined { + const [url, setUrl] = useState(signedUrl); + + useEffect(() => { + if (!signedUrl) { + setUrl(undefined); + return; + } + // Show signed URL immediately (browser may already have it cached) + setUrl(signedUrl); + + let cancelled = false; + getOrFetchUrl(signedUrl).then((resolved) => { + if (!cancelled) setUrl(resolved); + }); + return () => { cancelled = true; }; + }, [signedUrl]); + + return url; +} diff --git a/ui/web/src/lib/media-cache.ts b/ui/web/src/lib/media-cache.ts new file mode 100644 index 00000000..e656b38b --- /dev/null +++ b/ui/web/src/lib/media-cache.ts @@ -0,0 +1,74 @@ +/** Client-side blob cache for media URLs. + * Prevents image flickering when switching chat sessions — + * backend generates fresh ?ft= tokens on each chat.history call, + * so the same file gets a different URL each time. + * Cache key = clean path (without ?ft=), value = blob ObjectURL with 5-min TTL. */ + +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes, matches backend FileTokenTTL + +interface CacheEntry { + objectUrl: string; + expiresAt: number; +} + +const cache = new Map(); +const inflight = new Map>(); + +/** Strip ?ft=... or &ft=... from URL, return clean path. */ +export function stripFt(url: string): string { + return url.replace(/[?&]ft=[^&\s)"'<>]*/g, "").replace(/[?&]$/, ""); +} + +/** Evict expired entries and revoke their ObjectURLs. */ +function evictExpired() { + const now = Date.now(); + for (const [key, entry] of cache) { + if (entry.expiresAt <= now) { + URL.revokeObjectURL(entry.objectUrl); + cache.delete(key); + } + } +} + +/** Get cached ObjectURL or fetch blob and cache it. + * Returns the blob ObjectURL on success, or the original signed URL on failure. */ +export async function getOrFetchUrl(signedUrl: string): Promise { + evictExpired(); + + const key = stripFt(signedUrl); + const cached = cache.get(key); + if (cached && cached.expiresAt > Date.now()) { + return cached.objectUrl; + } + + // Deduplicate concurrent fetches for the same key + const existing = inflight.get(key); + if (existing) return existing; + + const promise = (async () => { + try { + const res = await fetch(signedUrl); + if (!res.ok) return signedUrl; + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + cache.set(key, { objectUrl, expiresAt: Date.now() + CACHE_TTL_MS }); + return objectUrl; + } catch { + return signedUrl; // graceful degradation + } finally { + inflight.delete(key); + } + })(); + + inflight.set(key, promise); + return promise; +} + +/** Revoke all cached ObjectURLs (call on logout/disconnect). */ +export function revokeAll() { + for (const entry of cache.values()) { + URL.revokeObjectURL(entry.objectUrl); + } + cache.clear(); + inflight.clear(); +}