Failed to load image
@@ -43,7 +72,7 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {

+ return (
+
+ )
case "tweet":
return (
diff --git a/apps/web/components/document-modal/content/pdf.tsx b/apps/web/components/document-modal/content/pdf.tsx
index e633db86..ca8d6f90 100644
--- a/apps/web/components/document-modal/content/pdf.tsx
+++ b/apps/web/components/document-modal/content/pdf.tsx
@@ -1,23 +1,55 @@
"use client"
import { Document, Page, pdfjs } from "react-pdf"
-import { useCallback, useMemo, useState } from "react"
+import { useEffect, useMemo, useRef, useState } from "react"
import "react-pdf/dist/Page/AnnotationLayer.css"
import "react-pdf/dist/Page/TextLayer.css"
+import { getCachedFileBlob } from "@/lib/file-cache"
-// Configure PDF.js worker to use local package
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString()
+type FileSource = string | { url: string; withCredentials: boolean } | null
+
interface PdfViewerProps {
url: string | null | undefined
documentId?: string | null
}
export function PdfViewer({ url, documentId }: PdfViewerProps) {
- const fileSource = useMemo(() => {
+ const [cachedUrl, setCachedUrl] = useState
(null)
+ const [cacheChecked, setCacheChecked] = useState(false)
+ const objectUrlRef = useRef(null)
+
+ useEffect(() => {
+ let revoked = false
+ if (!documentId) {
+ setCacheChecked(true)
+ return
+ }
+
+ getCachedFileBlob(documentId).then((blob) => {
+ if (revoked) return
+ if (blob) {
+ const objUrl = URL.createObjectURL(blob)
+ objectUrlRef.current = objUrl
+ setCachedUrl(objUrl)
+ }
+ setCacheChecked(true)
+ })
+
+ return () => {
+ revoked = true
+ if (objectUrlRef.current) {
+ URL.revokeObjectURL(objectUrlRef.current)
+ objectUrlRef.current = null
+ }
+ }
+ }, [documentId])
+
+ const remoteFileSource: FileSource = useMemo(() => {
if (!url) return null
try {
if (new URL(url).hostname === "www.googleapis.com" && documentId) {
@@ -32,12 +64,34 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
return url
}, [url, documentId])
+ const backendProxySource: FileSource = useMemo(() => {
+ if (!documentId) return null
+ const base =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+ return { url: `${base}/v3/file-proxy/${documentId}`, withCredentials: true }
+ }, [documentId])
+
const [numPages, setNumPages] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
- const [retryKey, setRetryKey] = useState(0)
+ const [failedSources, setFailedSources] = useState(0)
- if (!url) {
+ const fileSource = useMemo((): FileSource => {
+ if (cachedUrl) return cachedUrl
+ if (failedSources === 0) return remoteFileSource
+ if (failedSources === 1 && backendProxySource) return backendProxySource
+ return null
+ }, [cachedUrl, failedSources, remoteFileSource, backendProxySource])
+
+ if (!cacheChecked) {
+ return (
+
+ Loading PDF…
+
+ )
+ }
+
+ if (!url && !cachedUrl) {
return (
No PDF URL provided
@@ -51,24 +105,26 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
setError(null)
}
- // On first failure, wait briefly then force a re-mount of the Document
- // component to retry (covers transient R2 timing issues).
- // On second failure, give up and show the error state.
- const onDocumentLoadError = useCallback(
- (err: Error) => {
- if (retryKey === 0) {
- setTimeout(() => {
- setRetryKey(1)
- setLoading(true)
- setError(null)
- }, 500)
- return
- }
+ function onDocumentLoadError(err: Error) {
+ if (cachedUrl) {
setError(err.message || "Failed to load PDF")
setLoading(false)
- },
- [retryKey],
- )
+ return
+ }
+
+ const nextFailed = failedSources + 1
+ const hasMoreSources =
+ (nextFailed === 1 && backendProxySource !== null) || nextFailed < 1
+
+ if (hasMoreSources) {
+ setFailedSources(nextFailed)
+ setLoading(true)
+ setError(null)
+ } else {
+ setError(err.message || "Failed to load PDF")
+ setLoading(false)
+ }
+ }
return (
@@ -82,35 +138,33 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
Error: {error}
)}
-
-
- {numPages && (
-
- {Array.from(new Array(numPages), (_, index) => (
-
- ))}
-
- )}
-
-
+ {fileSource && (
+
+
+ {numPages && (
+
+ {Array.from(new Array(numPages), (_, index) => (
+
+ ))}
+
+ )}
+
+
+ )}
)
}
diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts
index 132ebeb3..f9f1dfee 100644
--- a/apps/web/lib/analytics.ts
+++ b/apps/web/lib/analytics.ts
@@ -122,6 +122,9 @@ export const analytics = {
// chat analytics
chatMessageSent: (props: {
source: "typed" | "suggested" | "highlight" | "home"
+ attachment_count?: number
+ saved_attachment_count?: number
+ temporary_attachment_count?: number
}) => safeCapture("chat_message_sent", props),
chatSuggestedQuestionClicked: () =>
diff --git a/apps/web/lib/file-cache.ts b/apps/web/lib/file-cache.ts
new file mode 100644
index 00000000..d503d0e5
--- /dev/null
+++ b/apps/web/lib/file-cache.ts
@@ -0,0 +1,51 @@
+import { createStore, get, set, del } from "idb-keyval"
+
+const fileCacheStore = createStore("supermemory-file-cache", "blobs")
+
+interface CachedFile {
+ blob: Blob
+ mimeType: string
+}
+
+export async function cacheFileBlob(
+ documentId: string,
+ blob: Blob,
+ mimeType: string,
+): Promise {
+ try {
+ await set(
+ documentId,
+ { blob, mimeType } satisfies CachedFile,
+ fileCacheStore,
+ )
+ } catch {
+ // Storage full or unavailable — non-critical, skip silently
+ }
+}
+
+export async function getCachedFileBlob(
+ documentId: string,
+): Promise {
+ try {
+ const cached = await get(documentId, fileCacheStore)
+ return cached?.blob ?? null
+ } catch {
+ return null
+ }
+}
+
+export async function getCachedFileUrl(
+ documentId: string,
+): Promise {
+ const blob = await getCachedFileBlob(documentId)
+ if (!blob) return null
+ return URL.createObjectURL(blob)
+}
+
+export async function removeCachedFile(documentId: string): Promise {
+ try {
+ await del(documentId, fileCacheStore)
+ } catch {
+ // non-critical
+ }
+}