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:
parent
161a294a24
commit
67dfb30647
14 changed files with 436 additions and 65 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
21
src/app/api/auth/route.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
6
src/instrumentation.ts
Normal 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
24
src/lib/auth.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
40
src/middleware.ts
Normal 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).*)'],
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue