diff --git a/app/api/generate/scene-outlines-stream/route.ts b/app/api/generate/scene-outlines-stream/route.ts index 36c1606a..043b4142 100644 --- a/app/api/generate/scene-outlines-stream/route.ts +++ b/app/api/generate/scene-outlines-stream/route.ts @@ -23,6 +23,7 @@ import { } from '@/lib/generation/generation-pipeline'; import type { AgentInfo } from '@/lib/generation/generation-pipeline'; import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation'; +import { sortPdfImagesForVision } from '@/lib/pdf/document-aggregator'; import { nanoid } from 'nanoid'; import type { UserRequirements, @@ -125,12 +126,13 @@ export async function POST(req: NextRequest) { let visionImages: Array<{ id: string; src: string }> | undefined; if (pdfImages && pdfImages.length > 0) { + const prioritizedImages = sortPdfImagesForVision(pdfImages); if (hasVision && imageMapping) { // Vision mode: split into vision images (first N) and text-only (rest) - const allWithSrc = pdfImages.filter((img) => imageMapping[img.id]); + const allWithSrc = prioritizedImages.filter((img) => imageMapping[img.id]); const visionSlice = allWithSrc.slice(0, MAX_VISION_IMAGES); const textOnlySlice = allWithSrc.slice(MAX_VISION_IMAGES); - const noSrcImages = pdfImages.filter((img) => !imageMapping[img.id]); + const noSrcImages = prioritizedImages.filter((img) => !imageMapping[img.id]); const visionDescriptions = visionSlice.map((img) => formatImagePlaceholder(img, requirements.language), @@ -148,7 +150,7 @@ export async function POST(req: NextRequest) { })); } else { // Text-only mode: full descriptions - availableImagesText = pdfImages + availableImagesText = prioritizedImages .map((img) => formatImageDescription(img, requirements.language)) .join('\n'); } diff --git a/app/generation-preview/page.tsx b/app/generation-preview/page.tsx index 213a5140..adb6d31e 100644 --- a/app/generation-preview/page.tsx +++ b/app/generation-preview/page.tsx @@ -23,14 +23,31 @@ import { db } from '@/lib/utils/database'; import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation'; import { nanoid } from 'nanoid'; import type { Stage } from '@/lib/types/stage'; -import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation'; +import type { + SceneOutline, + PdfImage, + ImageMapping, + SessionPdfSource, +} from '@/lib/types/generation'; import { AgentRevealModal } from '@/components/agent/agent-reveal-modal'; import { createLogger } from '@/lib/logger'; import { type GenerationSessionState, ALL_STEPS, getActiveSteps } from './types'; import { StepVisualizer } from './components/visualizers'; +import { + aggregateParsedPdfs, + PDF_PARSE_CONCURRENCY, + type ParsedPdfPart, + type ParsedPdfAsset, +} from '@/lib/pdf/document-aggregator'; const log = createLogger('GenerationPreview'); +type PdfFailureSummary = { + failedNames: string[]; + successCount: number; + totalCount: number; +}; + function GenerationPreviewContent() { const router = useRouter(); const { t } = useI18n(); @@ -45,6 +62,7 @@ function GenerationPreviewContent() { const [statusMessage, setStatusMessage] = useState(''); const [streamingOutlines, setStreamingOutlines] = useState(null); const [truncationWarnings, setTruncationWarnings] = useState([]); + const [pdfFailureSummary, setPdfFailureSummary] = useState(null); const [webSearchSources, setWebSearchSources] = useState>( [], ); @@ -65,6 +83,297 @@ function GenerationPreviewContent() { // Compute active steps based on session state const activeSteps = getActiveSteps(session); + const persistSession = (nextSession: GenerationSessionState) => { + setSession(nextSession); + sessionStorage.setItem('generationSession', JSON.stringify(nextSession)); + }; + + const getSessionPdfSources = (currentSession: GenerationSessionState): SessionPdfSource[] => { + if ((currentSession.pdfSources?.length ?? 0) > 0) { + return currentSession.pdfSources!; + } + + if (currentSession.pdfStorageKey && !currentSession.pdfText) { + return [ + { + id: currentSession.pdfStorageKey, + name: currentSession.pdfFileName || 'document.pdf', + size: 0, + storageKey: currentSession.pdfStorageKey, + order: 1, + status: 'pending', + }, + ]; + } + + return []; + }; + + const buildPdfFailureSummary = ( + currentSession: GenerationSessionState, + ): PdfFailureSummary | null => { + const pdfSources = getSessionPdfSources(currentSession); + const failedSources = pdfSources.filter((source) => source.status === 'failed'); + const successCount = pdfSources.filter((source) => source.status === 'success').length; + if (!currentSession.pdfText || failedSources.length === 0 || successCount === 0) { + return null; + } + + return { + failedNames: failedSources.map((source) => source.name), + successCount, + totalCount: pdfSources.length, + }; + }; + + const hasPendingPdfFailureGate = (currentSession: GenerationSessionState): boolean => + buildPdfFailureSummary(currentSession) !== null; + + const getPdfSourceStatusLabel = (status: SessionPdfSource['status']) => { + switch (status) { + case 'parsing': + return t('generation.pdfFileParsing'); + case 'success': + return t('generation.pdfFileSuccess'); + case 'failed': + return t('generation.pdfFileFailed'); + default: + return t('generation.pdfFilePending'); + } + }; + + const parseSinglePdfSource = async ( + source: SessionPdfSource, + currentSession: GenerationSessionState, + signal: AbortSignal, + ): Promise => { + const pdfBlob = await loadPdfBlob(source.storageKey); + if (!pdfBlob || !(pdfBlob instanceof Blob) || pdfBlob.size === 0) { + throw new Error(t('generation.pdfLoadFailed')); + } + + const pdfFile = new File([pdfBlob], source.name || 'document.pdf', { + type: 'application/pdf', + }); + + const parseFormData = new FormData(); + parseFormData.append('pdf', pdfFile); + + if (currentSession.pdfProviderId) { + parseFormData.append('providerId', currentSession.pdfProviderId); + } + if (currentSession.pdfProviderConfig?.apiKey?.trim()) { + parseFormData.append('apiKey', currentSession.pdfProviderConfig.apiKey); + } + if (currentSession.pdfProviderConfig?.baseUrl?.trim()) { + parseFormData.append('baseUrl', currentSession.pdfProviderConfig.baseUrl); + } + + const parseResponse = await fetch('/api/parse-pdf', { + method: 'POST', + body: parseFormData, + signal, + }); + + if (!parseResponse.ok) { + const errorData = await parseResponse.json().catch(() => ({ + error: t('generation.pdfParseFailed'), + })); + throw new Error(errorData.error || t('generation.pdfParseFailed')); + } + + const parseResult = await parseResponse.json(); + if (!parseResult.success || !parseResult.data) { + throw new Error(t('generation.pdfParseFailed')); + } + + const text = String(parseResult.data.text || ''); + const rawPdfImages = parseResult.data.metadata?.pdfImages; + const images: ParsedPdfAsset[] = rawPdfImages + ? rawPdfImages.map( + (img: { + id: string; + src?: string; + pageNumber?: number; + description?: string; + width?: number; + height?: number; + }) => ({ + id: img.id, + src: img.src || '', + pageNumber: img.pageNumber || 1, + description: img.description, + width: img.width, + height: img.height, + }), + ) + : (parseResult.data.images as string[]).map((src: string, index: number) => ({ + id: `img_${index + 1}`, + src, + pageNumber: 1, + })); + + return { + source, + text, + rawTextLength: text.length, + pageCount: Number(parseResult.data.metadata?.pageCount || 0), + images, + }; + }; + + const parsePdfSources = async ( + currentSession: GenerationSessionState, + signal: AbortSignal, + ): Promise<{ nextSession: GenerationSessionState; haltedForFailure: boolean }> => { + const pdfSources: SessionPdfSource[] = getSessionPdfSources(currentSession) + .slice() + .sort((a, b) => a.order - b.order) + .map((source) => ({ + ...source, + status: + source.status === 'success' && currentSession.pdfText + ? ('success' as const) + : ('pending' as const), + error: undefined, + pageCount: undefined, + extractedChars: undefined, + extractedImages: undefined, + })); + + let workingSession: GenerationSessionState = { + ...currentSession, + pdfSources, + pdfText: '', + pdfImages: [], + imageStorageIds: [], + sceneOutlines: null, + }; + + persistSession(workingSession); + setStatusMessage( + t('generation.pdfParsingProgress') + .replace('{done}', '0') + .replace('{total}', String(pdfSources.length)), + ); + + const updatePdfSource = ( + sourceId: string, + patch: Partial, + options?: { setProgress?: boolean }, + ) => { + const nextSources: SessionPdfSource[] = (workingSession.pdfSources || []).map((source) => + source.id === sourceId ? { ...source, ...patch } : source, + ); + workingSession = { + ...workingSession, + pdfSources: nextSources, + }; + if (options?.setProgress !== false) { + const doneCount = nextSources.filter( + (source) => source.status === 'success' || source.status === 'failed', + ).length; + setStatusMessage( + t('generation.pdfParsingProgress') + .replace('{done}', String(doneCount)) + .replace('{total}', String(nextSources.length)), + ); + } + persistSession(workingSession); + }; + + const queue = [...pdfSources]; + const parsedResults: ParsedPdfPart[] = []; + const workerCount = Math.min(PDF_PARSE_CONCURRENCY, queue.length); + + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (queue.length > 0) { + const source = queue.shift(); + if (!source) break; + + updatePdfSource(source.id, { + status: 'parsing', + error: undefined, + }); + + try { + const result = await parseSinglePdfSource(source, currentSession, signal); + parsedResults.push(result); + updatePdfSource(source.id, { + status: 'success', + pageCount: result.pageCount, + extractedChars: result.rawTextLength, + extractedImages: result.images.length, + }); + } catch (parseError) { + updatePdfSource(source.id, { + status: 'failed', + error: parseError instanceof Error ? parseError.message : String(parseError), + }); + } + } + }), + ); + + const successfulParts = parsedResults.sort((a, b) => a.source.order - b.source.order); + if (successfulParts.length === 0) { + throw new Error(t('generation.pdfParseAllFailed')); + } + + const aggregated = aggregateParsedPdfs(successfulParts, { + maxChars: MAX_PDF_CONTENT_CHARS, + maxVisionImages: MAX_VISION_IMAGES, + }); + const imageStorageIds = await storeImages(aggregated.pdfImages); + const pdfImages: PdfImage[] = aggregated.pdfImages.map((image, index) => ({ + ...image, + src: '', + storageId: imageStorageIds[index], + })); + + const nextSession: GenerationSessionState = { + ...workingSession, + pdfText: aggregated.pdfText, + pdfImages, + imageStorageIds, + pdfStorageKey: undefined, + pdfFileName: undefined, + }; + + persistSession(nextSession); + + const warnings: string[] = []; + if (aggregated.totalRawTextLength > aggregated.textContentBudget) { + warnings.push( + t('generation.textTruncated').replace('{n}', String(aggregated.textContentBudget)), + ); + } + if (aggregated.totalImageCount > MAX_VISION_IMAGES) { + warnings.push( + t('generation.imageTruncated') + .replace('{total}', String(aggregated.totalImageCount)) + .replace('{max}', String(MAX_VISION_IMAGES)), + ); + } + setTruncationWarnings(warnings); + + const failureSummary = buildPdfFailureSummary(nextSession); + setPdfFailureSummary(failureSummary); + if (failureSummary) { + setStatusMessage( + t('generation.pdfPartialFailed') + .replace('{failed}', String(failureSummary.failedNames.length)) + .replace('{success}', String(failureSummary.successCount)), + ); + } + + return { + nextSession, + haltedForFailure: failureSummary !== null, + }; + }; + // Load session from sessionStorage useEffect(() => { cleanupOldImages(24).catch((e) => log.error(e)); @@ -73,7 +382,20 @@ function GenerationPreviewContent() { if (saved) { try { const parsed = JSON.parse(saved) as GenerationSessionState; - setSession(parsed); + const normalizedSession = + !parsed.pdfText && + (!parsed.pdfSources || parsed.pdfSources.length === 0) && + parsed.pdfStorageKey + ? { + ...parsed, + pdfSources: getSessionPdfSources(parsed), + } + : parsed; + + setSession(normalizedSession); + if (normalizedSession !== parsed) { + sessionStorage.setItem('generationSession', JSON.stringify(normalizedSession)); + } } catch (e) { log.error('Failed to parse generation session:', e); } @@ -119,16 +441,35 @@ function GenerationPreviewContent() { // Auto-start generation when session is loaded useEffect(() => { - if (session && !hasStartedRef.current) { + if (session && !hasStartedRef.current && !hasPendingPdfFailureGate(session)) { hasStartedRef.current = true; startGeneration(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [session]); - // Main generation flow - const startGeneration = async () => { + useEffect(() => { if (!session) return; + const failureSummary = buildPdfFailureSummary(session); + setPdfFailureSummary(failureSummary); + if (failureSummary) { + setStatusMessage( + t('generation.pdfPartialFailed') + .replace('{failed}', String(failureSummary.failedNames.length)) + .replace('{success}', String(failureSummary.successCount)), + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session]); + + // Main generation flow + const startGeneration = async (options?: { + retryFailed?: boolean; + bypassPdfFailureGate?: boolean; + sessionOverride?: GenerationSessionState; + }) => { + const baseSession = options?.sessionOverride ?? session; + if (!baseSession) return; // Create AbortController for this generation run abortControllerRef.current?.abort(); @@ -137,17 +478,46 @@ function GenerationPreviewContent() { const signal = controller.signal; // Use a local mutable copy so we can update it after PDF parsing - let currentSession = session; + let currentSession = baseSession; + + if (options?.retryFailed) { + currentSession = { + ...currentSession, + pdfSources: getSessionPdfSources(currentSession).map((source) => ({ + ...source, + status: 'pending', + pageCount: undefined, + extractedChars: undefined, + extractedImages: undefined, + error: undefined, + })), + pdfText: '', + pdfImages: [], + imageStorageIds: [], + sceneOutlines: null, + }; + persistSession(currentSession); + } setError(null); setCurrentStepIndex(0); + setStatusMessage(''); + setTruncationWarnings([]); + if (options?.retryFailed || options?.bypassPdfFailureGate) { + setPdfFailureSummary(null); + } try { + if (hasPendingPdfFailureGate(currentSession) && !options?.bypassPdfFailureGate) { + return; + } + // Compute active steps for this session (recomputed after session mutations) let activeSteps = getActiveSteps(currentSession); // Determine if we need the PDF analysis step - const hasPdfToAnalyze = !!currentSession.pdfStorageKey && !currentSession.pdfText; + const hasPdfToAnalyze = + getSessionPdfSources(currentSession).length > 0 && !currentSession.pdfText; // If no PDF to analyze, skip to the next available step if (!hasPdfToAnalyze) { const firstNonPdfIdx = activeSteps.findIndex((s) => s.id !== 'pdf-analysis'); @@ -156,145 +526,14 @@ function GenerationPreviewContent() { // Step 0: Parse PDF if needed if (hasPdfToAnalyze) { - log.debug('=== Generation Preview: Parsing PDF ==='); - const pdfBlob = await loadPdfBlob(currentSession.pdfStorageKey!); - if (!pdfBlob) { - throw new Error(t('generation.pdfLoadFailed')); - } - - // Ensure pdfBlob is a valid Blob with content - if (!(pdfBlob instanceof Blob) || pdfBlob.size === 0) { - log.error('Invalid PDF blob:', { - type: typeof pdfBlob, - size: pdfBlob instanceof Blob ? pdfBlob.size : 'N/A', - }); - throw new Error(t('generation.pdfLoadFailed')); - } - - // Wrap as a File to guarantee multipart/form-data with correct content-type - const pdfFile = new File([pdfBlob], currentSession.pdfFileName || 'document.pdf', { - type: 'application/pdf', - }); - - const parseFormData = new FormData(); - parseFormData.append('pdf', pdfFile); - - if (currentSession.pdfProviderId) { - parseFormData.append('providerId', currentSession.pdfProviderId); - } - if (currentSession.pdfProviderConfig?.apiKey?.trim()) { - parseFormData.append('apiKey', currentSession.pdfProviderConfig.apiKey); - } - if (currentSession.pdfProviderConfig?.baseUrl?.trim()) { - parseFormData.append('baseUrl', currentSession.pdfProviderConfig.baseUrl); - } - - const parseResponse = await fetch('/api/parse-pdf', { - method: 'POST', - body: parseFormData, - signal, - }); - - if (!parseResponse.ok) { - const errorData = await parseResponse.json(); - throw new Error(errorData.error || t('generation.pdfParseFailed')); - } - - const parseResult = await parseResponse.json(); - if (!parseResult.success || !parseResult.data) { - throw new Error(t('generation.pdfParseFailed')); - } - - let pdfText = parseResult.data.text as string; - - // Truncate if needed - if (pdfText.length > MAX_PDF_CONTENT_CHARS) { - pdfText = pdfText.substring(0, MAX_PDF_CONTENT_CHARS); - } - - // Create image metadata and store images - // Prefer metadata.pdfImages (both parsers now return this) - const rawPdfImages = parseResult.data.metadata?.pdfImages; - const images = rawPdfImages - ? rawPdfImages.map( - (img: { - id: string; - src?: string; - pageNumber?: number; - description?: string; - width?: number; - height?: number; - }) => ({ - id: img.id, - src: img.src || '', - pageNumber: img.pageNumber || 1, - description: img.description, - width: img.width, - height: img.height, - }), - ) - : (parseResult.data.images as string[]).map((src: string, i: number) => ({ - id: `img_${i + 1}`, - src, - pageNumber: 1, - })); - - const imageStorageIds = await storeImages(images); - - const pdfImages: PdfImage[] = images.map( - ( - img: { - id: string; - src: string; - pageNumber: number; - description?: string; - width?: number; - height?: number; - }, - i: number, - ) => ({ - id: img.id, - src: '', - pageNumber: img.pageNumber, - description: img.description, - width: img.width, - height: img.height, - storageId: imageStorageIds[i], - }), - ); - - // Update session with parsed PDF data - const updatedSession = { - ...currentSession, - pdfText, - pdfImages, - imageStorageIds, - pdfStorageKey: undefined, // Clear so we don't re-parse - }; - setSession(updatedSession); - sessionStorage.setItem('generationSession', JSON.stringify(updatedSession)); - - // Truncation warnings - const warnings: string[] = []; - if ((parseResult.data.text as string).length > MAX_PDF_CONTENT_CHARS) { - warnings.push( - t('generation.textTruncated').replace('{n}', String(MAX_PDF_CONTENT_CHARS)), - ); - } - if (images.length > MAX_VISION_IMAGES) { - warnings.push( - t('generation.imageTruncated') - .replace('{total}', String(images.length)) - .replace('{max}', String(MAX_VISION_IMAGES)), - ); - } - if (warnings.length > 0) { - setTruncationWarnings(warnings); - } - - // Reassign local reference for subsequent steps - currentSession = updatedSession; + log.debug('=== Generation Preview: Parsing PDFs ==='); + const parseOutcome = await parsePdfSources(currentSession, signal); + currentSession = parseOutcome.nextSession; activeSteps = getActiveSteps(currentSession); + if (parseOutcome.haltedForFailure) { + return; + } + setStatusMessage(''); } // Step: Web Search (if enabled) @@ -333,8 +572,7 @@ function GenerationPreviewContent() { researchContext: searchData.context || '', researchSources: sources, }; - setSession(updatedSessionWithSearch); - sessionStorage.setItem('generationSession', JSON.stringify(updatedSessionWithSearch)); + persistSession(updatedSessionWithSearch); currentSession = updatedSessionWithSearch; activeSteps = getActiveSteps(currentSession); } @@ -545,8 +783,7 @@ function GenerationPreviewContent() { }); const updatedSession = { ...currentSession, sceneOutlines: outlines }; - setSession(updatedSession); - sessionStorage.setItem('generationSession', JSON.stringify(updatedSession)); + persistSession(updatedSession); // Outline generation succeeded — clear homepage draft cache try { @@ -748,6 +985,37 @@ function GenerationPreviewContent() { router.push('/'); }; + const handleRetryFailedPdfs = () => { + if (!session) return; + void startGeneration({ + retryFailed: true, + sessionOverride: session, + }); + }; + + const handleContinueWithSuccessfulFiles = () => { + if (!session) return; + + const successfulSources = getSessionPdfSources(session) + .filter((source) => source.status === 'success') + .map((source, index) => ({ + ...source, + order: index + 1, + })); + + const nextSession: GenerationSessionState = { + ...session, + pdfSources: successfulSources, + }; + + setPdfFailureSummary(null); + persistSession(nextSession); + void startGeneration({ + bypassPdfFailureGate: true, + sessionOverride: nextSession, + }); + }; + // Still loading session from sessionStorage if (!sessionLoaded) { return ( @@ -782,6 +1050,11 @@ function GenerationPreviewContent() { activeSteps.length > 0 ? activeSteps[Math.min(currentStepIndex, activeSteps.length - 1)] : ALL_STEPS[0]; + const pdfSourcesForDisplay = getSessionPdfSources(session) + .slice() + .sort((a, b) => a.order - b.order); + const showPdfSourceStatus = + pdfSourcesForDisplay.length > 0 && (activeStep.id === 'pdf-analysis' || pdfFailureSummary); return (
@@ -959,6 +1232,42 @@ function GenerationPreviewContent() { )} + + {showPdfSourceStatus && ( +
+
+ {pdfSourcesForDisplay.map((source) => ( +
+
+

+ {source.order}. {source.name} +

+ {source.error && ( +

{source.error}

+ )} +
+ + {getPdfSourceStatusLabel(source.status)} + +
+ ))} +
+
+ )}
@@ -977,6 +1286,28 @@ function GenerationPreviewContent() { {t('generation.goBackAndRetry')} + ) : pdfFailureSummary ? ( + + + + ) : !isComplete ? ( { return ALL_STEPS.filter((step) => { - if (step.id === 'pdf-analysis') return !!session?.pdfStorageKey; + if (step.id === 'pdf-analysis') { + return Boolean( + session?.pdfStorageKey || ((session?.pdfSources?.length ?? 0) > 0 && !session?.pdfText), + ); + } if (step.id === 'web-search') return !!session?.requirements?.webSearch; if (step.id === 'agent-generation') return useSettingsStore.getState().agentMode === 'auto'; return true; diff --git a/app/page.tsx b/app/page.tsx index 80dfbd85..bec57a5d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -29,8 +29,8 @@ import { GenerationToolbar } from '@/components/generation/generation-toolbar'; import { AgentBar } from '@/components/agent/agent-bar'; import { useTheme } from '@/lib/hooks/use-theme'; import { nanoid } from 'nanoid'; -import { storePdfBlob } from '@/lib/utils/image-storage'; -import type { UserRequirements } from '@/lib/types/generation'; +import { storePdfFiles } from '@/lib/utils/image-storage'; +import type { SelectedPdf, SessionPdfSource, UserRequirements } from '@/lib/types/generation'; import { useSettingsStore } from '@/lib/store/settings'; import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile'; import { @@ -54,14 +54,14 @@ const LANGUAGE_STORAGE_KEY = 'generationLanguage'; const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; interface FormState { - pdfFile: File | null; + pdfFiles: SelectedPdf[]; requirement: string; language: 'zh-CN' | 'en-US'; webSearch: boolean; } const initialFormState: FormState = { - pdfFile: null, + pdfFiles: [], requirement: '', language: 'zh-CN', webSearch: false, @@ -134,6 +134,7 @@ function HomePage() { const [pendingDeleteId, setPendingDeleteId] = useState(null); const toolbarRef = useRef(null); const textareaRef = useRef(null); + const pdfFileMapRef = useRef>({}); // Close dropdowns when clicking outside useEffect(() => { @@ -200,6 +201,49 @@ function HomePage() { } }; + const handlePdfFilesAdd = (files: File[]) => { + setForm((prev) => { + const existingSignatures = new Set( + prev.pdfFiles.map((file) => `${file.name}:${file.size}:${file.lastModified}`), + ); + const nextFiles = [...prev.pdfFiles]; + + for (const file of files) { + const signature = `${file.name}:${file.size}:${file.lastModified}`; + if (existingSignatures.has(signature)) { + continue; + } + + const id = nanoid(); + pdfFileMapRef.current[id] = file; + nextFiles.push({ + id, + name: file.name, + size: file.size, + lastModified: file.lastModified, + storageKey: '', + order: nextFiles.length + 1, + }); + existingSignatures.add(signature); + } + + return { ...prev, pdfFiles: nextFiles }; + }); + }; + + const handlePdfFileRemove = (id: string) => { + delete pdfFileMapRef.current[id]; + setForm((prev) => ({ + ...prev, + pdfFiles: prev.pdfFiles + .filter((file) => file.id !== id) + .map((file, index) => ({ + ...file, + order: index + 1, + })), + })); + }; + const showSetupToast = (icon: React.ReactNode, title: string, desc: string) => { toast.custom( (id) => ( @@ -259,15 +303,11 @@ function HomePage() { webSearch: form.webSearch || undefined, }; - let pdfStorageKey: string | undefined; - let pdfFileName: string | undefined; let pdfProviderId: string | undefined; let pdfProviderConfig: { apiKey?: string; baseUrl?: string } | undefined; + let pdfSources: SessionPdfSource[] | undefined; - if (form.pdfFile) { - pdfStorageKey = await storePdfBlob(form.pdfFile); - pdfFileName = form.pdfFile.name; - + if (form.pdfFiles.length > 0) { const settings = useSettingsStore.getState(); pdfProviderId = settings.pdfProviderId; const providerCfg = settings.pdfProvidersConfig?.[settings.pdfProviderId]; @@ -277,6 +317,30 @@ function HomePage() { baseUrl: providerCfg.baseUrl, }; } + + const orderedFiles = form.pdfFiles + .slice() + .sort((a, b) => a.order - b.order) + .map((meta) => { + const file = pdfFileMapRef.current[meta.id]; + if (!file) { + throw new Error(`Missing selected PDF file: ${meta.name}`); + } + return { meta, file }; + }); + + const storedFiles = await storePdfFiles(orderedFiles.map((entry) => entry.file)); + pdfSources = storedFiles.map(({ file, storageKey }, index) => { + const meta = orderedFiles[index].meta; + return { + id: meta.id, + name: file.name, + size: file.size, + storageKey, + order: meta.order, + status: 'pending', + }; + }); } const sessionState = { @@ -285,8 +349,7 @@ function HomePage() { pdfText: '', pdfImages: [], imageStorageIds: [], - pdfStorageKey, - pdfFileName, + pdfSources, pdfProviderId, pdfProviderConfig, sceneOutlines: null, @@ -556,8 +619,9 @@ function HomePage() { setSettingsSection(section); setSettingsOpen(true); }} - pdfFile={form.pdfFile} - onPdfFileChange={(f) => updateForm('pdfFile', f)} + pdfFiles={form.pdfFiles} + onPdfFilesAdd={handlePdfFilesAdd} + onPdfFileRemove={handlePdfFileRemove} onPdfError={setError} /> diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index 27301bbd..8d3ee356 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -20,7 +20,9 @@ import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants'; import type { WebSearchProviderId } from '@/lib/web-search/types'; import type { ProviderId } from '@/lib/ai/providers'; import type { SettingsSection } from '@/lib/types/settings'; +import type { SelectedPdf } from '@/lib/types/generation'; import { MediaPopover } from '@/components/generation/media-popover'; +import { MAX_PDF_FILES, MAX_TOTAL_PDF_SIZE_BYTES } from '@/lib/pdf/document-aggregator'; // ─── Constants ─────────────────────────────────────────────── const MAX_PDF_SIZE_MB = 50; @@ -34,8 +36,9 @@ export interface GenerationToolbarProps { onWebSearchChange: (v: boolean) => void; onSettingsOpen: (section?: SettingsSection) => void; // PDF - pdfFile: File | null; - onPdfFileChange: (file: File | null) => void; + pdfFiles: SelectedPdf[]; + onPdfFilesAdd: (files: File[]) => void; + onPdfFileRemove: (id: string) => void; onPdfError: (error: string | null) => void; } @@ -46,8 +49,9 @@ export function GenerationToolbar({ webSearch, onWebSearchChange, onSettingsOpen, - pdfFile, - onPdfFileChange, + pdfFiles, + onPdfFilesAdd, + onPdfFileRemove, onPdfError, }: GenerationToolbarProps) { const { t } = useI18n(); @@ -97,14 +101,48 @@ export function GenerationToolbar({ const currentProviderConfig = providersConfig?.[currentProviderId]; // PDF handler - const handleFileSelect = (file: File) => { - if (file.type !== 'application/pdf') return; - if (file.size > MAX_PDF_SIZE_BYTES) { + const handleFileSelect = (incomingFiles: File[]) => { + const validPdfFiles = incomingFiles.filter((file) => file.type === 'application/pdf'); + if (validPdfFiles.length === 0) return; + + if (validPdfFiles.some((file) => file.size > MAX_PDF_SIZE_BYTES)) { onPdfError(t('upload.fileTooLarge')); return; } + + const existingSignatures = new Set( + pdfFiles.map((file) => `${file.name}:${file.size}:${file.lastModified}`), + ); + const dedupedFiles = validPdfFiles.filter((file) => { + const signature = `${file.name}:${file.size}:${file.lastModified}`; + return !existingSignatures.has(signature); + }); + + if (dedupedFiles.length === 0) { + return; + } + + if (pdfFiles.length + dedupedFiles.length > MAX_PDF_FILES) { + onPdfError(t('upload.pdfCountLimit').replace('{n}', String(MAX_PDF_FILES))); + return; + } + + const totalSize = + pdfFiles.reduce((sum, file) => sum + file.size, 0) + + dedupedFiles.reduce((sum, file) => sum + file.size, 0); + + if (totalSize > MAX_TOTAL_PDF_SIZE_BYTES) { + onPdfError( + t('upload.pdfTotalSizeLimit').replace( + '{n}', + String(Math.floor(MAX_TOTAL_PDF_SIZE_BYTES / 1024 / 1024)), + ), + ); + return; + } + onPdfError(null); - onPdfFileChange(file); + onPdfFilesAdd(dedupedFiles); }; // ─── Pill button helper ───────────────────────────── @@ -150,19 +188,13 @@ export function GenerationToolbar({ {/* ── PDF (parser + upload) combined Popover ── */} - {pdfFile ? ( + {pdfFiles.length > 0 ? ( ) : ( @@ -213,33 +245,14 @@ export function GenerationToolbar({ ref={fileInputRef} className="hidden" accept=".pdf" + multiple onChange={(e) => { - const f = e.target.files?.[0]; - if (f) handleFileSelect(f); + const files = Array.from(e.target.files ?? []); + if (files.length > 0) handleFileSelect(files); e.target.value = ''; }} /> - {pdfFile ? ( -
-
-
- -
-
-

{pdfFile.name}

-

- {(pdfFile.size / 1024 / 1024).toFixed(2)} MB -

-
-
- -
- ) : ( +
{ e.preventDefault(); setIsDragging(false); - const f = e.dataTransfer.files?.[0]; - if (f) handleFileSelect(f); + const files = Array.from(e.dataTransfer.files ?? []); + if (files.length > 0) handleFileSelect(files); }} >

{t('toolbar.pdfUpload')}

-

+

{t('upload.pdfSizeLimit')}

+

+ {t('upload.pdfCountLimit').replace('{n}', String(MAX_PDF_FILES))} +

- )} + + {pdfFiles.length > 0 && ( +
+

+ {t('toolbar.pdfMergeOrder')} +

+
+ {pdfFiles + .slice() + .sort((a, b) => a.order - b.order) + .map((file) => ( +
+
+ +
+
+

+ {file.order}. {file.name} +

+

+ {(file.size / 1024 / 1024).toFixed(2)} MB +

+
+ +
+ ))} +
+
+ )} +
diff --git a/lib/generation/outline-generator.ts b/lib/generation/outline-generator.ts index 4849bcef..7ecb9798 100644 --- a/lib/generation/outline-generator.ts +++ b/lib/generation/outline-generator.ts @@ -11,6 +11,7 @@ import type { PdfImage, ImageMapping, } from '@/lib/types/generation'; +import { sortPdfImagesForVision } from '@/lib/pdf/document-aggregator'; import { buildPrompt, PROMPT_IDS } from './prompts'; import { formatImageDescription, formatImagePlaceholder } from './prompt-formatters'; import { parseJsonResponse } from './json-repair'; @@ -44,12 +45,13 @@ export async function generateSceneOutlinesFromRequirements( let visionImages: Array<{ id: string; src: string }> | undefined; if (pdfImages && pdfImages.length > 0) { + const prioritizedImages = sortPdfImagesForVision(pdfImages); if (options?.visionEnabled && options?.imageMapping) { // Vision mode: split into vision images (first N) and text-only (rest) - const allWithSrc = pdfImages.filter((img) => options.imageMapping![img.id]); + const allWithSrc = prioritizedImages.filter((img) => options.imageMapping![img.id]); const visionSlice = allWithSrc.slice(0, MAX_VISION_IMAGES); const textOnlySlice = allWithSrc.slice(MAX_VISION_IMAGES); - const noSrcImages = pdfImages.filter((img) => !options.imageMapping![img.id]); + const noSrcImages = prioritizedImages.filter((img) => !options.imageMapping![img.id]); const visionDescriptions = visionSlice.map((img) => formatImagePlaceholder(img, requirements.language), @@ -67,7 +69,7 @@ export async function generateSceneOutlinesFromRequirements( })); } else { // Text-only mode: full descriptions - availableImagesText = pdfImages + availableImagesText = prioritizedImages .map((img) => formatImageDescription(img, requirements.language)) .join('\n'); } diff --git a/lib/generation/scene-generator.ts b/lib/generation/scene-generator.ts index 1dc22937..8abf94c1 100644 --- a/lib/generation/scene-generator.ts +++ b/lib/generation/scene-generator.ts @@ -8,6 +8,7 @@ import { nanoid } from 'nanoid'; import katex from 'katex'; import { MAX_VISION_IMAGES } from '@/lib/constants/generation'; +import { sortPdfImagesForVision } from '@/lib/pdf/document-aggregator'; import type { SceneOutline, GeneratedSlideContent, @@ -474,12 +475,13 @@ async function generateSlideContent( let visionImages: Array<{ id: string; src: string }> | undefined; if (assignedImages && assignedImages.length > 0) { + const prioritizedImages = sortPdfImagesForVision(assignedImages); if (visionEnabled && imageMapping) { // Vision mode: split into vision images and text-only - const withSrc = assignedImages.filter((img) => imageMapping[img.id]); + const withSrc = prioritizedImages.filter((img) => imageMapping[img.id]); const visionSlice = withSrc.slice(0, MAX_VISION_IMAGES); const textOnlySlice = withSrc.slice(MAX_VISION_IMAGES); - const noSrcImages = assignedImages.filter((img) => !imageMapping[img.id]); + const noSrcImages = prioritizedImages.filter((img) => !imageMapping[img.id]); const visionDescriptions = visionSlice.map((img) => formatImagePlaceholder(img, lang)); const textDescriptions = [...textOnlySlice, ...noSrcImages].map((img) => @@ -494,7 +496,7 @@ async function generateSlideContent( height: img.height, })); } else { - assignedImagesText = assignedImages + assignedImagesText = prioritizedImages .map((img) => formatImageDescription(img, lang)) .join('\n'); } diff --git a/lib/i18n/common.ts b/lib/i18n/common.ts index 1bceb5d6..fac7994d 100644 --- a/lib/i18n/common.ts +++ b/lib/i18n/common.ts @@ -13,6 +13,8 @@ export const commonZhCN = { languageHint: '课程将以此语言生成', pdfParser: '解析器', pdfUpload: '上传 PDF', + pdfFilesSelected: '已选择 {n} 个 PDF', + pdfMergeOrder: '材料将按选择顺序合并', removePdf: '移除文件', webSearchOn: '已开启', webSearchOff: '点击开启', @@ -54,6 +56,8 @@ export const commonEnUS = { languageHint: 'Course will be generated in this language', pdfParser: 'Parser', pdfUpload: 'Upload PDF', + pdfFilesSelected: '{n} PDFs selected', + pdfMergeOrder: 'Materials will be merged in selection order', removePdf: 'Remove file', webSearchOn: 'Enabled', webSearchOff: 'Click to enable', diff --git a/lib/i18n/generation.ts b/lib/i18n/generation.ts index 1ed17f35..9f573948 100644 --- a/lib/i18n/generation.ts +++ b/lib/i18n/generation.ts @@ -11,6 +11,8 @@ export const generationZhCN = { }, upload: { pdfSizeLimit: '支持最大50MB的PDF文件', + pdfCountLimit: '最多可选择 {n} 个 PDF', + pdfTotalSizeLimit: '所选 PDF 总大小需不超过 {n}MB', generateFailed: '生成课堂失败,请重试', requirementPlaceholder: '输入你想学的任何内容,例如:\n「从零学 Python,30 分钟写出第一个程序」\n「用白板给我讲解傅里叶变换」\n「阿瓦隆桌游怎么玩」', @@ -21,6 +23,15 @@ export const generationZhCN = { // Progress steps (used dynamically via activeStep) analyzingPdf: '解析 PDF 文档', analyzingPdfDesc: '正在提取文档结构和内容...', + pdfParsingProgress: '正在解析 {done}/{total} 份 PDF...', + pdfFilePending: '等待中', + pdfFileParsing: '解析中', + pdfFileSuccess: '已完成', + pdfFileFailed: '失败', + pdfParseAllFailed: '所有已选 PDF 都解析失败了,请重试。', + pdfPartialFailed: '{failed} 份 PDF 解析失败,当前可基于 {success} 份成功文件继续。', + continueWithSuccessfulFiles: '使用成功文件继续', + retryFailedPdfs: '重试失败文件', generatingOutlines: '生成课程大纲', generatingOutlinesDesc: '正在构建学习路径...', generatingSlideContent: '生成页面内容', @@ -75,6 +86,8 @@ export const generationEnUS = { }, upload: { pdfSizeLimit: 'Supports PDF files up to 50MB', + pdfCountLimit: 'Up to {n} PDF files', + pdfTotalSizeLimit: 'Total selected PDFs must stay within {n}MB', generateFailed: 'Failed to generate classroom, please try again', requirementPlaceholder: 'Tell me anything you want to learn, e.g.\n"Teach me Python from scratch in 30 minutes"\n"Explain Fourier Transform on the whiteboard"\n"How to play the board game Avalon"', @@ -85,6 +98,16 @@ export const generationEnUS = { // Progress steps (used dynamically via activeStep) analyzingPdf: 'Analyzing PDF Document', analyzingPdfDesc: 'Extracting document structure and content...', + pdfParsingProgress: 'Parsing {done}/{total} PDFs...', + pdfFilePending: 'Pending', + pdfFileParsing: 'Parsing', + pdfFileSuccess: 'Parsed', + pdfFileFailed: 'Failed', + pdfParseAllFailed: 'All selected PDFs failed to parse. Please try again.', + pdfPartialFailed: + '{failed} PDFs failed to parse. You can retry or continue with {success} successful files.', + continueWithSuccessfulFiles: 'Continue with successful files', + retryFailedPdfs: 'Retry failed files', generatingOutlines: 'Drafting Course Outline', generatingOutlinesDesc: 'Structuring the learning path...', generatingSlideContent: 'Generating Page Content', diff --git a/lib/pdf/document-aggregator.ts b/lib/pdf/document-aggregator.ts new file mode 100644 index 00000000..ae0287ba --- /dev/null +++ b/lib/pdf/document-aggregator.ts @@ -0,0 +1,252 @@ +import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation'; +import type { PdfImage, SessionPdfSource } from '@/lib/types/generation'; + +export const MAX_PDF_FILES = 5; +export const MAX_TOTAL_PDF_SIZE_BYTES = 150 * 1024 * 1024; +export const PDF_PARSE_CONCURRENCY = 2; + +const BASE_BUDGET_PER_FILE = 1500; +const RESERVED_BUDGET_RATIO = 0.4; +const SECTION_SEPARATOR = '\n\n---\n\n'; + +export interface ParsedPdfAsset extends Omit { + src: string; +} + +export interface ParsedPdfPart { + source: SessionPdfSource; + text: string; + rawTextLength: number; + pageCount: number; + images: ParsedPdfAsset[]; +} + +export interface AggregatedPdfResult { + pdfText: string; + pdfImages: ParsedPdfAsset[]; + textContentBudget: number; + totalRawTextLength: number; + totalImageCount: number; + visionImageCount: number; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function replaceImageIds(text: string, idMap: ReadonlyMap): string { + let nextText = text; + for (const [fromId, toId] of idMap.entries()) { + nextText = nextText.replace( + new RegExp(`(? 0); + } + + const reserved = Math.min( + lengths.length * BASE_BUDGET_PER_FILE, + Math.floor(maxChars * RESERVED_BUDGET_RATIO), + ); + const basePerFile = Math.floor(reserved / lengths.length); + const initialBudgets = lengths.map((length) => Math.min(length, basePerFile)); + let remainingBudget = maxChars - initialBudgets.reduce((sum, value) => sum + value, 0); + + const unmetIndexes = lengths + .map((length, index) => ({ index, remaining: Math.max(0, length - initialBudgets[index]) })) + .filter((entry) => entry.remaining > 0); + + while (remainingBudget > 0 && unmetIndexes.length > 0) { + const totalRemaining = unmetIndexes.reduce((sum, entry) => sum + entry.remaining, 0); + if (totalRemaining === 0) break; + + let distributed = 0; + for (const entry of unmetIndexes) { + if (remainingBudget === 0) break; + const proportionalShare = Math.floor((remainingBudget * entry.remaining) / totalRemaining); + const allocation = Math.min( + entry.remaining, + proportionalShare > 0 ? proportionalShare : 1, + remainingBudget, + ); + initialBudgets[entry.index] += allocation; + entry.remaining -= allocation; + remainingBudget -= allocation; + distributed += allocation; + } + + if (distributed === 0) break; + + for (let i = unmetIndexes.length - 1; i >= 0; i -= 1) { + if (unmetIndexes[i].remaining === 0) { + unmetIndexes.splice(i, 1); + } + } + } + + return initialBudgets; +} + +function compareImagesForVision(a: ParsedPdfAsset, b: ParsedPdfAsset): number { + const aHasDescription = Number(Boolean(a.description)); + const bHasDescription = Number(Boolean(b.description)); + if (aHasDescription !== bHasDescription) { + return bHasDescription - aHasDescription; + } + + if (a.pageNumber !== b.pageNumber) { + return a.pageNumber - b.pageNumber; + } + + const aArea = (a.width ?? 0) * (a.height ?? 0); + const bArea = (b.width ?? 0) * (b.height ?? 0); + return bArea - aArea; +} + +function pickVisionImages(images: ParsedPdfAsset[], maxImages: number): string[] { + if (images.length === 0 || maxImages <= 0) return []; + + const grouped = new Map(); + for (const image of images) { + const key = image.sourceFileId || 'unknown'; + const bucket = grouped.get(key) ?? []; + bucket.push(image); + grouped.set(key, bucket); + } + + const orderedGroups = Array.from(grouped.values()).map((group) => + [...group].sort(compareImagesForVision), + ); + const selectedIds: string[] = []; + + for (const group of orderedGroups) { + if (selectedIds.length >= maxImages) break; + if (group.length > 0) { + selectedIds.push(group.shift()!.id); + } + } + + while (selectedIds.length < maxImages) { + let addedThisRound = false; + for (const group of orderedGroups) { + if (selectedIds.length >= maxImages) break; + if (group.length > 0) { + selectedIds.push(group.shift()!.id); + addedThisRound = true; + } + } + if (!addedThisRound) break; + } + + return selectedIds; +} + +export function sortPdfImagesForVision< + T extends Pick, +>(images: T[]): T[] { + return [...images].sort((a, b) => { + const priorityDiff = (b.visionPriority ?? 0) - (a.visionPriority ?? 0); + if (priorityDiff !== 0) return priorityDiff; + + if (a.pageNumber !== b.pageNumber) return a.pageNumber - b.pageNumber; + + return a.id.localeCompare(b.id); + }); +} + +export function aggregateParsedPdfs( + parsedParts: ParsedPdfPart[], + options?: { + maxChars?: number; + maxVisionImages?: number; + }, +): AggregatedPdfResult { + const maxChars = options?.maxChars ?? MAX_PDF_CONTENT_CHARS; + const maxVisionImages = options?.maxVisionImages ?? MAX_VISION_IMAGES; + + const orderedParts = [...parsedParts].sort((a, b) => a.source.order - b.source.order); + const stableParts = orderedParts.map((part) => { + const stableIdMap = new Map(); + const stableImages = part.images.map((image, index) => { + const stableId = `img_${part.source.order}_${index + 1}`; + stableIdMap.set(image.id, stableId); + return { + ...image, + id: stableId, + originalId: image.id, + sourceFileId: part.source.id, + sourceFileName: part.source.name, + }; + }); + + return { + ...part, + text: replaceImageIds(part.text, stableIdMap), + images: stableImages, + }; + }); + + const headers = stableParts.map(buildSectionHeader); + const framingChars = + headers.reduce((sum, header) => sum + header.length, 0) + + Math.max(0, stableParts.length - 1) * SECTION_SEPARATOR.length; + const textContentBudget = Math.max(0, maxChars - framingChars); + const textBudgets = allocateTextBudgets( + stableParts.map((part) => part.text.length), + textContentBudget, + ); + + const flattenedStableImages = stableParts.flatMap((part) => part.images); + const finalIdMap = new Map(); + flattenedStableImages.forEach((image, index) => { + finalIdMap.set(image.id, `img_${index + 1}`); + }); + + const rewrittenParts = stableParts.map((part, index) => ({ + header: headers[index], + text: replaceImageIds(part.text, finalIdMap).slice(0, textBudgets[index]), + })); + + const pdfText = rewrittenParts + .map((part) => `${part.header}${part.text}`) + .join(SECTION_SEPARATOR); + + const pdfImages = flattenedStableImages.map((image) => ({ + ...image, + id: finalIdMap.get(image.id) ?? image.id, + })); + + const selectedVisionIds = pickVisionImages(pdfImages, maxVisionImages); + const visionPriorityMap = new Map( + selectedVisionIds.map((id, index) => [id, selectedVisionIds.length - index]), + ); + const prioritizedImages = pdfImages.map((image) => ({ + ...image, + visionPriority: visionPriorityMap.get(image.id) ?? 0, + })); + + return { + pdfText, + pdfImages: prioritizedImages, + textContentBudget, + totalRawTextLength: stableParts.reduce((sum, part) => sum + part.rawTextLength, 0), + totalImageCount: prioritizedImages.length, + visionImageCount: selectedVisionIds.length, + }; +} diff --git a/lib/types/generation.ts b/lib/types/generation.ts index c1e6eb7a..d2b44741 100644 --- a/lib/types/generation.ts +++ b/lib/types/generation.ts @@ -18,9 +18,13 @@ export interface PdfImage { src: string; // base64 data URL (empty when stored in IndexedDB) pageNumber: number; // Page number in PDF description?: string; // Optional description for AI context + originalId?: string; // Original per-file image ID before aggregation + sourceFileId?: string; // Source PDF ID for multi-file uploads + sourceFileName?: string; // Source PDF filename for multi-file uploads storageId?: string; // Reference to IndexedDB (session_xxx_img_1) width?: number; // Image width (px or normalized) height?: number; // Image height (px or normalized) + visionPriority?: number; // Higher priority images should be used first in vision mode } /** @@ -70,6 +74,30 @@ export interface UserRequirements { webSearch?: boolean; // Enable web search for richer context } +export interface SelectedPdf { + id: string; + name: string; + size: number; + lastModified: number; + storageKey: string; + order: number; +} + +export type PdfParseStatus = 'pending' | 'parsing' | 'success' | 'failed'; + +export interface SessionPdfSource { + id: string; + name: string; + size: number; + storageKey: string; + order: number; + status: PdfParseStatus; + pageCount?: number; + extractedChars?: number; + extractedImages?: number; + error?: string; +} + /** * @deprecated Use UserRequirements instead * Legacy structured requirements - kept for backward compatibility diff --git a/lib/utils/image-storage.ts b/lib/utils/image-storage.ts index ea77c7e4..3122e88f 100644 --- a/lib/utils/image-storage.ts +++ b/lib/utils/image-storage.ts @@ -168,6 +168,25 @@ export async function storePdfBlob(file: File): Promise { return storageKey; } +/** + * Store multiple PDF files as blobs in IndexedDB. + * Preserves input order in the returned array. + */ +export async function storePdfFiles( + files: File[], +): Promise> { + const stored: Array<{ file: File; storageKey: string }> = []; + + for (const file of files) { + stored.push({ + file, + storageKey: await storePdfBlob(file), + }); + } + + return stored; +} + /** * Load a PDF Blob from IndexedDB by its storage key. */