From 419d29dbe85643f14e804fda3e3abcb835e1a89c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Wed, 14 Jan 2026 20:13:13 +1100 Subject: [PATCH] sync: PDF file upload from private repo - Add drag-and-drop PDF upload to Quick Capture - New upload endpoint with size limits (warn 10MB, reject 50MB) - Add extractFromBuffer method to PaperExtractor - Visual feedback: drag-over highlight, file preview card Co-Authored-By: Claude Opus 4.5 --- app/api/extract/pdf/upload/route.ts | 104 ++++++ src/components/agents/QuickAddInput.tsx | 334 ++++++++++++++++---- src/services/typescript/extractors/paper.ts | 42 +++ 3 files changed, 427 insertions(+), 53 deletions(-) create mode 100644 app/api/extract/pdf/upload/route.ts diff --git a/app/api/extract/pdf/upload/route.ts b/app/api/extract/pdf/upload/route.ts new file mode 100644 index 0000000..53d8d78 --- /dev/null +++ b/app/api/extract/pdf/upload/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { PaperExtractor } from '@/services/typescript/extractors/paper'; + +export const runtime = 'nodejs'; + +// Size limits in bytes +const WARN_SIZE = 10 * 1024 * 1024; // 10MB +const MAX_SIZE = 50 * 1024 * 1024; // 50MB + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const file = formData.get('file'); + + // Validate file presence + if (!file || !(file instanceof File)) { + return NextResponse.json( + { success: false, error: 'PDF file is required' }, + { status: 400 } + ); + } + + // Validate MIME type + if (file.type !== 'application/pdf') { + return NextResponse.json( + { success: false, error: `Invalid file type: ${file.type}. Only PDF files are accepted.` }, + { status: 400 } + ); + } + + // Check file size + if (file.size > MAX_SIZE) { + return NextResponse.json( + { success: false, error: `File too large (${Math.round(file.size / 1024 / 1024)}MB). Maximum size is 50MB.` }, + { status: 413 } + ); + } + + const isLargeFile = file.size > WARN_SIZE; + + // Get buffer from file + const buffer = Buffer.from(await file.arrayBuffer()); + + // Extract PDF content using PaperExtractor + const extractor = new PaperExtractor(); + const extraction = await extractor.extractFromBuffer(buffer, file.name); + + // Derive title from metadata or filename + const title = extraction.metadata.title || file.name.replace(/\.pdf$/i, ''); + + // Create node via internal API call + // IMPORTANT: Use request.url origin for packaged Tauri app compatibility + const nodeApiUrl = new URL('/api/nodes', request.url); + + const createResponse = await fetch(nodeApiUrl.toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + type: 'pdf', + // Keep content readable in UI (not the full extracted text) + content: `Imported PDF: ${file.name} (${extraction.metadata.pages} pages, ${Math.round(extraction.metadata.text_length / 1000)}k characters)`, + // Full extracted text goes in chunk for universal chunking/embedding + chunk: extraction.chunk, + metadata: { + source: 'pdf_upload', + original_filename: file.name, + pages: extraction.metadata.pages, + text_length: extraction.metadata.text_length, + extraction_method: extraction.metadata.extraction_method, + imported_at: new Date().toISOString(), + }, + }), + }); + + if (!createResponse.ok) { + const errorData = await createResponse.json().catch(() => ({})); + throw new Error(errorData.error || `Failed to create node: ${createResponse.status}`); + } + + const nodeResult = await createResponse.json(); + + if (!nodeResult.success || !nodeResult.data?.id) { + throw new Error(nodeResult.error || 'Failed to create node'); + } + + return NextResponse.json({ + success: true, + nodeId: nodeResult.data.id, + title, + pages: extraction.metadata.pages, + textLength: extraction.metadata.text_length, + warning: isLargeFile ? `Large file (${Math.round(file.size / 1024 / 1024)}MB) - processing may take longer` : undefined, + }); + + } catch (error) { + console.error('[PDF Upload API] Error:', error); + const message = error instanceof Error ? error.message : 'Failed to process PDF upload'; + return NextResponse.json( + { success: false, error: message }, + { status: 500 } + ); + } +} diff --git a/src/components/agents/QuickAddInput.tsx b/src/components/agents/QuickAddInput.tsx index c198dcb..a55bb1c 100644 --- a/src/components/agents/QuickAddInput.tsx +++ b/src/components/agents/QuickAddInput.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import type { AgentDelegation } from '@/services/agents/delegation'; interface QuickAddSubmitPayload { @@ -18,6 +18,9 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI const [input, setInput] = useState(''); const [isPosting, setIsPosting] = useState(false); const [isExpanded, setIsExpanded] = useState(false); + const [dragOver, setDragOver] = useState(false); + const [uploadedFile, setUploadedFile] = useState(null); + const [uploadError, setUploadError] = useState(null); const maxConcurrent = 5; const activeCount = activeDelegations.filter( @@ -25,7 +28,51 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI ).length; const isSoftLimited = activeCount >= maxConcurrent; + const handleFileUpload = useCallback(async (file: File) => { + setIsPosting(true); + setUploadError(null); + + try { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/api/extract/pdf/upload', { + method: 'POST', + body: formData, + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || 'Upload failed'); + } + + // Success - clear state + setUploadedFile(null); + setInput(''); + setIsExpanded(false); + + // Show warning if present (large file) + if (result.warning) { + console.log('[QuickAddInput] Upload warning:', result.warning); + } + + } catch (error) { + console.error('[QuickAddInput] Upload error:', error); + setUploadError(error instanceof Error ? error.message : 'Upload failed'); + } finally { + setIsPosting(false); + } + }, []); + const handleSubmit = async () => { + // If there's a file, upload it + if (uploadedFile) { + await handleFileUpload(uploadedFile); + return; + } + + // Otherwise, submit text as before if (!input.trim() || isPosting || isSoftLimited) return; setIsPosting(true); @@ -52,9 +99,56 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI if (e.key === 'Escape') { setIsExpanded(false); setInput(''); + setUploadedFile(null); + setUploadError(null); } }; + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + setUploadError(null); + + const file = e.dataTransfer?.files[0]; + if (!file) return; + + // Check if it's a PDF + if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) { + setUploadError('Only PDF files are supported'); + return; + } + + // Check size (50MB limit) + const MAX_SIZE = 50 * 1024 * 1024; + if (file.size > MAX_SIZE) { + setUploadError(`File too large (${Math.round(file.size / 1024 / 1024)}MB). Maximum is 50MB.`); + return; + } + + setUploadedFile(file); + setInput(''); // Clear text input when file is dropped + }, []); + + const clearFile = useCallback(() => { + setUploadedFile(null); + setUploadError(null); + }, []); + + const hasContent = input.trim() || uploadedFile; + // Collapsed state - prominent "ADD STUFF" button if (!isExpanded) { return ( @@ -109,21 +203,27 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI // Expanded state - full width overlay return ( -
+
{/* Header */}
{ setIsExpanded(false); setInput(''); + setUploadedFile(null); + setUploadError(null); }} style={{ padding: '6px', @@ -182,48 +284,174 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
- {/* Input area - expanded */} -
-