feat(auth): implement authentication middleware and token management

- Added middleware for handling authentication based on environment variables.
- Introduced functions for generating and managing authentication tokens.
- Updated summarization logic to support chunked processing with context limits.
- Enhanced settings modal to include context limit configuration for summarization.
- Improved PDF viewer to manage document loading states and prevent rendering during transitions.
- Added progress indicators for chunked summarization in the summarize modal.
This commit is contained in:
Sunny Modi 2026-01-25 23:34:48 -06:00
parent 161a294a24
commit 67dfb30647
14 changed files with 436 additions and 65 deletions

3
.gitignore vendored
View file

@ -44,6 +44,9 @@ next-env.d.ts
# documents
/docstore
# auth token storage
/.data
# Playwright
node_modules/
/tests/results/

21
src/app/api/auth/route.ts Normal file
View file

@ -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;
}

View file

@ -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;

View file

@ -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<ArrayBuffer | undefined>(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 (
<div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full">
<Document
key={documentKeyRef.current}
loading={<DocumentSkeleton />}
noData={<DocumentSkeleton />}
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
<div className="flex flex-col gap-4">
{currDocPages && [...Array(currDocPages)].map((_, i) => (
{canRenderPages && [...Array(currDocPages)].map((_, i) => (
<Page
key={`page_${i + 1}`}
pageNumber={i + 1}
@ -206,7 +237,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
) : (
// Single/Dual page mode
<div className="flex justify-center gap-4">
{currDocPages && leftPage > 0 && (
{canRenderPages && leftPage > 0 && (
<Page
key={`page_${leftPage}`}
pageNumber={leftPage}
@ -220,7 +251,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
}}
/>
)}
{currDocPages && rightPage && rightPage <= currDocPages && viewType === 'dual' && (
{canRenderPages && rightPage && rightPage <= currDocPages && viewType === 'dual' && (
<Page
key={`page_${rightPage}`}
pageNumber={rightPage}

View file

@ -41,7 +41,7 @@ export function SettingsModal() {
const [isOpen, setIsOpen] = useState(false);
const { theme, setTheme } = useTheme();
const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey, summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl } = useConfig();
const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey, summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl, summaryContextLimit } = useConfig();
const { clearPDFs, clearEPUBs, clearHTML } = useDocuments();
const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
@ -54,6 +54,18 @@ export function SettingsModal() {
const [localSummaryModel, setLocalSummaryModel] = useState(summaryModel);
const [localSummaryApiKey, setLocalSummaryApiKey] = useState(summaryApiKey);
const [localSummaryBaseUrl, setLocalSummaryBaseUrl] = useState(summaryBaseUrl);
const [localSummaryContextLimit, setLocalSummaryContextLimit] = useState(summaryContextLimit);
// Context limit options
const contextLimitOptions = useMemo(() => [
{ 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"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogPanel className="w-full max-w-md lg:max-w-lg transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground mb-4"
@ -376,23 +390,27 @@ export function SettingsModal() {
</DialogTitle>
<TabGroup>
<TabList className="flex flex-col sm:flex-col-none sm:flex-row gap-1 rounded-xl bg-background p-1 mb-4">
<TabList className="grid grid-cols-2 lg:grid-cols-4 gap-1 rounded-xl bg-background p-1 mb-4">
{tabs.map((tab) => (
<Tab
key={tab.name}
className={({ selected }) =>
`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'
}`
}
>
<span className="flex items-center justify-center gap-2">
<span>{tab.icon}</span>
{tab.name}
</span>
{({ selected }) => (
<span
className="flex items-center justify-center gap-1.5"
style={{ color: selected ? 'var(--background)' : 'var(--foreground)' }}
>
<span>{tab.icon}</span>
<span>{tab.name}</span>
</span>
)}
</Tab>
))}
</TabList>
@ -585,7 +603,7 @@ export function SettingsModal() {
<div className="pt-4 flex justify-end gap-2">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
@ -602,7 +620,7 @@ export function SettingsModal() {
</Button>
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
@ -795,6 +813,62 @@ export function SettingsModal() {
</div>
)}
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">
Context Limit
<span className="ml-2 text-xs text-muted">(for chunked summarization)</span>
</label>
<Listbox
value={contextLimitOptions.find(o => o.id === localSummaryContextLimit) || contextLimitOptions[3]}
onChange={(option) => setLocalSummaryContextLimit(option.id)}
>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
<span className="block truncate">
{contextLimitOptions.find(o => o.id === localSummaryContextLimit)?.name || '32K tokens'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-50">
{contextLimitOptions.map((option) => (
<ListboxOption
key={option.id}
className={({ active }) =>
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${
active ? 'bg-offbase text-accent' : 'text-foreground'
}`
}
value={option}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option.name}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
) : null}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
<p className="text-xs text-muted">
Documents larger than this limit will be split into chunks and summarized hierarchically.
</p>
</div>
<div className="pt-4 flex justify-end gap-2">
<Button
type="button"
@ -807,6 +881,7 @@ export function SettingsModal() {
setLocalSummaryModel('llama-3.3-70b-versatile');
setLocalSummaryApiKey('');
setLocalSummaryBaseUrl('');
setLocalSummaryContextLimit(32768);
}}
>
Reset
@ -822,6 +897,7 @@ export function SettingsModal() {
await updateConfigKey('summaryModel', localSummaryModel);
await updateConfigKey('summaryApiKey', localSummaryApiKey);
await updateConfigKey('summaryBaseUrl', localSummaryBaseUrl);
await updateConfigKey('summaryContextLimit', localSummaryContextLimit);
setIsOpen(false);
}}
>

View file

@ -13,12 +13,15 @@ import {
Radio,
Label,
} from '@headlessui/react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { CopyIcon, CheckIcon } from '@/components/icons/Icons';
import { CodeBlock } from '@/components/CodeBlock';
import { LoadingSpinner } from '@/components/Spinner';
import { useConfig } from '@/contexts/ConfigContext';
import { generateSummary } from '@/lib/summarize';
import { saveSummary, getSummary } from '@/lib/dexie';
import type { SummarizeMode, SummaryRow } from '@/types/summary';
import type { SummarizeMode, SummaryRow, ChunkSummaryProgress } from '@/types/summary';
interface SummarizeModalProps {
isOpen: boolean;
@ -45,7 +48,7 @@ export function SummarizeModal({
totalPages,
onExtractText,
}: SummarizeModalProps) {
const { summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl } = useConfig();
const { summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl, summaryContextLimit } = useConfig();
const [mode, setMode] = useState<SummarizeMode>('current_page');
const [selectedPage, setSelectedPage] = useState<number>(currentPage);
@ -54,6 +57,7 @@ export function SummarizeModal({
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [savedSummary, setSavedSummary] = useState<SummaryRow | null>(null);
const [chunkProgress, setChunkProgress] = useState<ChunkSummaryProgress | null>(null);
// Update selected page when current page changes
useEffect(() => {
@ -93,12 +97,13 @@ export function SummarizeModal({
setError(null);
setSummary('');
setSavedSummary(null);
setChunkProgress(null);
try {
const pageNumber = mode === 'whole_book' ? undefined : (mode === 'current_page' ? currentPage : selectedPage);
const text = await onExtractText(mode, pageNumber);
if (!text || text.trim().length === 0) {
if (!text?.trim()) {
throw new Error('No text could be extracted from the document');
}
@ -107,11 +112,11 @@ export function SummarizeModal({
apiKey: summaryApiKey,
baseUrl: summaryBaseUrl,
model: summaryModel,
});
contextLimit: summaryContextLimit,
}, undefined, setChunkProgress);
setSummary(result.summary);
// Save to IndexedDB
await saveSummary({
docId,
docType,
@ -124,13 +129,13 @@ export function SummarizeModal({
updatedAt: Date.now(),
});
// Refresh saved summary state
await checkSavedSummary();
} catch (err) {
console.error('Error generating summary:', err);
setError(err instanceof Error ? err.message : 'Failed to generate summary');
} finally {
setIsGenerating(false);
setChunkProgress(null);
}
};
@ -256,7 +261,11 @@ export function SummarizeModal({
{isGenerating ? (
<>
<LoadingSpinner />
<span className="ml-2">Generating...</span>
<span className="ml-2">
{chunkProgress
? chunkProgress.message
: 'Generating...'}
</span>
</>
) : savedSummary ? (
'Regenerate Summary'
@ -265,6 +274,22 @@ export function SummarizeModal({
)}
</Button>
{/* Chunk progress indicator */}
{isGenerating && chunkProgress && chunkProgress.totalChunks > 1 && (
<div className="space-y-2">
<div className="flex justify-between text-xs text-muted">
<span>{chunkProgress.message}</span>
<span>{Math.round((chunkProgress.currentChunk / chunkProgress.totalChunks) * 100)}%</span>
</div>
<div className="w-full bg-offbase rounded-full h-2">
<div
className="bg-accent h-2 rounded-full transition-all duration-300"
style={{ width: `${(chunkProgress.currentChunk / chunkProgress.totalChunks) * 100}%` }}
/>
</div>
</div>
)}
{/* Error display */}
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
@ -301,8 +326,34 @@ export function SummarizeModal({
)}
</Button>
</div>
<div className="max-h-64 overflow-y-auto p-3 bg-background rounded-lg border border-offbase">
<p className="text-sm text-foreground whitespace-pre-wrap">{summary}</p>
<div className="max-h-64 overflow-y-auto p-3 bg-background rounded-lg border border-offbase
prose prose-sm dark:prose-invert max-w-none text-foreground
prose-headings:text-foreground prose-headings:font-semibold prose-headings:mt-3 prose-headings:mb-2
prose-p:text-foreground prose-p:my-1.5
prose-strong:text-foreground prose-em:text-foreground
prose-ul:text-foreground prose-ol:text-foreground prose-li:text-foreground prose-li:my-0.5
prose-a:text-accent hover:prose-a:text-secondary-accent
prose-blockquote:border-accent prose-blockquote:text-muted
prose-code:text-accent prose-code:bg-offbase prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ children }) => <>{children}</>,
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const isInline = !match && !className;
return isInline ? (
<code className={className} {...props}>
{children}
</code>
) : (
<CodeBlock>{String(children).replace(/\n$/, '')}</CodeBlock>
);
},
}}
>
{summary}
</ReactMarkdown>
</div>
</div>
)}

View file

@ -40,6 +40,7 @@ interface ConfigContextType {
summaryModel: string;
summaryApiKey: string;
summaryBaseUrl: string;
summaryContextLimit: number;
}
const ConfigContext = createContext<ConfigContextType | undefined>(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}
</ConfigContext.Provider>

6
src/instrumentation.ts Normal file
View file

@ -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();
}
}

24
src/lib/auth.ts Normal file
View file

@ -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');
}

View file

@ -168,9 +168,6 @@ export async function extractTextFromPDF(
margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 }
): Promise<string> {
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');
}

View file

@ -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<SummarizeResponse> {
// Build headers, only include API key if explicitly set (otherwise server uses env vars)
const headers: Record<string, string> = {
'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<SummarizeResponse> {
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 };
}

40
src/middleware.ts Normal file
View file

@ -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).*)'],
};

View file

@ -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 {

View file

@ -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;
}