diff --git a/.gitignore b/.gitignore index 8cbfc60..6289889 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ next-env.d.ts # documents /docstore +# auth token storage +/.data + # Playwright node_modules/ /tests/results/ diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts new file mode 100644 index 0000000..94abe7d --- /dev/null +++ b/src/app/api/auth/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthToken } from '@/lib/auth'; + +export async function GET(request: NextRequest) { + const token = request.nextUrl.searchParams.get('token'); + const returnTo = request.nextUrl.searchParams.get('returnTo') || '/'; + + if (token !== getAuthToken()) { + return NextResponse.json({ error: 'Invalid token' }, { status: 401 }); + } + + const response = NextResponse.redirect(new URL(returnTo, request.url)); + response.cookies.set('auth_session', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, + path: '/', + }); + return response; +} diff --git a/src/app/api/summarize/route.ts b/src/app/api/summarize/route.ts index c6245a5..d151e00 100644 --- a/src/app/api/summarize/route.ts +++ b/src/app/api/summarize/route.ts @@ -22,6 +22,21 @@ Do not include any preamble like "Here is a summary" - just provide the summary Provide a comprehensive summary of the entire document content provided. Structure your summary with key themes, main arguments, and important conclusions. For longer texts, organize the summary into logical sections. +Do not include any preamble like "Here is a summary" - just provide the summary directly.`, + + // For summarizing individual chunks of a large document + chunk: `You are a helpful assistant that summarizes text content. +This is a portion of a larger document. Provide a detailed summary of this section. +Capture all key information, arguments, and details as they may be needed for the final summary. +Focus on factual content and main points. Be thorough but concise. +Do not include any preamble - just provide the summary directly.`, + + // For combining chunk summaries into a final cohesive summary + final_pass: `You are a helpful assistant that creates comprehensive document summaries. +You are given summaries of different sections of a document, separated by "---". +Your task is to synthesize these section summaries into a single, cohesive, well-organized summary. +Structure the final summary with clear sections covering key themes, main arguments, and important conclusions. +Remove any redundancy while preserving all important information. Do not include any preamble like "Here is a summary" - just provide the summary directly.`, }; @@ -53,9 +68,9 @@ export async function POST(req: NextRequest) { } const body = (await req.json()) as SummarizeRequest; - const { text, mode, maxLength } = body; + const { text, mode, maxLength, isChunk, isFinalPass } = body; - console.log('Received summarize request:', { provider, modelId, mode, textLength: text?.length }); + console.log('Received summarize request:', { provider, modelId, mode, textLength: text?.length, isChunk, isFinalPass }); if (!text) { const errorBody: SummarizeError = { @@ -73,10 +88,25 @@ export async function POST(req: NextRequest) { return NextResponse.json(errorBody, { status: 400 }); } - const systemPrompt = SYSTEM_PROMPTS[mode] || SYSTEM_PROMPTS.current_page; - const userPrompt = maxLength - ? `Please summarize the following text in approximately ${maxLength} words:\n\n${text}` - : `Please summarize the following text:\n\n${text}`; + // Select appropriate prompt based on chunking mode + let systemPrompt: string; + let userPrompt: string; + + if (isChunk) { + // Summarizing an individual chunk + systemPrompt = SYSTEM_PROMPTS.chunk; + userPrompt = `Please summarize this section of the document:\n\n${text}`; + } else if (isFinalPass) { + // Combining chunk summaries into final summary + systemPrompt = SYSTEM_PROMPTS.final_pass; + userPrompt = `Please synthesize these section summaries into a comprehensive document summary:\n\n${text}`; + } else { + // Normal summarization + systemPrompt = SYSTEM_PROMPTS[mode] || SYSTEM_PROMPTS.current_page; + userPrompt = maxLength + ? `Please summarize the following text in approximately ${maxLength} words:\n\n${text}` + : `Please summarize the following text:\n\n${text}`; + } let model; diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index de88b1d..1550800 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { RefObject, useCallback, useState, useEffect, useRef } from 'react'; +import { RefObject, useCallback, useState, useEffect, useRef, useMemo } from 'react'; import { Document, Page } from 'react-pdf'; import type { Dest } from 'react-pdf/src/shared/types.js'; import 'react-pdf/dist/Page/AnnotationLayer.css'; @@ -47,8 +47,34 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { currDocPages, currDocText, currDocPage, + pdfDocument, } = usePDF(); + // Track document loading state to prevent rendering pages during transitions + const [isDocumentReady, setIsDocumentReady] = useState(false); + const documentKeyRef = useRef(0); + const lastDataRef = useRef(undefined); + + // Reset ready state when document data changes + if (currDocData !== lastDataRef.current) { + lastDataRef.current = currDocData; + if (currDocData) { + documentKeyRef.current += 1; + } + if (isDocumentReady) { + setIsDocumentReady(false); + } + } + + // Create a Uint8Array copy to prevent "detached ArrayBuffer" errors + const pdfFileData = useMemo(() => { + if (!currDocData) return undefined; + return { data: new Uint8Array(currDocData) }; + }, [currDocData]); + + // Only render pages when document is fully loaded and ready + const canRenderPages = isDocumentReady && pdfDocument && currDocPages; + useEffect(() => { /* * Handles highlighting the current sentence being read by TTS. @@ -165,11 +191,16 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { return (
} noData={} - file={currDocData} + file={pdfFileData} onLoadSuccess={(pdf) => { onDocumentLoadSuccess(pdf); + setIsDocumentReady(true); + }} + onLoadError={() => { + // Ignore errors from destroyed documents during navigation/reload }} onItemClick={(args: PDFOnLinkClickArgs) => { if (args?.pageNumber) { @@ -188,7 +219,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { {viewType === 'scroll' ? ( // Scroll mode: render all pages
- {currDocPages && [...Array(currDocPages)].map((_, i) => ( + {canRenderPages && [...Array(currDocPages)].map((_, i) => ( - {currDocPages && leftPage > 0 && ( + {canRenderPages && leftPage > 0 && ( )} - {currDocPages && rightPage && rightPage <= currDocPages && viewType === 'dual' && ( + {canRenderPages && rightPage && rightPage <= currDocPages && viewType === 'dual' && ( [ + { id: 4096, name: '4K tokens' }, + { id: 8192, name: '8K tokens' }, + { id: 16384, name: '16K tokens' }, + { id: 32768, name: '32K tokens' }, + { id: 65536, name: '64K tokens' }, + { id: 131072, name: '128K tokens' }, + { id: 200000, name: '200K tokens' }, + ], []); const [isSyncing, setIsSyncing] = useState(false); const [isLoading, setIsLoading] = useState(false); const [showProgress, setShowProgress] = useState(false); @@ -205,7 +217,8 @@ export function SettingsModal() { setLocalSummaryModel(summaryModel); setLocalSummaryApiKey(summaryApiKey); setLocalSummaryBaseUrl(summaryBaseUrl); - }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, checkFirstVist, summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl]); + setLocalSummaryContextLimit(summaryContextLimit); + }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, checkFirstVist, summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl, summaryContextLimit]); // Detect if current model is custom (not in presets) and mirror it in the input field useEffect(() => { @@ -322,7 +335,8 @@ export function SettingsModal() { setLocalSummaryModel(summaryModel); setLocalSummaryApiKey(summaryApiKey); setLocalSummaryBaseUrl(summaryBaseUrl); - }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, ttsModels, summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl]); + setLocalSummaryContextLimit(summaryContextLimit); + }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, ttsModels, summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl, summaryContextLimit]); const tabs = [ { name: 'API', icon: 'šŸ”‘' }, @@ -367,7 +381,7 @@ export function SettingsModal() { leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + - + {tabs.map((tab) => ( - `w-full rounded-lg py-1 text-sm font-medium - ring-accent/60 ring-offset-2 ring-offset-base + `w-full rounded-lg py-2 px-2 text-sm font-medium transition-colors ${selected - ? 'bg-accent text-background shadow' - : 'text-foreground hover:text-accent' + ? 'bg-accent shadow' + : 'hover:bg-offbase/50' }` } > - - {tab.icon} - {tab.name} - + {({ selected }) => ( + + {tab.icon} + {tab.name} + + )} ))} @@ -585,7 +603,7 @@ export function SettingsModal() {
+ {/* Chunk progress indicator */} + {isGenerating && chunkProgress && chunkProgress.totalChunks > 1 && ( +
+
+ {chunkProgress.message} + {Math.round((chunkProgress.currentChunk / chunkProgress.totalChunks) * 100)}% +
+
+
+
+
+ )} + {/* Error display */} {error && (
@@ -301,8 +326,34 @@ export function SummarizeModal({ )}
-
-

{summary}

+
+ <>{children}, + code: ({ className, children, ...props }) => { + const match = /language-(\w+)/.exec(className || ''); + const isInline = !match && !className; + return isInline ? ( + + {children} + + ) : ( + {String(children).replace(/\n$/, '')} + ); + }, + }} + > + {summary} +
)} diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index c79dc3e..106555b 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -40,6 +40,7 @@ interface ConfigContextType { summaryModel: string; summaryApiKey: string; summaryBaseUrl: string; + summaryContextLimit: number; } const ConfigContext = createContext(undefined); @@ -117,6 +118,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { summaryModel, summaryApiKey, summaryBaseUrl, + summaryContextLimit, } = config || APP_CONFIG_DEFAULTS; /** @@ -221,6 +223,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { summaryModel, summaryApiKey, summaryBaseUrl, + summaryContextLimit, }}> {children} diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..9209ac1 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,6 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs' && process.env.AUTH_ENABLED === 'true') { + const { printAuthUrl } = await import('@/lib/auth'); + printAuthUrl(); + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..8a3f617 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,24 @@ +import { randomBytes } from 'crypto'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import path from 'path'; + +const TOKEN_FILE = path.join(process.cwd(), '.data', '.auth-token'); + +export function getAuthToken(): string { + const dir = path.dirname(TOKEN_FILE); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + + if (existsSync(TOKEN_FILE)) { + return readFileSync(TOKEN_FILE, 'utf-8').trim(); + } + + const token = randomBytes(32).toString('base64url'); + writeFileSync(TOKEN_FILE, token); + return token; +} + +export function printAuthUrl() { + const token = getAuthToken(); + const port = process.env.PORT || 3000; + console.log('\nšŸ” Auth URL: http://localhost:' + port + '?token=' + token + '\n'); +} diff --git a/src/lib/pdf.ts b/src/lib/pdf.ts index fc4c120..2070cce 100644 --- a/src/lib/pdf.ts +++ b/src/lib/pdf.ts @@ -168,9 +168,6 @@ export async function extractTextFromPDF( margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 } ): Promise { try { - // Log pdf worker version - //console.log('PDF worker version:', pdfjs.GlobalWorkerOptions.workerSrc); - const page = await pdf.getPage(pageNumber); const textContent = await page.getTextContent(); @@ -271,6 +268,11 @@ export async function extractTextFromPDF( return pageText.replace(/\s+/g, ' ').trim(); } catch (error) { + // Return empty string if document was destroyed during async operation + const msg = error instanceof Error ? error.message : ''; + if (msg.includes('sendWithPromise') || msg.includes('destroyed')) { + return ''; + } console.error('Error extracting text from PDF:', error); throw new Error('Failed to extract text from PDF'); } diff --git a/src/lib/summarize.ts b/src/lib/summarize.ts index b86d2dc..a8c14f2 100644 --- a/src/lib/summarize.ts +++ b/src/lib/summarize.ts @@ -1,51 +1,123 @@ -import type { SummarizeMode, SummarizeResponse, SummarizeError } from '@/types/summary'; +import type { SummarizeMode, SummarizeResponse, SummarizeError, ChunkSummaryProgress } from '@/types/summary'; export interface SummarizeOptions { provider: string; apiKey: string; baseUrl: string; model: string; + contextLimit?: number; } -export async function generateSummary( +// Estimate tokens (~4 chars per token) +export const estimateTokens = (text: string): number => Math.ceil((text?.length || 0) / 4); + +// Max safe input (75% of limit for system prompt + output) +const getMaxInputTokens = (limit: number): number => Math.floor(limit * 0.75); + +// Check if text needs chunking +export const needsChunking = (text: string, contextLimit: number): boolean => + estimateTokens(text) > getMaxInputTokens(contextLimit); + +// Split text into chunks by paragraphs/sentences +function splitTextIntoChunks(text: string, maxTokens: number): string[] { + const maxChars = maxTokens * 4; + const chunks: string[] = []; + let current = ''; + + for (const para of text.split(/\n\n+/)) { + const trimmed = para.trim(); + if (!trimmed) continue; + + if ((current + '\n\n' + trimmed).length > maxChars) { + if (current) chunks.push(current.trim()); + current = trimmed.length > maxChars + ? trimmed.slice(0, maxChars) // Force split if paragraph too long + : trimmed; + } else { + current = current ? current + '\n\n' + trimmed : trimmed; + } + } + if (current.trim()) chunks.push(current.trim()); + return chunks; +} + +// Make API request +async function callSummarizeAPI( text: string, mode: SummarizeMode, options: SummarizeOptions, - maxLength?: number + flags?: { isChunk?: boolean; isFinalPass?: boolean } ): Promise { - // Build headers, only include API key if explicitly set (otherwise server uses env vars) const headers: Record = { 'Content-Type': 'application/json', 'x-summary-provider': options.provider, 'x-summary-model': options.model, }; - - // Only send API key if explicitly configured (empty = use server env var) - if (options.apiKey) { - headers['x-summary-api-key'] = options.apiKey; - } - - // Only send base URL if configured - if (options.baseUrl) { - headers['x-summary-base-url'] = options.baseUrl; - } + if (options.apiKey) headers['x-summary-api-key'] = options.apiKey; + if (options.baseUrl) headers['x-summary-base-url'] = options.baseUrl; const response = await fetch('/api/summarize', { method: 'POST', headers, - body: JSON.stringify({ - text, - mode, - maxLength, - }), + body: JSON.stringify({ text, mode, ...flags }), }); const data = await response.json(); - - if (!response.ok) { - const error = data as SummarizeError; - throw new Error(error.message || 'Failed to generate summary'); - } - + if (!response.ok) throw new Error((data as SummarizeError).message || 'Failed to generate summary'); return data as SummarizeResponse; } + +// Main summarization function with automatic chunking +export async function generateSummary( + text: string, + mode: SummarizeMode, + options: SummarizeOptions, + maxLength?: number, + onProgress?: (progress: ChunkSummaryProgress) => void +): Promise { + const contextLimit = options.contextLimit || 32768; + + // Direct summarization for small texts or non-whole-book modes + if (mode !== 'whole_book' || !needsChunking(text, contextLimit)) { + return callSummarizeAPI(text, mode, options); + } + + // Hierarchical summarization for large documents + const maxInputTokens = getMaxInputTokens(contextLimit); + const chunks = splitTextIntoChunks(text, maxInputTokens); + + onProgress?.({ currentChunk: 0, totalChunks: chunks.length, phase: 'chunking', message: `Splitting into ${chunks.length} chunks...` }); + + // Summarize each chunk + const summaries: string[] = []; + let totalTokens = 0; + + for (let i = 0; i < chunks.length; i++) { + onProgress?.({ currentChunk: i + 1, totalChunks: chunks.length, phase: 'summarizing', message: `Summarizing ${i + 1}/${chunks.length}...` }); + const result = await callSummarizeAPI(chunks[i], 'whole_book', options, { isChunk: true }); + summaries.push(result.summary); + totalTokens += result.tokensUsed || 0; + } + + // Combine summaries (recursively if still too large) + onProgress?.({ currentChunk: chunks.length, totalChunks: chunks.length, phase: 'combining', message: 'Combining summaries...' }); + + let combined = summaries.join('\n\n---\n\n'); + while (needsChunking(combined, contextLimit)) { + const subChunks = splitTextIntoChunks(combined, maxInputTokens); + const subSummaries: string[] = []; + for (const chunk of subChunks) { + const result = await callSummarizeAPI(chunk, 'whole_book', options, { isChunk: true }); + subSummaries.push(result.summary); + totalTokens += result.tokensUsed || 0; + } + combined = subSummaries.join('\n\n---\n\n'); + } + + // Final pass + const final = await callSummarizeAPI(combined, 'whole_book', options, { isFinalPass: true }); + + onProgress?.({ currentChunk: chunks.length, totalChunks: chunks.length, phase: 'complete', message: 'Complete!' }); + + return { ...final, tokensUsed: totalTokens + (final.tokensUsed || 0), chunksProcessed: chunks.length, totalChunks: chunks.length }; +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..b52addc --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + // Auth disabled by default - set AUTH_ENABLED=true to enable + if (process.env.AUTH_ENABLED !== 'true') { + return NextResponse.next(); + } + + const { pathname, searchParams } = request.nextUrl; + + // Skip static files and auth endpoint + if (pathname.startsWith('/_next') || pathname.startsWith('/api/auth') || pathname.match(/\.(ico|png|svg|jpg|jpeg|gif|webp|json)$/)) { + return NextResponse.next(); + } + + // Token in URL -> redirect to auth API + const token = searchParams.get('token'); + if (token) { + const authUrl = new URL('/api/auth', request.url); + authUrl.searchParams.set('token', token); + authUrl.searchParams.set('returnTo', pathname); + return NextResponse.redirect(authUrl); + } + + // Valid cookie -> allow + if (request.cookies.get('auth_session')?.value) { + return NextResponse.next(); + } + + // Unauthorized + return new NextResponse('Unauthorized - add ?token=YOUR_TOKEN to URL (check server logs)', { + status: 401, + headers: { 'Content-Type': 'text/plain' }, + }); +} + +export const config = { + matcher: ['/((?!_next/static|_next/image).*)'], +}; diff --git a/src/types/config.ts b/src/types/config.ts index 136824d..049eba1 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -35,6 +35,7 @@ export interface AppConfigValues { summaryModel: string; summaryApiKey: string; summaryBaseUrl: string; + summaryContextLimit: number; // Token limit for summarization } export const APP_CONFIG_DEFAULTS: AppConfigValues = { @@ -73,6 +74,7 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = { summaryModel: 'llama-3.3-70b-versatile', summaryApiKey: '', summaryBaseUrl: '', + summaryContextLimit: 32768, // Default to 32k tokens }; export interface AppConfigRow extends AppConfigValues { diff --git a/src/types/summary.ts b/src/types/summary.ts index 95082fe..28fea35 100644 --- a/src/types/summary.ts +++ b/src/types/summary.ts @@ -1,8 +1,7 @@ export type SummarizeMode = 'current_page' | 'select_page' | 'whole_book'; -export type SummaryProvider = 'openai' | 'anthropic' | 'custom-openai'; export interface SummaryRow { - id: string; // `${docId}-${scope}-${pageNumber ?? 'all'}` + id: string; docId: string; docType: 'pdf' | 'epub' | 'html'; scope: 'page' | 'book'; @@ -18,6 +17,8 @@ export interface SummarizeRequest { text: string; mode: SummarizeMode; maxLength?: number; + isChunk?: boolean; + isFinalPass?: boolean; } export interface SummarizeResponse { @@ -25,6 +26,8 @@ export interface SummarizeResponse { provider: string; model: string; tokensUsed?: number; + chunksProcessed?: number; + totalChunks?: number; } export interface SummarizeError { @@ -32,3 +35,10 @@ export interface SummarizeError { message: string; details?: string; } + +export interface ChunkSummaryProgress { + currentChunk: number; + totalChunks: number; + phase: 'chunking' | 'summarizing' | 'combining' | 'complete'; + message: string; +}