diff --git a/app/api/extract-document/route.ts b/app/api/extract-document/route.ts new file mode 100644 index 0000000000..582eeca7f6 --- /dev/null +++ b/app/api/extract-document/route.ts @@ -0,0 +1,184 @@ +import { NextRequest } from 'next/server'; +import { + isServerConfiguredProvider, + resolvePDFApiKey, + resolvePDFBaseUrl, +} from '@/lib/server/provider-config'; +import { PDF_PROVIDERS } from '@/lib/pdf/constants'; +import type { PDFProviderId } from '@/lib/pdf/types'; +import type { ParsedPdfContent } from '@/lib/types/pdf'; +import { + documentArtifactToParsedPdfContent, + getDocumentExtractorProvider, + selectDocumentExtractorProvider, +} from '@/lib/document'; +import { normalizeDocumentMimeType } from '@/lib/document/mime'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; + +const log = createLogger('Extract Document'); + +function isPdfProviderId(providerId: string): providerId is PDFProviderId { + return providerId in PDF_PROVIDERS; +} + +function supportsMimeType(provider: { supportedMimeTypes: string[] }, mimeType: string): boolean { + return provider.supportedMimeTypes.map((type) => type.toLowerCase()).includes(mimeType); +} + +function isSelfHostedMinerUProvider( + providerId: string, +): providerId is Extract { + return providerId === 'mineru'; +} + +function requestedTypeLabel(mimeType: string): string { + if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { + return 'DOCX'; + } + if (mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') { + return 'PPTX'; + } + return mimeType; +} + +export async function POST(req: NextRequest) { + let fileName: string | undefined; + let resolvedProviderId: string | undefined; + try { + const contentType = req.headers.get('content-type') || ''; + if (!contentType.includes('multipart/form-data')) { + log.error('Invalid Content-Type for document upload:', contentType); + return apiError( + 'INVALID_REQUEST', + 400, + `Invalid Content-Type: expected multipart/form-data, got "${contentType}"`, + ); + } + + const formData = await req.formData(); + const documentFile = (formData.get('file') || formData.get('pdf')) as File | null; + const preferredProviderId = formData.get('providerId') as string | null; + const apiKey = formData.get('apiKey') as string | null; + const baseUrl = formData.get('baseUrl') as string | null; + + if (!documentFile) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'No course material file provided'); + } + + fileName = documentFile.name; + const mimeType = normalizeDocumentMimeType({ + mimeType: documentFile.type, + fileName: documentFile.name, + }); + if (!mimeType) { + return apiError( + 'INVALID_REQUEST', + 400, + `Unsupported course material type for "${documentFile.name}"`, + ); + } + + let provider = preferredProviderId + ? getDocumentExtractorProvider(preferredProviderId) + : undefined; + if (preferredProviderId && !provider) { + return apiError( + 'INVALID_REQUEST', + 400, + `Unknown document extractor provider: ${preferredProviderId}`, + ); + } + + if (provider && !supportsMimeType(provider, mimeType)) provider = undefined; + + try { + provider = + provider || + selectDocumentExtractorProvider({ + mimeType, + requiredCapabilities: { text: true }, + }); + } catch (error) { + return apiError( + 'INVALID_REQUEST', + 400, + error instanceof Error ? error.message : `Unsupported course material type "${mimeType}"`, + ); + } + resolvedProviderId = provider.id; + + let managed = isPdfProviderId(provider.id) && isServerConfiguredProvider('pdf', provider.id); + let clientBaseUrl = managed ? undefined : baseUrl || undefined; + if (isSelfHostedMinerUProvider(provider.id) && !managed && !clientBaseUrl) { + const cloudProvider = getDocumentExtractorProvider('mineru-cloud'); + const cloudManaged = isServerConfiguredProvider('pdf', 'mineru-cloud'); + const cloudApiKey = resolvePDFApiKey( + 'mineru-cloud', + cloudManaged ? undefined : apiKey || undefined, + ); + if (cloudProvider && supportsMimeType(cloudProvider, mimeType) && cloudApiKey) { + provider = cloudProvider; + managed = cloudManaged; + clientBaseUrl = managed ? undefined : baseUrl || undefined; + resolvedProviderId = provider.id; + } + } + if (isSelfHostedMinerUProvider(provider.id) && !managed && !clientBaseUrl) { + return apiError( + 'INVALID_REQUEST', + 422, + `${requestedTypeLabel(mimeType)} extraction requires a configured MinerU document extractor. Configure a self-hosted MinerU base URL or a MinerU Cloud API key in PDF provider settings.`, + ); + } + if (clientBaseUrl && process.env.NODE_ENV === 'production') { + const ssrfError = await validateUrlForSSRF(clientBaseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + } + + const config = { + providerId: provider.id, + apiKey: isPdfProviderId(provider.id) + ? resolvePDFApiKey(provider.id, managed ? undefined : apiKey || undefined) + : apiKey || undefined, + baseUrl: isPdfProviderId(provider.id) + ? resolvePDFBaseUrl(provider.id, clientBaseUrl) + : clientBaseUrl, + }; + + const arrayBuffer = await documentFile.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const artifact = await provider.extract({ + buffer, + fileName: documentFile.name, + fileSize: documentFile.size, + mimeType, + config, + }); + const result = documentArtifactToParsedPdfContent(artifact); + + const resultWithMetadata: ParsedPdfContent = { + ...result, + metadata: { + ...result.metadata, + pageCount: result.metadata?.pageCount ?? 0, + fileName: documentFile.name, + fileSize: documentFile.size, + mimeType, + parser: result.metadata?.parser ?? provider.id, + }, + }; + + return apiSuccess({ data: resultWithMetadata }); + } catch (error) { + log.error( + `Document extraction failed [provider=${resolvedProviderId ?? 'unknown'}, file="${fileName ?? 'unknown'}"]:`, + error, + ); + return apiError('PARSE_FAILED', 500, error instanceof Error ? error.message : 'Unknown error'); + } +} diff --git a/app/generation-preview/page.tsx b/app/generation-preview/page.tsx index b489a67875..6dccc88c7b 100644 --- a/app/generation-preview/page.tsx +++ b/app/generation-preview/page.tsx @@ -37,7 +37,12 @@ import type { Stage } from '@/lib/types/stage'; import type { SceneOutline, PdfImage, ImageMapping } 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 { + type GenerationSessionState, + ALL_STEPS, + getActiveSteps, + getGenerationStepText, +} from './types'; import { StepVisualizer } from './components/visualizers'; import { resolveTaskEngineModeFromOutlineDoneEvent } from './vocational-mode'; @@ -231,7 +236,7 @@ function GenerationPreviewContent() { abortControllerRef.current = controller; const signal = controller.signal; - // Use a local mutable copy so we can update it after PDF parsing + // Use a local mutable copy so we can update it after document extraction let currentSession = generationSession; setError(null); @@ -241,38 +246,38 @@ function GenerationPreviewContent() { // Compute active steps for this session (recomputed after session mutations) let activeSteps = getActiveSteps(currentSession); - // Determine if we need the PDF analysis step + // Determine if we need the document analysis step const hasPdfToAnalyze = !!currentSession.pdfStorageKey && !currentSession.pdfText; - // If no PDF to analyze, skip to the next available step + // If no document to analyze, skip to the next available step if (!hasPdfToAnalyze) { const firstNonPdfIdx = activeSteps.findIndex((s) => s.id !== 'pdf-analysis'); setCurrentStepIndex(Math.max(0, firstNonPdfIdx)); } - // Step 0: Parse PDF if needed + // Step 0: Extract uploaded course material if needed if (hasPdfToAnalyze) { - log.debug('=== Generation Preview: Parsing PDF ==='); + log.debug('=== Generation Preview: Extracting course material ==='); const pdfBlob = await loadPdfBlob(currentSession.pdfStorageKey!); if (!pdfBlob) { - throw new Error(t('generation.pdfLoadFailed')); + throw new Error(t('generation.courseMaterialLoadFailed')); } // Ensure pdfBlob is a valid Blob with content if (!(pdfBlob instanceof Blob) || pdfBlob.size === 0) { - log.error('Invalid PDF blob:', { + log.error('Invalid course material blob:', { type: typeof pdfBlob, size: pdfBlob instanceof Blob ? pdfBlob.size : 'N/A', }); - throw new Error(t('generation.pdfLoadFailed')); + throw new Error(t('generation.courseMaterialLoadFailed')); } // 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', + type: currentSession.documentMimeType || pdfBlob.type || 'application/pdf', }); const parseFormData = new FormData(); - parseFormData.append('pdf', pdfFile); + parseFormData.append('file', pdfFile); if (currentSession.pdfProviderId) { parseFormData.append('providerId', currentSession.pdfProviderId); @@ -284,7 +289,7 @@ function GenerationPreviewContent() { parseFormData.append('baseUrl', currentSession.pdfProviderConfig.baseUrl); } - const parseResponse = await fetch('/api/parse-pdf', { + const parseResponse = await fetch('/api/extract-document', { method: 'POST', body: parseFormData, signal, @@ -292,12 +297,12 @@ function GenerationPreviewContent() { if (!parseResponse.ok) { const errorData = await parseResponse.json(); - throw new Error(errorData.error || t('generation.pdfParseFailed')); + throw new Error(errorData.error || t('generation.courseMaterialParseFailed')); } const parseResult = await parseResponse.json(); if (!parseResult.success || !parseResult.data) { - throw new Error(t('generation.pdfParseFailed')); + throw new Error(t('generation.courseMaterialParseFailed')); } let pdfText = parseResult.data.text as string; @@ -358,7 +363,7 @@ function GenerationPreviewContent() { }), ); - // Update session with parsed PDF data + // Update session with extracted document data const updatedSession = { ...currentSession, pdfText, @@ -1137,6 +1142,7 @@ function GenerationPreviewContent() { activeSteps.length > 0 ? activeSteps[Math.min(currentStepIndex, activeSteps.length - 1)] : ALL_STEPS[0]; + const activeStepText = getGenerationStepText(activeStep, session); if (isReviewingOutlines) { const outlineStepIndex = Math.max( @@ -1327,14 +1333,14 @@ function GenerationPreviewContent() { ? t('generation.generationFailed') : isComplete ? t('generation.generationComplete') - : t(activeStep.title)} + : t(activeStepText.title, activeStepText.titleValues)}

{error ? error : isComplete ? t('generation.classroomReady') - : statusMessage || t(activeStep.description)} + : statusMessage || t(activeStepText.description)}

diff --git a/app/generation-preview/types.ts b/app/generation-preview/types.ts index f5b1637b89..9d3b943cd4 100644 --- a/app/generation-preview/types.ts +++ b/app/generation-preview/types.ts @@ -21,6 +21,7 @@ export interface GenerationSessionState { // PDF deferred parsing fields pdfStorageKey?: string; pdfFileName?: string; + documentMimeType?: string; pdfProviderId?: string; pdfProviderConfig?: { apiKey?: string; baseUrl?: string }; // Web search context @@ -42,11 +43,48 @@ export type GenerationStep = { type: 'analysis' | 'writing' | 'visual'; }; +function getDocumentTypeLabel(session: GenerationSessionState | null): string { + const mimeType = session?.documentMimeType; + if (mimeType) { + if (mimeType === 'application/pdf') return 'PDF'; + if (mimeType.includes('wordprocessingml')) return 'DOCX'; + if (mimeType.includes('presentationml')) return 'PPTX'; + if (mimeType === 'text/plain') return 'TXT'; + if (mimeType.includes('markdown')) return 'Markdown'; + } + const extension = session?.pdfFileName?.split('.').pop()?.trim().toLowerCase(); + if (extension === 'pdf') return 'PDF'; + if (extension === 'docx') return 'DOCX'; + if (extension === 'pptx') return 'PPTX'; + if (extension === 'txt') return 'TXT'; + if (extension === 'md' || extension === 'markdown') return 'Markdown'; + return 'document'; +} + +export function getGenerationStepText( + step: GenerationStep, + session: GenerationSessionState | null, +) { + if (step.id === 'pdf-analysis') { + const documentType = getDocumentTypeLabel(session); + return { + title: 'generation.analyzingCourseMaterial', + titleValues: { type: documentType }, + description: 'generation.analyzingCourseMaterialDesc', + }; + } + return { + title: step.title, + titleValues: undefined, + description: step.description, + }; +} + export const ALL_STEPS: GenerationStep[] = [ { id: 'pdf-analysis', - title: 'generation.analyzingPdf', - description: 'generation.analyzingPdfDesc', + title: 'generation.analyzingCourseMaterial', + description: 'generation.analyzingCourseMaterialDesc', icon: ScanLine, type: 'analysis', }, diff --git a/app/page.tsx b/app/page.tsx index b4d32e8cbb..c27bda6b23 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -37,6 +37,7 @@ 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 { normalizeDocumentMimeType } from '@/lib/document/mime'; import type { UserRequirements } from '@/lib/types/generation'; import { useSettingsStore } from '@/lib/store/settings'; import { hasUsableLLMProvider } from '@/lib/store/settings-validation'; @@ -307,12 +308,17 @@ function HomePage() { let pdfStorageKey: string | undefined; let pdfFileName: string | undefined; + let documentMimeType: string | undefined; let pdfProviderId: string | undefined; let pdfProviderConfig: { apiKey?: string; baseUrl?: string } | undefined; if (form.pdfFile) { pdfStorageKey = await storePdfBlob(form.pdfFile); pdfFileName = form.pdfFile.name; + documentMimeType = normalizeDocumentMimeType({ + mimeType: form.pdfFile.type, + fileName: form.pdfFile.name, + }); const settings = useSettingsStore.getState(); pdfProviderId = settings.pdfProviderId; @@ -333,6 +339,7 @@ function HomePage() { imageStorageIds: [], pdfStorageKey, pdfFileName, + documentMimeType, pdfProviderId, pdfProviderConfig, sceneOutlines: null, diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index ca651b23d2..dc7e019009 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -36,10 +36,11 @@ import { } from '@/lib/ai/thinking-config'; import type { SettingsSection } from '@/lib/types/settings'; import { MediaPopover } from '@/components/generation/media-popover'; +import { COURSE_MATERIAL_ACCEPT, isSupportedCourseMaterial } from '@/lib/document/mime'; // ─── Constants ─────────────────────────────────────────────── -const MAX_PDF_SIZE_MB = 50; -const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024; +const MAX_COURSE_MATERIAL_SIZE_MB = 50; +const MAX_COURSE_MATERIAL_SIZE_BYTES = MAX_COURSE_MATERIAL_SIZE_MB * 1024 * 1024; // ─── Types ─────────────────────────────────────────────────── export interface GenerationToolbarProps { @@ -112,10 +113,13 @@ export function GenerationToolbar({ const currentThinkingConfig = thinkingConfigs[getThinkingConfigKey(currentProviderId, currentModelId)]; - // PDF handler + // Course material handler const handleFileSelect = (file: File) => { - if (file.type !== 'application/pdf') return; - if (file.size > MAX_PDF_SIZE_BYTES) { + if (!isSupportedCourseMaterial({ mimeType: file.type, fileName: file.name })) { + onPdfError(t('upload.unsupportedCourseMaterial')); + return; + } + if (file.size > MAX_COURSE_MATERIAL_SIZE_BYTES) { onPdfError(t('upload.fileTooLarge')); return; } @@ -169,7 +173,7 @@ export function GenerationToolbar({ {/* ── Separator ── */}
- {/* ── PDF (parser + upload) combined Popover ── */} + {/* ── Course material (extractor + upload) combined Popover ── */} {pdfFile ? ( @@ -194,10 +198,10 @@ export function GenerationToolbar({ )} - {/* Parser selector */} + {/* Extractor selector */}
- {t('toolbar.pdfParser')} + {t('toolbar.documentExtractor')}