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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
2cbc950fd4
commit
419d29dbe8
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||||
|
|
||||||
interface QuickAddSubmitPayload {
|
interface QuickAddSubmitPayload {
|
||||||
@@ -18,6 +18,9 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [isPosting, setIsPosting] = useState(false);
|
const [isPosting, setIsPosting] = useState(false);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
|
||||||
const maxConcurrent = 5;
|
const maxConcurrent = 5;
|
||||||
const activeCount = activeDelegations.filter(
|
const activeCount = activeDelegations.filter(
|
||||||
@@ -25,7 +28,51 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
).length;
|
).length;
|
||||||
const isSoftLimited = activeCount >= maxConcurrent;
|
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 () => {
|
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;
|
if (!input.trim() || isPosting || isSoftLimited) return;
|
||||||
|
|
||||||
setIsPosting(true);
|
setIsPosting(true);
|
||||||
@@ -52,9 +99,56 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
setInput('');
|
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
|
// Collapsed state - prominent "ADD STUFF" button
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
return (
|
return (
|
||||||
@@ -109,21 +203,27 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
|
|
||||||
// Expanded state - full width overlay
|
// Expanded state - full width overlay
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
position: 'absolute',
|
style={{
|
||||||
top: '60px',
|
position: 'absolute',
|
||||||
left: '20px',
|
top: '60px',
|
||||||
right: '20px',
|
left: '20px',
|
||||||
display: 'flex',
|
right: '20px',
|
||||||
flexDirection: 'column',
|
display: 'flex',
|
||||||
gap: '12px',
|
flexDirection: 'column',
|
||||||
background: '#0a0a0a',
|
gap: '12px',
|
||||||
padding: '16px',
|
background: '#0a0a0a',
|
||||||
borderRadius: '12px',
|
padding: '16px',
|
||||||
border: '1px solid #2a2a2a',
|
borderRadius: '12px',
|
||||||
zIndex: 100,
|
border: dragOver ? '1px solid #22c55e' : '1px solid #2a2a2a',
|
||||||
animation: 'fadeIn 150ms ease-out'
|
zIndex: 100,
|
||||||
}}>
|
animation: 'fadeIn 150ms ease-out',
|
||||||
|
transition: 'border-color 0.15s ease'
|
||||||
|
}}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -160,6 +260,8 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
setInput('');
|
setInput('');
|
||||||
|
setUploadedFile(null);
|
||||||
|
setUploadError(null);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px',
|
padding: '6px',
|
||||||
@@ -182,48 +284,174 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input area - expanded */}
|
{/* File preview (when a file is dropped) */}
|
||||||
<div style={{ position: 'relative' }}>
|
{uploadedFile && (
|
||||||
<textarea
|
<div style={{
|
||||||
value={input}
|
display: 'flex',
|
||||||
onChange={(e) => setInput(e.target.value)}
|
alignItems: 'center',
|
||||||
onKeyDown={handleKeyDown}
|
gap: '12px',
|
||||||
placeholder="Paste a URL, note, or transcript — RA-H will figure out what it is."
|
padding: '12px 14px',
|
||||||
disabled={isPosting || isSoftLimited}
|
background: '#0f1a0f',
|
||||||
autoFocus
|
border: '1px solid #1a3a1a',
|
||||||
|
borderRadius: '10px',
|
||||||
|
}}>
|
||||||
|
{/* PDF icon */}
|
||||||
|
<div style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0
|
||||||
|
}}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" strokeLinecap="round"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File info */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
color: '#e5e5e5',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}>
|
||||||
|
{uploadedFile.name}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '11px',
|
||||||
|
marginTop: '2px'
|
||||||
|
}}>
|
||||||
|
{uploadedFile.size < 1024 * 1024
|
||||||
|
? `${Math.round(uploadedFile.size / 1024)} KB`
|
||||||
|
: `${(uploadedFile.size / 1024 / 1024).toFixed(1)} MB`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove button */}
|
||||||
|
<button
|
||||||
|
onClick={clearFile}
|
||||||
|
style={{
|
||||||
|
padding: '6px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#666',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '6px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'color 0.15s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{uploadError && (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#ef4444',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}>
|
||||||
|
{uploadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input area - show if no file uploaded */}
|
||||||
|
{!uploadedFile && (
|
||||||
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
position: 'relative',
|
||||||
minHeight: '140px',
|
|
||||||
maxHeight: '300px',
|
|
||||||
padding: '14px 16px',
|
|
||||||
background: '#0a0a0a',
|
|
||||||
border: '1px solid #1f1f1f',
|
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
color: '#e5e5e5',
|
border: dragOver ? '2px dashed #22c55e' : '1px solid #1f1f1f',
|
||||||
fontSize: '14px',
|
background: dragOver ? 'rgba(34, 197, 94, 0.05)' : '#0a0a0a',
|
||||||
fontFamily: 'inherit',
|
transition: 'all 0.15s ease'
|
||||||
outline: 'none',
|
|
||||||
resize: 'none',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
transition: 'border-color 0.15s ease'
|
|
||||||
}}
|
}}
|
||||||
onFocus={(e) => {
|
>
|
||||||
e.currentTarget.style.borderColor = '#333';
|
{/* Drag overlay */}
|
||||||
}}
|
{dragOver && (
|
||||||
onBlur={(e) => {
|
<div style={{
|
||||||
e.currentTarget.style.borderColor = '#1f1f1f';
|
position: 'absolute',
|
||||||
}}
|
inset: 0,
|
||||||
/>
|
display: 'flex',
|
||||||
</div>
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'rgba(34, 197, 94, 0.05)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
zIndex: 10,
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
color: '#22c55e'
|
||||||
|
}}>
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 500 }}>Drop PDF here</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Paste a URL, note, or transcript — or drop a PDF file"
|
||||||
|
disabled={isPosting || isSoftLimited}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '140px',
|
||||||
|
maxHeight: '300px',
|
||||||
|
padding: '14px 16px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#e5e5e5',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
outline: 'none',
|
||||||
|
resize: 'none',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
opacity: dragOver ? 0.3 : 1,
|
||||||
|
transition: 'opacity 0.15s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<span style={{ fontSize: '11px', color: '#525252' }}>
|
<span style={{ fontSize: '11px', color: '#525252' }}>
|
||||||
⌘↵ to submit · esc to close
|
{uploadedFile ? 'Ready to upload' : '\u2318\u21B5 to submit \u00B7 esc to close'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!input.trim() || isPosting || isSoftLimited}
|
disabled={!hasContent || isPosting || isSoftLimited}
|
||||||
style={{
|
style={{
|
||||||
width: '38px',
|
width: '38px',
|
||||||
height: '38px',
|
height: '38px',
|
||||||
@@ -231,15 +459,15 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: '0',
|
padding: '0',
|
||||||
background: input.trim() && !isPosting ? '#22c55e' : '#262626',
|
background: hasContent && !isPosting ? '#22c55e' : '#262626',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
cursor: input.trim() && !isPosting ? 'pointer' : 'not-allowed',
|
cursor: hasContent && !isPosting ? 'pointer' : 'not-allowed',
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
boxShadow: input.trim() && !isPosting ? '0 0 0 0 rgba(34, 197, 94, 0)' : 'none'
|
boxShadow: hasContent && !isPosting ? '0 0 0 0 rgba(34, 197, 94, 0)' : 'none'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (input.trim() && !isPosting) {
|
if (hasContent && !isPosting) {
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(34, 197, 94, 0.3)';
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(34, 197, 94, 0.3)';
|
||||||
}
|
}
|
||||||
@@ -259,7 +487,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
|||||||
animation: 'spin 0.8s linear infinite'
|
animation: 'spin 0.8s linear infinite'
|
||||||
}} />
|
}} />
|
||||||
) : (
|
) : (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={input.trim() ? '#0a0a0a' : '#525252'} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={hasContent ? '#0a0a0a' : '#525252'} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M12 19V5"/>
|
<path d="M12 19V5"/>
|
||||||
<path d="M5 12l7-7 7 7"/>
|
<path d="M5 12l7-7 7 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ interface ExtractionResult {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BufferExtractionResult {
|
||||||
|
content: string;
|
||||||
|
chunk: string;
|
||||||
|
metadata: PaperMetadata;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
type PdfJsTextItem = {
|
type PdfJsTextItem = {
|
||||||
str?: string;
|
str?: string;
|
||||||
};
|
};
|
||||||
@@ -341,6 +348,41 @@ export class PaperExtractor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text from a PDF buffer (for file uploads)
|
||||||
|
* @param buffer - The PDF file contents as a Buffer
|
||||||
|
* @param filename - Optional original filename for metadata
|
||||||
|
*/
|
||||||
|
async extractFromBuffer(buffer: Buffer, filename?: string): Promise<BufferExtractionResult> {
|
||||||
|
// Validate PDF header
|
||||||
|
if (!this.isLikelyPdf(buffer)) {
|
||||||
|
const preview = buffer.slice(0, 64).toString('utf8');
|
||||||
|
throw new Error(`File does not appear to be a valid PDF (header: ${preview.substring(0, 20)}...)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text and metadata using existing private method
|
||||||
|
const { text, metadata } = await this.extractFromPDF(buffer);
|
||||||
|
|
||||||
|
// Clean the text
|
||||||
|
const cleanedText = this.cleanAcademicContent(text);
|
||||||
|
|
||||||
|
// Add filename to metadata
|
||||||
|
metadata.filename = filename || 'uploaded.pdf';
|
||||||
|
if (!metadata.extraction_method) {
|
||||||
|
metadata.extraction_method = 'typescript_pdf-parse';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format content for display
|
||||||
|
const content = this.formatContent(metadata, cleanedText);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
chunk: cleanedText,
|
||||||
|
metadata,
|
||||||
|
filename: metadata.filename,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private formatErrorMessage(error: unknown): string {
|
private formatErrorMessage(error: unknown): string {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return error.message;
|
return error.message;
|
||||||
|
|||||||
Reference in New Issue
Block a user