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
|
# documents
|
||||||
/docstore
|
/docstore
|
||||||
|
|
||||||
|
# auth token storage
|
||||||
|
/.data
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
node_modules/
|
node_modules/
|
||||||
/tests/results/
|
/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.
|
Provide a comprehensive summary of the entire document content provided.
|
||||||
Structure your summary with key themes, main arguments, and important conclusions.
|
Structure your summary with key themes, main arguments, and important conclusions.
|
||||||
For longer texts, organize the summary into logical sections.
|
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.`,
|
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 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) {
|
if (!text) {
|
||||||
const errorBody: SummarizeError = {
|
const errorBody: SummarizeError = {
|
||||||
|
|
@ -73,10 +88,25 @@ export async function POST(req: NextRequest) {
|
||||||
return NextResponse.json(errorBody, { status: 400 });
|
return NextResponse.json(errorBody, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPTS[mode] || SYSTEM_PROMPTS.current_page;
|
// Select appropriate prompt based on chunking mode
|
||||||
const userPrompt = maxLength
|
let systemPrompt: string;
|
||||||
? `Please summarize the following text in approximately ${maxLength} words:\n\n${text}`
|
let userPrompt: string;
|
||||||
: `Please summarize the following text:\n\n${text}`;
|
|
||||||
|
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;
|
let model;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'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 { Document, Page } from 'react-pdf';
|
||||||
import type { Dest } from 'react-pdf/src/shared/types.js';
|
import type { Dest } from 'react-pdf/src/shared/types.js';
|
||||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||||
|
|
@ -47,8 +47,34 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
currDocPages,
|
currDocPages,
|
||||||
currDocText,
|
currDocText,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
|
pdfDocument,
|
||||||
} = usePDF();
|
} = 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(() => {
|
useEffect(() => {
|
||||||
/*
|
/*
|
||||||
* Handles highlighting the current sentence being read by TTS.
|
* Handles highlighting the current sentence being read by TTS.
|
||||||
|
|
@ -165,11 +191,16 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full">
|
<div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full">
|
||||||
<Document
|
<Document
|
||||||
|
key={documentKeyRef.current}
|
||||||
loading={<DocumentSkeleton />}
|
loading={<DocumentSkeleton />}
|
||||||
noData={<DocumentSkeleton />}
|
noData={<DocumentSkeleton />}
|
||||||
file={currDocData}
|
file={pdfFileData}
|
||||||
onLoadSuccess={(pdf) => {
|
onLoadSuccess={(pdf) => {
|
||||||
onDocumentLoadSuccess(pdf);
|
onDocumentLoadSuccess(pdf);
|
||||||
|
setIsDocumentReady(true);
|
||||||
|
}}
|
||||||
|
onLoadError={() => {
|
||||||
|
// Ignore errors from destroyed documents during navigation/reload
|
||||||
}}
|
}}
|
||||||
onItemClick={(args: PDFOnLinkClickArgs) => {
|
onItemClick={(args: PDFOnLinkClickArgs) => {
|
||||||
if (args?.pageNumber) {
|
if (args?.pageNumber) {
|
||||||
|
|
@ -188,7 +219,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
{viewType === 'scroll' ? (
|
{viewType === 'scroll' ? (
|
||||||
// Scroll mode: render all pages
|
// Scroll mode: render all pages
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{currDocPages && [...Array(currDocPages)].map((_, i) => (
|
{canRenderPages && [...Array(currDocPages)].map((_, i) => (
|
||||||
<Page
|
<Page
|
||||||
key={`page_${i + 1}`}
|
key={`page_${i + 1}`}
|
||||||
pageNumber={i + 1}
|
pageNumber={i + 1}
|
||||||
|
|
@ -206,7 +237,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
) : (
|
) : (
|
||||||
// Single/Dual page mode
|
// Single/Dual page mode
|
||||||
<div className="flex justify-center gap-4">
|
<div className="flex justify-center gap-4">
|
||||||
{currDocPages && leftPage > 0 && (
|
{canRenderPages && leftPage > 0 && (
|
||||||
<Page
|
<Page
|
||||||
key={`page_${leftPage}`}
|
key={`page_${leftPage}`}
|
||||||
pageNumber={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
|
<Page
|
||||||
key={`page_${rightPage}`}
|
key={`page_${rightPage}`}
|
||||||
pageNumber={rightPage}
|
pageNumber={rightPage}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export function SettingsModal() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { theme, setTheme } = useTheme();
|
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 { clearPDFs, clearEPUBs, clearHTML } = useDocuments();
|
||||||
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
||||||
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
||||||
|
|
@ -54,6 +54,18 @@ export function SettingsModal() {
|
||||||
const [localSummaryModel, setLocalSummaryModel] = useState(summaryModel);
|
const [localSummaryModel, setLocalSummaryModel] = useState(summaryModel);
|
||||||
const [localSummaryApiKey, setLocalSummaryApiKey] = useState(summaryApiKey);
|
const [localSummaryApiKey, setLocalSummaryApiKey] = useState(summaryApiKey);
|
||||||
const [localSummaryBaseUrl, setLocalSummaryBaseUrl] = useState(summaryBaseUrl);
|
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 [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showProgress, setShowProgress] = useState(false);
|
const [showProgress, setShowProgress] = useState(false);
|
||||||
|
|
@ -205,7 +217,8 @@ export function SettingsModal() {
|
||||||
setLocalSummaryModel(summaryModel);
|
setLocalSummaryModel(summaryModel);
|
||||||
setLocalSummaryApiKey(summaryApiKey);
|
setLocalSummaryApiKey(summaryApiKey);
|
||||||
setLocalSummaryBaseUrl(summaryBaseUrl);
|
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
|
// Detect if current model is custom (not in presets) and mirror it in the input field
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -322,7 +335,8 @@ export function SettingsModal() {
|
||||||
setLocalSummaryModel(summaryModel);
|
setLocalSummaryModel(summaryModel);
|
||||||
setLocalSummaryApiKey(summaryApiKey);
|
setLocalSummaryApiKey(summaryApiKey);
|
||||||
setLocalSummaryBaseUrl(summaryBaseUrl);
|
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 = [
|
const tabs = [
|
||||||
{ name: 'API', icon: '🔑' },
|
{ name: 'API', icon: '🔑' },
|
||||||
|
|
@ -367,7 +381,7 @@ export function SettingsModal() {
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
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
|
<DialogTitle
|
||||||
as="h3"
|
as="h3"
|
||||||
className="text-lg font-semibold leading-6 text-foreground mb-4"
|
className="text-lg font-semibold leading-6 text-foreground mb-4"
|
||||||
|
|
@ -376,23 +390,27 @@ export function SettingsModal() {
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<TabGroup>
|
<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) => (
|
{tabs.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.name}
|
key={tab.name}
|
||||||
className={({ selected }) =>
|
className={({ selected }) =>
|
||||||
`w-full rounded-lg py-1 text-sm font-medium
|
`w-full rounded-lg py-2 px-2 text-sm font-medium transition-colors
|
||||||
ring-accent/60 ring-offset-2 ring-offset-base
|
|
||||||
${selected
|
${selected
|
||||||
? 'bg-accent text-background shadow'
|
? 'bg-accent shadow'
|
||||||
: 'text-foreground hover:text-accent'
|
: 'hover:bg-offbase/50'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="flex items-center justify-center gap-2">
|
{({ selected }) => (
|
||||||
<span>{tab.icon}</span>
|
<span
|
||||||
{tab.name}
|
className="flex items-center justify-center gap-1.5"
|
||||||
</span>
|
style={{ color: selected ? 'var(--background)' : 'var(--foreground)' }}
|
||||||
|
>
|
||||||
|
<span>{tab.icon}</span>
|
||||||
|
<span>{tab.name}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Tab>
|
</Tab>
|
||||||
))}
|
))}
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
@ -585,7 +603,7 @@ export function SettingsModal() {
|
||||||
<div className="pt-4 flex justify-end gap-2">
|
<div className="pt-4 flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="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
|
font-medium text-foreground hover:bg-offbase focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
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"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
|
|
@ -602,7 +620,7 @@ export function SettingsModal() {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="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
|
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
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"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
||||||
|
|
@ -795,6 +813,62 @@ export function SettingsModal() {
|
||||||
</div>
|
</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">
|
<div className="pt-4 flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -807,6 +881,7 @@ export function SettingsModal() {
|
||||||
setLocalSummaryModel('llama-3.3-70b-versatile');
|
setLocalSummaryModel('llama-3.3-70b-versatile');
|
||||||
setLocalSummaryApiKey('');
|
setLocalSummaryApiKey('');
|
||||||
setLocalSummaryBaseUrl('');
|
setLocalSummaryBaseUrl('');
|
||||||
|
setLocalSummaryContextLimit(32768);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
|
|
@ -822,6 +897,7 @@ export function SettingsModal() {
|
||||||
await updateConfigKey('summaryModel', localSummaryModel);
|
await updateConfigKey('summaryModel', localSummaryModel);
|
||||||
await updateConfigKey('summaryApiKey', localSummaryApiKey);
|
await updateConfigKey('summaryApiKey', localSummaryApiKey);
|
||||||
await updateConfigKey('summaryBaseUrl', localSummaryBaseUrl);
|
await updateConfigKey('summaryBaseUrl', localSummaryBaseUrl);
|
||||||
|
await updateConfigKey('summaryContextLimit', localSummaryContextLimit);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,15 @@ import {
|
||||||
Radio,
|
Radio,
|
||||||
Label,
|
Label,
|
||||||
} from '@headlessui/react';
|
} from '@headlessui/react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
import { CopyIcon, CheckIcon } from '@/components/icons/Icons';
|
import { CopyIcon, CheckIcon } from '@/components/icons/Icons';
|
||||||
|
import { CodeBlock } from '@/components/CodeBlock';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
import { LoadingSpinner } from '@/components/Spinner';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { generateSummary } from '@/lib/summarize';
|
import { generateSummary } from '@/lib/summarize';
|
||||||
import { saveSummary, getSummary } from '@/lib/dexie';
|
import { saveSummary, getSummary } from '@/lib/dexie';
|
||||||
import type { SummarizeMode, SummaryRow } from '@/types/summary';
|
import type { SummarizeMode, SummaryRow, ChunkSummaryProgress } from '@/types/summary';
|
||||||
|
|
||||||
interface SummarizeModalProps {
|
interface SummarizeModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -45,7 +48,7 @@ export function SummarizeModal({
|
||||||
totalPages,
|
totalPages,
|
||||||
onExtractText,
|
onExtractText,
|
||||||
}: SummarizeModalProps) {
|
}: SummarizeModalProps) {
|
||||||
const { summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl } = useConfig();
|
const { summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl, summaryContextLimit } = useConfig();
|
||||||
|
|
||||||
const [mode, setMode] = useState<SummarizeMode>('current_page');
|
const [mode, setMode] = useState<SummarizeMode>('current_page');
|
||||||
const [selectedPage, setSelectedPage] = useState<number>(currentPage);
|
const [selectedPage, setSelectedPage] = useState<number>(currentPage);
|
||||||
|
|
@ -54,6 +57,7 @@ export function SummarizeModal({
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [savedSummary, setSavedSummary] = useState<SummaryRow | null>(null);
|
const [savedSummary, setSavedSummary] = useState<SummaryRow | null>(null);
|
||||||
|
const [chunkProgress, setChunkProgress] = useState<ChunkSummaryProgress | null>(null);
|
||||||
|
|
||||||
// Update selected page when current page changes
|
// Update selected page when current page changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -93,12 +97,13 @@ export function SummarizeModal({
|
||||||
setError(null);
|
setError(null);
|
||||||
setSummary('');
|
setSummary('');
|
||||||
setSavedSummary(null);
|
setSavedSummary(null);
|
||||||
|
setChunkProgress(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pageNumber = mode === 'whole_book' ? undefined : (mode === 'current_page' ? currentPage : selectedPage);
|
const pageNumber = mode === 'whole_book' ? undefined : (mode === 'current_page' ? currentPage : selectedPage);
|
||||||
const text = await onExtractText(mode, pageNumber);
|
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');
|
throw new Error('No text could be extracted from the document');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,11 +112,11 @@ export function SummarizeModal({
|
||||||
apiKey: summaryApiKey,
|
apiKey: summaryApiKey,
|
||||||
baseUrl: summaryBaseUrl,
|
baseUrl: summaryBaseUrl,
|
||||||
model: summaryModel,
|
model: summaryModel,
|
||||||
});
|
contextLimit: summaryContextLimit,
|
||||||
|
}, undefined, setChunkProgress);
|
||||||
|
|
||||||
setSummary(result.summary);
|
setSummary(result.summary);
|
||||||
|
|
||||||
// Save to IndexedDB
|
|
||||||
await saveSummary({
|
await saveSummary({
|
||||||
docId,
|
docId,
|
||||||
docType,
|
docType,
|
||||||
|
|
@ -124,13 +129,13 @@ export function SummarizeModal({
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh saved summary state
|
|
||||||
await checkSavedSummary();
|
await checkSavedSummary();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error generating summary:', err);
|
console.error('Error generating summary:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Failed to generate summary');
|
setError(err instanceof Error ? err.message : 'Failed to generate summary');
|
||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
|
setChunkProgress(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -256,7 +261,11 @@ export function SummarizeModal({
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<>
|
<>
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
<span className="ml-2">Generating...</span>
|
<span className="ml-2">
|
||||||
|
{chunkProgress
|
||||||
|
? chunkProgress.message
|
||||||
|
: 'Generating...'}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : savedSummary ? (
|
) : savedSummary ? (
|
||||||
'Regenerate Summary'
|
'Regenerate Summary'
|
||||||
|
|
@ -265,6 +274,22 @@ export function SummarizeModal({
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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 display */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||||
|
|
@ -301,8 +326,34 @@ export function SummarizeModal({
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-64 overflow-y-auto p-3 bg-background rounded-lg border border-offbase">
|
<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>
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ interface ConfigContextType {
|
||||||
summaryModel: string;
|
summaryModel: string;
|
||||||
summaryApiKey: string;
|
summaryApiKey: string;
|
||||||
summaryBaseUrl: string;
|
summaryBaseUrl: string;
|
||||||
|
summaryContextLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
|
|
@ -117,6 +118,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
summaryModel,
|
summaryModel,
|
||||||
summaryApiKey,
|
summaryApiKey,
|
||||||
summaryBaseUrl,
|
summaryBaseUrl,
|
||||||
|
summaryContextLimit,
|
||||||
} = config || APP_CONFIG_DEFAULTS;
|
} = config || APP_CONFIG_DEFAULTS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -221,6 +223,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
summaryModel,
|
summaryModel,
|
||||||
summaryApiKey,
|
summaryApiKey,
|
||||||
summaryBaseUrl,
|
summaryBaseUrl,
|
||||||
|
summaryContextLimit,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</ConfigContext.Provider>
|
</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 }
|
margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 }
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// Log pdf worker version
|
|
||||||
//console.log('PDF worker version:', pdfjs.GlobalWorkerOptions.workerSrc);
|
|
||||||
|
|
||||||
const page = await pdf.getPage(pageNumber);
|
const page = await pdf.getPage(pageNumber);
|
||||||
const textContent = await page.getTextContent();
|
const textContent = await page.getTextContent();
|
||||||
|
|
||||||
|
|
@ -271,6 +268,11 @@ export async function extractTextFromPDF(
|
||||||
|
|
||||||
return pageText.replace(/\s+/g, ' ').trim();
|
return pageText.replace(/\s+/g, ' ').trim();
|
||||||
} catch (error) {
|
} 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);
|
console.error('Error extracting text from PDF:', error);
|
||||||
throw new Error('Failed to extract text from PDF');
|
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 {
|
export interface SummarizeOptions {
|
||||||
provider: string;
|
provider: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
model: 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,
|
text: string,
|
||||||
mode: SummarizeMode,
|
mode: SummarizeMode,
|
||||||
options: SummarizeOptions,
|
options: SummarizeOptions,
|
||||||
maxLength?: number
|
flags?: { isChunk?: boolean; isFinalPass?: boolean }
|
||||||
): Promise<SummarizeResponse> {
|
): Promise<SummarizeResponse> {
|
||||||
// Build headers, only include API key if explicitly set (otherwise server uses env vars)
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-summary-provider': options.provider,
|
'x-summary-provider': options.provider,
|
||||||
'x-summary-model': options.model,
|
'x-summary-model': options.model,
|
||||||
};
|
};
|
||||||
|
if (options.apiKey) headers['x-summary-api-key'] = options.apiKey;
|
||||||
// Only send API key if explicitly configured (empty = use server env var)
|
if (options.baseUrl) headers['x-summary-base-url'] = options.baseUrl;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('/api/summarize', {
|
const response = await fetch('/api/summarize', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ text, mode, ...flags }),
|
||||||
text,
|
|
||||||
mode,
|
|
||||||
maxLength,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error((data as SummarizeError).message || 'Failed to generate summary');
|
||||||
if (!response.ok) {
|
|
||||||
const error = data as SummarizeError;
|
|
||||||
throw new Error(error.message || 'Failed to generate summary');
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as SummarizeResponse;
|
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;
|
summaryModel: string;
|
||||||
summaryApiKey: string;
|
summaryApiKey: string;
|
||||||
summaryBaseUrl: string;
|
summaryBaseUrl: string;
|
||||||
|
summaryContextLimit: number; // Token limit for summarization
|
||||||
}
|
}
|
||||||
|
|
||||||
export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
||||||
|
|
@ -73,6 +74,7 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
||||||
summaryModel: 'llama-3.3-70b-versatile',
|
summaryModel: 'llama-3.3-70b-versatile',
|
||||||
summaryApiKey: '',
|
summaryApiKey: '',
|
||||||
summaryBaseUrl: '',
|
summaryBaseUrl: '',
|
||||||
|
summaryContextLimit: 32768, // Default to 32k tokens
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AppConfigRow extends AppConfigValues {
|
export interface AppConfigRow extends AppConfigValues {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
export type SummarizeMode = 'current_page' | 'select_page' | 'whole_book';
|
export type SummarizeMode = 'current_page' | 'select_page' | 'whole_book';
|
||||||
export type SummaryProvider = 'openai' | 'anthropic' | 'custom-openai';
|
|
||||||
|
|
||||||
export interface SummaryRow {
|
export interface SummaryRow {
|
||||||
id: string; // `${docId}-${scope}-${pageNumber ?? 'all'}`
|
id: string;
|
||||||
docId: string;
|
docId: string;
|
||||||
docType: 'pdf' | 'epub' | 'html';
|
docType: 'pdf' | 'epub' | 'html';
|
||||||
scope: 'page' | 'book';
|
scope: 'page' | 'book';
|
||||||
|
|
@ -18,6 +17,8 @@ export interface SummarizeRequest {
|
||||||
text: string;
|
text: string;
|
||||||
mode: SummarizeMode;
|
mode: SummarizeMode;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
|
isChunk?: boolean;
|
||||||
|
isFinalPass?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SummarizeResponse {
|
export interface SummarizeResponse {
|
||||||
|
|
@ -25,6 +26,8 @@ export interface SummarizeResponse {
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
tokensUsed?: number;
|
tokensUsed?: number;
|
||||||
|
chunksProcessed?: number;
|
||||||
|
totalChunks?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SummarizeError {
|
export interface SummarizeError {
|
||||||
|
|
@ -32,3 +35,10 @@ export interface SummarizeError {
|
||||||
message: string;
|
message: string;
|
||||||
details?: string;
|
details?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChunkSummaryProgress {
|
||||||
|
currentChunk: number;
|
||||||
|
totalChunks: number;
|
||||||
|
phase: 'chunking' | 'summarizing' | 'combining' | 'complete';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue