feat: implement hybrid image extraction and memory management

Backend:
- Add hybrid image extraction for Direct track (inline image blocks)
- Add render_inline_image_regions() fallback when OCR doesn't find images
- Add check_document_for_missing_images() for detecting missing images
- Add memory management system (MemoryGuard, ModelManager, ServicePool)
- Update pdf_generator_service to handle HYBRID processing track
- Add ElementType.LOGO for logo extraction

Frontend:
- Fix PDF viewer re-rendering issues with memoization
- Add TaskNotFound component and useTaskValidation hook
- Disable StrictMode due to react-pdf incompatibility
- Fix task detail and results page loading states

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-11-26 10:56:22 +08:00
parent ba8ddf2b68
commit 1afdb822c3
26 changed files with 8273 additions and 366 deletions

View File

@@ -1,11 +1,17 @@
import { useState, useMemo } from 'react'
import { Document, Page } from 'react-pdf'
import { useState, useCallback, useMemo, useRef, useEffect } from 'react'
import { Document, Page, pdfjs } from 'react-pdf'
import type { PDFDocumentProxy } from 'pdfjs-dist'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react'
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Loader2 } from 'lucide-react'
import 'react-pdf/dist/Page/AnnotationLayer.css'
import 'react-pdf/dist/Page/TextLayer.css'
// Configure standard font data URL for proper font rendering
const pdfOptions = {
standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`,
}
interface PDFViewerProps {
title?: string
pdfUrl: string
@@ -17,41 +23,56 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
const [numPages, setNumPages] = useState<number>(0)
const [pageNumber, setPageNumber] = useState<number>(1)
const [scale, setScale] = useState<number>(1.0)
const [loading, setLoading] = useState<boolean>(true)
const [documentLoaded, setDocumentLoaded] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
// Memoize the file prop to prevent unnecessary reloads
// Store PDF document reference
const pdfDocRef = useRef<PDFDocumentProxy | null>(null)
// Memoize file config to prevent unnecessary reloads
const fileConfig = useMemo(() => {
return httpHeaders ? { url: pdfUrl, httpHeaders } : pdfUrl
}, [pdfUrl, httpHeaders])
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
setNumPages(numPages)
setLoading(false)
// Reset state when URL changes
useEffect(() => {
setDocumentLoaded(false)
setError(null)
}
setNumPages(0)
setPageNumber(1)
pdfDocRef.current = null
}, [pdfUrl])
const onDocumentLoadError = (error: Error) => {
console.error('Error loading PDF:', error)
setError('Failed to load PDF. Please try again later.')
setLoading(false)
}
const onDocumentLoadSuccess = useCallback((pdf: { numPages: number }) => {
pdfDocRef.current = pdf as unknown as PDFDocumentProxy
setNumPages(pdf.numPages)
setPageNumber(1)
setDocumentLoaded(true)
setError(null)
}, [])
const goToPreviousPage = () => {
const onDocumentLoadError = useCallback((err: Error) => {
console.error('Error loading PDF:', err)
setError('無法載入 PDF 檔案。請稍後再試。')
setDocumentLoaded(false)
pdfDocRef.current = null
}, [])
const goToPreviousPage = useCallback(() => {
setPageNumber((prev) => Math.max(prev - 1, 1))
}
}, [])
const goToNextPage = () => {
const goToNextPage = useCallback(() => {
setPageNumber((prev) => Math.min(prev + 1, numPages))
}
}, [numPages])
const zoomIn = () => {
const zoomIn = useCallback(() => {
setScale((prev) => Math.min(prev + 0.2, 3.0))
}
}, [])
const zoomOut = () => {
const zoomOut = useCallback(() => {
setScale((prev) => Math.max(prev - 0.2, 0.5))
}
}, [])
return (
<Card className={className}>
@@ -69,18 +90,18 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
variant="outline"
size="sm"
onClick={goToPreviousPage}
disabled={pageNumber <= 1 || loading}
disabled={pageNumber <= 1 || !documentLoaded}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm whitespace-nowrap">
Page {pageNumber} of {numPages || '...'}
{pageNumber} / {numPages || '...'}
</span>
<Button
variant="outline"
size="sm"
onClick={goToNextPage}
disabled={pageNumber >= numPages || loading}
disabled={pageNumber >= numPages || !documentLoaded}
>
<ChevronRight className="h-4 w-4" />
</Button>
@@ -92,7 +113,7 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
variant="outline"
size="sm"
onClick={zoomOut}
disabled={scale <= 0.5 || loading}
disabled={scale <= 0.5 || !documentLoaded}
>
<ZoomOut className="h-4 w-4" />
</Button>
@@ -103,7 +124,7 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
variant="outline"
size="sm"
onClick={zoomIn}
disabled={scale >= 3.0 || loading}
disabled={scale >= 3.0 || !documentLoaded}
>
<ZoomIn className="h-4 w-4" />
</Button>
@@ -113,39 +134,48 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
{/* PDF Document */}
<div className="border rounded-md bg-muted/10 overflow-auto max-h-[800px]">
<div className="flex justify-center p-4">
{loading && (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
)}
{error && (
{error ? (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<p className="text-destructive font-semibold mb-2">Error</p>
<p className="text-destructive font-semibold mb-2"></p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
)}
{!error && (
) : (
<Document
key={pdfUrl}
file={fileConfig}
options={pdfOptions}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<div className="text-center">
<Loader2 className="w-12 h-12 animate-spin text-primary mx-auto mb-4" />
<p className="text-muted-foreground"> PDF ...</p>
</div>
</div>
}
>
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={true}
renderAnnotationLayer={true}
className="shadow-lg"
/>
{documentLoaded && (
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={true}
renderAnnotationLayer={true}
className="shadow-lg"
loading={
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
}
error={
<div className="text-center p-4 text-destructive">
{pageNumber}
</div>
}
/>
)}
</Document>
)}
</div>

View File

@@ -0,0 +1,46 @@
import { useNavigate } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Trash2 } from 'lucide-react'
interface TaskNotFoundProps {
taskId: string | null
onClearAndUpload: () => void
}
export default function TaskNotFound({ taskId, onClearAndUpload }: TaskNotFoundProps) {
const navigate = useNavigate()
const handleClick = () => {
onClearAndUpload()
navigate('/upload')
}
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="max-w-md text-center">
<CardHeader>
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-destructive/10 rounded-full flex items-center justify-center">
<Trash2 className="w-8 h-8 text-destructive" />
</div>
</div>
<CardTitle className="text-xl"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
</p>
{taskId && (
<p className="text-xs text-muted-foreground font-mono">
ID: {taskId}
</p>
)}
<Button onClick={handleClick} size="lg">
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,12 +1,14 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { cn } from '@/lib/utils'
import { ChevronDown } from 'lucide-react'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
// Simple native select for backwards compatibility
export interface NativeSelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
options: Array<{ value: string; label: string }>
}
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
const NativeSelect = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
({ className, options, ...props }, ref) => {
return (
<div className="relative">
@@ -33,6 +35,168 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
)
}
)
Select.displayName = 'Select'
NativeSelect.displayName = 'NativeSelect'
export { Select }
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-lg border border-border bg-background px-3 py-2 text-sm',
'ring-offset-background placeholder:text-muted-foreground',
'focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary',
'disabled:cursor-not-allowed disabled:opacity-50',
'[&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none',
'focus:bg-accent focus:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
NativeSelect,
}