From 1eeee575d88d5984f548f8b96c689637b287c162 Mon Sep 17 00:00:00 2001 From: Phillip Carter Date: Thu, 19 Mar 2026 09:49:48 -0700 Subject: [PATCH] add PDF support --- .github/workflows/ci.yml | 6 ++++++ app/page.tsx | 43 +++++++++++++++++++++++++++++++--------- lib/chat.ts | 23 ++++++++++++++++----- next.config.ts | 3 ++- tests/unit.test.ts | 23 +++++++++++++++++++++ 5 files changed, 83 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 839b586..fd37c41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,5 +18,11 @@ jobs: with: node-version: 22 cache: pnpm + - name: Cache Next.js build + uses: actions/cache@v4 + with: + path: .next/cache + key: nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.ts', '**/*.tsx', '**/*.css') }} + restore-keys: nextjs-${{ hashFiles('pnpm-lock.yaml') }}- - run: pnpm install --frozen-lockfile - run: pnpm test diff --git a/app/page.tsx b/app/page.tsx index a3e2edd..5e2edfc 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,7 +8,8 @@ import { getOrCreateKey, encrypt, decrypt } from '@/lib/crypto'; type Role = 'user' | 'assistant'; type Image = { data: string; mimeType: string }; // base64, no prefix type PendingFile = { name: string; content: string }; // text/code files -type Message = { role: Role; content: string; html?: string; images?: Image[] }; +type PendingPdf = { name: string; data: string }; // base64 PDF +type Message = { role: Role; content: string; html?: string; images?: Image[]; pdfs?: PendingPdf[] }; const MODELS = [ { id: 'gpt-5.4', label: 'GPT-5.4', provider: 'openai' }, @@ -57,6 +58,17 @@ function readTextFiles(files: File[], onEach: (f: PendingFile) => void) { }); } +function readPdfFiles(files: File[], onEach: (f: PendingPdf) => void) { + files.forEach(file => { + const reader = new FileReader(); + reader.onload = () => { + const data = (reader.result as string).split(',')[1]; + onEach({ name: file.name, data }); + }; + reader.readAsDataURL(file); + }); +} + export default function Home() { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); @@ -68,6 +80,7 @@ export default function Home() { const [showSettings, setShowSettings] = useState(false); const [pendingImages, setPendingImages] = useState([]); const [pendingFiles, setPendingFiles] = useState([]); + const [pendingPdfs, setPendingPdfs] = useState([]); const [shareLabel, setShareLabel] = useState('[SHARE]'); const [showScrollBtn, setShowScrollBtn] = useState(false); const [copiedMsgIndex, setCopiedMsgIndex] = useState(null); @@ -283,8 +296,10 @@ export default function Home() { const handleFileSelect = (e: React.ChangeEvent) => { const all = Array.from(e.target.files ?? []); const imgs = all.filter(f => f.type.startsWith('image/')); - const texts = all.filter(f => !f.type.startsWith('image/')); - readImageFiles(imgs, img => setPendingImages(imgs => [...imgs, img])); + const pdfs = all.filter(f => f.type === 'application/pdf'); + const texts = all.filter(f => !f.type.startsWith('image/') && f.type !== 'application/pdf'); + readImageFiles(imgs, img => setPendingImages(imgs => [...imgs, img])); + readPdfFiles(pdfs, pdf => setPendingPdfs(ps => [...ps, pdf])); readTextFiles(texts, f => setPendingFiles(fs => [...fs, f])); e.target.value = ''; }; @@ -293,8 +308,10 @@ export default function Home() { e.preventDefault(); const all = Array.from(e.dataTransfer.files); const imgs = all.filter(f => f.type.startsWith('image/')); - const texts = all.filter(f => !f.type.startsWith('image/')); - readImageFiles(imgs, img => setPendingImages(imgs => [...imgs, img])); + const pdfs = all.filter(f => f.type === 'application/pdf'); + const texts = all.filter(f => !f.type.startsWith('image/') && f.type !== 'application/pdf'); + readImageFiles(imgs, img => setPendingImages(imgs => [...imgs, img])); + readPdfFiles(pdfs, pdf => setPendingPdfs(ps => [...ps, pdf])); readTextFiles(texts, f => setPendingFiles(fs => [...fs, f])); }; @@ -431,7 +448,7 @@ export default function Home() { const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); - if ((!input.trim() && pendingImages.length === 0 && pendingFiles.length === 0) || streaming) return; + if ((!input.trim() && pendingImages.length === 0 && pendingFiles.length === 0 && pendingPdfs.length === 0) || streaming) return; textareaRef.current?.focus(); @@ -451,11 +468,13 @@ export default function Home() { role: 'user', content: fullContent, ...(pendingImages.length > 0 ? { images: pendingImages } : {}), + ...(pendingPdfs.length > 0 ? { pdfs: pendingPdfs } : {}), }; const currentWebSearch = webSearch; setInput(''); setPendingImages([]); setPendingFiles([]); + setPendingPdfs([]); setWebSearch(false); if (textareaRef.current) textareaRef.current.style.height = 'auto'; @@ -704,7 +723,7 @@ export default function Home() { onDragOver={(e) => e.preventDefault()} onDrop={handleDrop} > - {(pendingImages.length > 0 || pendingFiles.length > 0) && ( + {(pendingImages.length > 0 || pendingFiles.length > 0 || pendingPdfs.length > 0) && (
{pendingImages.map((img, i) => (
@@ -712,6 +731,12 @@ export default function Home() {
))} + {pendingPdfs.map((p, i) => ( +
+ 📄 {p.name} + +
+ ))} {pendingFiles.map((f, i) => (
{f.name} @@ -734,7 +759,7 @@ export default function Home() {