From 5b42d0506231096c2f404eb69eef6b9933e31b29 Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 26 Jan 2026 13:39:32 +0100 Subject: [PATCH] feat(app): add document (PDF) memory support Add complete document memory feature with encrypted storage and AI processing: - Upload PDFs with text extraction and thumbnail generation - Encrypted document storage using Fernet encryption - AI-powered title, summary, and tag generation - PDF viewer with pagination in memory detail panel - Open documents in system default application Code quality improvements: - Extract Poppler version to workflow variable - Create shared useDocumentUpload hook (DRY) - Add error logging to Electron temp file cleanup - Add empty PDF validation in backend - Simplify Vite PDF.js worker configuration --- .changeset/code-quality-fixes.md | 11 + .github/workflows/release.yml | 31 ++- .gitignore | 5 +- app/electron/main.js | 40 +++ app/electron/preload.js | 2 + app/package.json | 4 +- app/src/components/DocumentCard.tsx | 251 ++++++++++++++++++ app/src/components/MemoryDetailPanel.tsx | 318 ++++++++++++++++++++++- app/src/global.d.ts | 2 + app/src/hooks/useDocumentUpload.ts | 109 ++++++++ app/src/pages/HomePage.tsx | 46 +++- app/src/pages/MemoriesPage.tsx | 90 ++++++- app/src/types/chat.ts | 10 +- app/vite.config.ts | 29 ++- backend/app/db/crud.py | 145 +++++++++++ backend/app/db/migrations.py | 20 ++ backend/app/models.py | 5 + backend/app/routes/__init__.py | 2 + backend/app/routes/auth.py | 4 + backend/app/routes/document.py | 286 ++++++++++++++++++++ backend/app/services/ai_processing.py | 135 ++++++++++ backend/app/services/document_storage.py | 181 +++++++++++++ backend/app/services/document_utils.py | 101 +++++++ backend/pyproject.toml | 4 + backend/think.spec | 30 +++ pnpm-lock.yaml | 211 +++++++++++++++ 26 files changed, 2043 insertions(+), 29 deletions(-) create mode 100644 .changeset/code-quality-fixes.md create mode 100644 app/src/components/DocumentCard.tsx create mode 100644 app/src/hooks/useDocumentUpload.ts create mode 100644 backend/app/routes/document.py create mode 100644 backend/app/services/document_storage.py create mode 100644 backend/app/services/document_utils.py diff --git a/.changeset/code-quality-fixes.md b/.changeset/code-quality-fixes.md new file mode 100644 index 0000000..d627e48 --- /dev/null +++ b/.changeset/code-quality-fixes.md @@ -0,0 +1,11 @@ +--- +"think-app": patch +--- + +Fix code quality issues for release readiness + +- Extract Poppler version to workflow variable to prevent future breakage +- Create shared useDocumentUpload hook to reduce code duplication +- Add error logging to Electron temp file cleanup +- Add empty PDF validation to reject image-only/scanned PDFs +- Simplify Vite PDF.js worker configuration diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 891d1fe..9cbc90a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,8 +125,8 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true - - name: Install SQLCipher - run: brew install sqlcipher + - name: Install SQLCipher and Poppler + run: brew install sqlcipher poppler - name: Install pnpm dependencies run: pnpm install --frozen-lockfile @@ -240,6 +240,33 @@ jobs: } working-directory: backend + - name: Download Poppler for Windows + shell: pwsh + env: + POPPLER_VERSION: "24.08.0-0" + run: | + # Download Poppler binaries for PDF thumbnail generation + # From: https://github.com/oschwartz10612/poppler-windows/releases + $popplerUrl = "https://github.com/oschwartz10612/poppler-windows/releases/download/v$env:POPPLER_VERSION/Release-$env:POPPLER_VERSION.zip" + $archivePath = "$env:RUNNER_TEMP\poppler.zip" + $extractPath = "backend\poppler-windows" + + Write-Host "Downloading Poppler..." + Invoke-WebRequest -Uri $popplerUrl -OutFile $archivePath + + Write-Host "Extracting Poppler..." + Expand-Archive -Path $archivePath -DestinationPath $extractPath -Force + + # The archive extracts to poppler-24.08.0, move contents up + $extractedDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1 + if ($extractedDir) { + Get-ChildItem -Path $extractedDir.FullName | Move-Item -Destination $extractPath -Force + Remove-Item $extractedDir.FullName -Force + } + + Write-Host "Poppler installed to $extractPath" + Get-ChildItem -Path "$extractPath\Library\bin" -ErrorAction SilentlyContinue | Select-Object -First 10 Name + - name: Build backend run: pnpm build:backend diff --git a/.gitignore b/.gitignore index 70bcda6..ddd73fb 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,7 @@ extension/extension.pem backend/native_host/think-native-stub # FFmpeg (downloaded on first use, cached locally) -app/public/ffmpeg/ \ No newline at end of file +app/public/ffmpeg/ + +# PDF.js worker (copied by vite config at build time) +app/public/pdf.worker.min.mjs \ No newline at end of file diff --git a/app/electron/main.js b/app/electron/main.js index 31bed15..7d17384 100644 --- a/app/electron/main.js +++ b/app/electron/main.js @@ -639,3 +639,43 @@ ipcMain.handle('delete-temp-file', async (_event, filePath) => { return { success: false, error: error.message }; } }); + +// Open a document with the system's default viewer +ipcMain.handle('open-document-with-system', async (_event, documentId, filename) => { + try { + // Fetch the document from the backend with authentication + const response = await fetch(`http://localhost:8765/api/document/${documentId}/file`, { + headers: { 'X-App-Token': APP_TOKEN } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch document: ${response.status}`); + } + + // Write to temp file + const tempDir = app.getPath('temp'); + const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '_'); + const tempPath = path.join(tempDir, `think_doc_${Date.now()}_${safeName}`); + const buffer = await response.arrayBuffer(); + fs.writeFileSync(tempPath, Buffer.from(buffer)); + + // Open with system default application + const result = await shell.openPath(tempPath); + if (result) { + // shell.openPath returns empty string on success, error message on failure + throw new Error(result); + } + + // Schedule cleanup after 60 seconds (give app time to open and load) + setTimeout(() => { + fs.unlink(tempPath, (err) => { + if (err) console.warn('Failed to cleanup temp file:', tempPath, err.message); + }); + }, 60000); + + return { success: true }; + } catch (error) { + console.error('[IPC] open-document-with-system error:', error); + return { success: false, error: error.message }; + } +}); diff --git a/app/electron/preload.js b/app/electron/preload.js index 80a8759..e262748 100644 --- a/app/electron/preload.js +++ b/app/electron/preload.js @@ -62,4 +62,6 @@ contextBridge.exposeInMainWorld('electronAPI', { removeVideoProcessListeners: () => { ipcRenderer.removeAllListeners('video-process-progress'); }, + // Document viewing + openDocumentWithSystem: (documentId, filename) => ipcRenderer.invoke('open-document-with-system', documentId, filename), }); diff --git a/app/package.json b/app/package.json index 5781c2b..2f00793 100644 --- a/app/package.json +++ b/app/package.json @@ -74,7 +74,6 @@ } }, "dependencies": { - "ffmpeg-static": "^5.2.0", "@microsoft/fetch-event-source": "^2.0.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", @@ -86,10 +85,12 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "electron-updater": "^6.6.2", + "ffmpeg-static": "^5.2.0", "lucide-react": "^0.460.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", + "react-pdf": "^10.3.0", "react-router-dom": "^7.10.0", "sonner": "^2.0.7", "tailwind-merge": "^2.6.0" @@ -110,6 +111,7 @@ "tailwindcss": "^3.4.15", "typescript": "^5.6.3", "vite": "^6.0.1", + "vite-plugin-static-copy": "^3.1.6", "wait-on": "^8.0.1" } } diff --git a/app/src/components/DocumentCard.tsx b/app/src/components/DocumentCard.tsx new file mode 100644 index 0000000..7360c7a --- /dev/null +++ b/app/src/components/DocumentCard.tsx @@ -0,0 +1,251 @@ +import { useState, useRef, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + FileText, + X, + PanelRight, + Loader2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { getAppToken } from "@/lib/api"; +import { API_BASE_URL } from "@/constants"; + +interface MemoryTag { + id: number; + name: string; + source: "ai" | "manual"; +} + +interface DocumentMemory { + id: number; + type: "document"; + title: string; + content?: string; + summary: string | null; + tags: MemoryTag[]; + created_at: string; + document_format?: string; + document_page_count?: number; + thumbnail_path?: string; +} + +interface DocumentCardProps { + memory: DocumentMemory; + onRemoveTag: (memoryId: number, tagId: number) => void; + onExpand: (id: number) => void; + formatDate: (date: string) => string; +} + +function ProcessingStatusBadge({ + hasSummary, + hasContent, +}: { + hasSummary: boolean; + hasContent: boolean; +}) { + // No processing status needed if summary exists (fully processed) + if (hasSummary) return null; + + // Content exists but no summary yet - AI processing in progress + if (hasContent) { + return ( + + + Processing... + + ); + } + + // No content yet - still extracting text from document + return ( + + + Extracting... + + ); +} + +export function DocumentCard({ memory, onRemoveTag, onExpand, formatDate }: DocumentCardProps) { + const [thumbnailUrl, setThumbnailUrl] = useState(null); + const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false); + const thumbnailLoadedRef = useRef(false); + + // Load thumbnail on mount + useEffect(() => { + if (!memory.thumbnail_path || thumbnailLoadedRef.current) return; + + thumbnailLoadedRef.current = true; + setIsLoadingThumbnail(true); + + const token = getAppToken(); + fetch(`${API_BASE_URL}/api/document/${memory.id}/thumbnail`, { + headers: token ? { "X-App-Token": token } : {}, + }) + .then((response) => { + if (response.ok) return response.blob(); + throw new Error("Failed to load thumbnail"); + }) + .then((blob) => { + const url = URL.createObjectURL(blob); + setThumbnailUrl(url); + }) + .catch((error) => { + console.error("Failed to load thumbnail:", error); + }) + .finally(() => { + setIsLoadingThumbnail(false); + }); + + return () => { + if (thumbnailUrl) { + URL.revokeObjectURL(thumbnailUrl); + } + }; + }, [memory.id, memory.thumbnail_path]); + + // Cleanup blob URL on unmount + useEffect(() => { + return () => { + if (thumbnailUrl) { + URL.revokeObjectURL(thumbnailUrl); + } + }; + }, [thumbnailUrl]); + + return ( +
+ {/* Hover actions - top right */} +
+ +
+ + {/* Thumbnail */} +
onExpand(memory.id)} + > + {thumbnailUrl ? ( + {memory.title} + ) : isLoadingThumbnail ? ( +
+ +
+ ) : ( +
+ +
+ )} + + {/* Page count badge */} + {memory.document_page_count !== undefined && ( +
+ {memory.document_page_count} {memory.document_page_count === 1 ? "page" : "pages"} +
+ )} +
+ + {/* Header: Document icon + Title */} +
+
+ +
+

+ {memory.title || "Document"} +

+
+ + {/* Processing status */} +
+ +
+ + {/* Summary */} + {memory.summary ? ( +

+ {memory.summary} +

+ ) : ( +

+ Generating summary... +

+ )} + + {/* Tags */} + {memory.tags.length > 0 && ( +
+ {memory.tags.map((tag) => ( + + {tag.name} + {tag.source === "manual" && ( + + )} + + ))} +
+ )} + + {/* Date */} +

{formatDate(memory.created_at)}

+
+ ); +} diff --git a/app/src/components/MemoryDetailPanel.tsx b/app/src/components/MemoryDetailPanel.tsx index a1bfb63..cea404d 100644 --- a/app/src/components/MemoryDetailPanel.tsx +++ b/app/src/components/MemoryDetailPanel.tsx @@ -1,6 +1,9 @@ import { useState, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { useNavigate } from "react-router-dom"; +import { Document, Page, pdfjs } from "react-pdf"; +import "react-pdf/dist/Page/AnnotationLayer.css"; +import "react-pdf/dist/Page/TextLayer.css"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -28,7 +31,17 @@ import { Copy, SkipBack, SkipForward, + ExternalLink, + ChevronLeft, + ChevronRight, } from "lucide-react"; + +// Set up PDF.js worker - served from public folder (dev) or copied to dist (build) +// Use import.meta.url to resolve relative to the app base, works in both web and Electron +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + "/pdf.worker.min.mjs", + import.meta.url +).href; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { apiFetch, getAppToken } from "@/lib/api"; @@ -45,7 +58,7 @@ interface MemoryTag { interface Memory { id: number; - type: "web" | "note" | "voice_memo" | "audio" | "video" | "voice"; // "voice" for backwards compat + type: "web" | "note" | "voice_memo" | "audio" | "video" | "document" | "voice"; // "voice" for backwards compat url: string | null; title: string; content?: string; @@ -64,6 +77,9 @@ interface Memory { video_height?: number; thumbnail_path?: string; video_processing_status?: VideoProcessingStatus; + // Document-specific fields + document_format?: string; + document_page_count?: number; } interface Tag { @@ -130,6 +146,17 @@ export function MemoryDetailPanel({ const videoBlobUrlRef = useRef(null); const videoThumbnailUrlRef = useRef(null); + // Document viewer state + const [documentThumbnailUrl, setDocumentThumbnailUrl] = useState(null); + const [showDocumentContent, setShowDocumentContent] = useState(false); + const documentThumbnailUrlRef = useRef(null); + // PDF preview state + const [pdfData, setPdfData] = useState(null); + const [pdfNumPages, setPdfNumPages] = useState(0); + const [pdfPageNumber, setPdfPageNumber] = useState(1); + const [isPdfLoading, setIsPdfLoading] = useState(false); + const pdfBlobUrlRef = useRef(null); + const titleInputRef = useRef(null); const navigate = useNavigate(); const { startNewChat, addAttachedMemory } = useConversation(); @@ -215,6 +242,64 @@ export function MemoryDetailPanel({ }; }, [memory?.id, memory?.type, memory?.thumbnail_path]); + // Fetch document thumbnail when memory has one + useEffect(() => { + if (!memory || memory.type !== "document" || !memory.thumbnail_path) { + return; + } + + let isCancelled = false; + let blobUrl: string | null = null; + + const fetchThumbnail = async () => { + try { + const token = getAppToken(); + const response = await fetch(`${API_BASE_URL}/api/document/${memory.id}/thumbnail`, { + headers: token ? { "X-App-Token": token } : {}, + }); + + if (!response.ok) { + throw new Error("Failed to load thumbnail"); + } + + const blob = await response.blob(); + + // Check if component was unmounted during fetch + if (isCancelled) { + return; + } + + blobUrl = URL.createObjectURL(blob); + + // Revoke previous URL if exists + if (documentThumbnailUrlRef.current) { + URL.revokeObjectURL(documentThumbnailUrlRef.current); + } + + documentThumbnailUrlRef.current = blobUrl; + setDocumentThumbnailUrl(blobUrl); + } catch (error) { + if (!isCancelled) { + console.error("Failed to load document thumbnail:", error); + } + } + }; + + fetchThumbnail(); + + return () => { + isCancelled = true; + // Revoke both the ref URL and any URL created during this effect + if (documentThumbnailUrlRef.current) { + URL.revokeObjectURL(documentThumbnailUrlRef.current); + documentThumbnailUrlRef.current = null; + } + if (blobUrl && blobUrl !== documentThumbnailUrlRef.current) { + URL.revokeObjectURL(blobUrl); + } + }; + }, [memory?.id, memory?.type, memory?.thumbnail_path]); + // Reset editing state when panel closes useEffect(() => { if (!isOpen) { @@ -725,6 +810,33 @@ export function MemoryDetailPanel({ return `${minutes}:${secs.toString().padStart(2, "0")}`; }; + // Load PDF file for preview + const loadPdfFile = async (documentId: number) => { + if (isPdfLoading) return; + setIsPdfLoading(true); + try { + const token = getAppToken(); + const response = await fetch(`${API_BASE_URL}/api/document/${documentId}/file`, { + headers: token ? { "X-App-Token": token } : {}, + }); + if (!response.ok) { + throw new Error(`Failed to load PDF: ${response.status}`); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + if (pdfBlobUrlRef.current) { + URL.revokeObjectURL(pdfBlobUrlRef.current); + } + pdfBlobUrlRef.current = url; + setPdfData(url); + } catch (error) { + console.error("Failed to load PDF:", error); + toast.error("Failed to load PDF"); + } finally { + setIsPdfLoading(false); + } + }; + // Reset audio/video player when memory changes and cleanup blob URLs useEffect(() => { // Audio cleanup @@ -761,6 +873,21 @@ export function MemoryDetailPanel({ setVideoCurrentTime(0); setVideoDuration(0); setVideoPlaybackRate(1); + + // Document cleanup + if (documentThumbnailUrlRef.current) { + URL.revokeObjectURL(documentThumbnailUrlRef.current); + documentThumbnailUrlRef.current = null; + } + if (pdfBlobUrlRef.current) { + URL.revokeObjectURL(pdfBlobUrlRef.current); + pdfBlobUrlRef.current = null; + } + setDocumentThumbnailUrl(null); + setShowDocumentContent(false); + setPdfData(null); + setPdfNumPages(0); + setPdfPageNumber(1); }, [memoryId]); // Cleanup blob URLs on unmount @@ -775,6 +902,12 @@ export function MemoryDetailPanel({ if (videoThumbnailUrlRef.current) { URL.revokeObjectURL(videoThumbnailUrlRef.current); } + if (documentThumbnailUrlRef.current) { + URL.revokeObjectURL(documentThumbnailUrlRef.current); + } + if (pdfBlobUrlRef.current) { + URL.revokeObjectURL(pdfBlobUrlRef.current); + } }; }, []); @@ -840,11 +973,16 @@ export function MemoryDetailPanel({ ) : memory.type === "video" ? (