latest
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import {
|
||||
Box, CircularProgress, IconButton, Typography, Collapse, Chip,
|
||||
List, ListItem, ListItemButton, ListItemIcon, ListItemText
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore, ChevronRight, Description, Check, Schedule,
|
||||
Visibility, Psychology, Home as OverviewIcon
|
||||
} from '@mui/icons-material';
|
||||
import { supabase } from '../../../supabaseClient';
|
||||
|
||||
// Types
|
||||
type PageImagesManifest = {
|
||||
version: number;
|
||||
file_id: string;
|
||||
page_count: number;
|
||||
bucket?: string;
|
||||
base_dir?: string;
|
||||
page_images: Array<{
|
||||
page: number;
|
||||
full_image_path: string;
|
||||
thumbnail_path: string;
|
||||
full_dimensions?: { width: number; height: number };
|
||||
thumbnail_dimensions?: { width: number; height: number };
|
||||
}>
|
||||
};
|
||||
|
||||
type OutlineSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
level: number;
|
||||
start_page: number;
|
||||
end_page: number;
|
||||
parent_id?: string | null;
|
||||
children?: string[];
|
||||
};
|
||||
|
||||
type ProcessingStatus = {
|
||||
tika: boolean;
|
||||
frontmatter: boolean;
|
||||
structure_analysis: boolean;
|
||||
split_map: boolean;
|
||||
page_images: boolean;
|
||||
docling_ocr: boolean;
|
||||
docling_no_ocr: boolean;
|
||||
docling_vlm: boolean;
|
||||
};
|
||||
|
||||
type SectionNode = {
|
||||
sec: OutlineSection;
|
||||
children: SectionNode[];
|
||||
};
|
||||
|
||||
interface CCEnhancedFilePanelProps {
|
||||
fileId: string;
|
||||
selectedPage: number;
|
||||
onSelectPage: (page: number) => void;
|
||||
currentSection?: { start: number; end: number };
|
||||
}
|
||||
|
||||
export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
|
||||
fileId, selectedPage, onSelectPage, currentSection
|
||||
}) => {
|
||||
// State
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
||||
const [outline, setOutline] = useState<OutlineSection[]>([]);
|
||||
const [processingStatus, setProcessingStatus] = useState<ProcessingStatus>({
|
||||
tika: false, frontmatter: false, structure_analysis: false, split_map: false,
|
||||
page_images: false, docling_ocr: false, docling_no_ocr: false, docling_vlm: false
|
||||
});
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||
const [selectedView, setSelectedView] = useState<'overview' | 'structure' | 'thumbnails'>('structure');
|
||||
const [thumbUrls] = useState<Map<number, string>>(() => new Map());
|
||||
|
||||
// Refs for scroll syncing
|
||||
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const API_BASE = useMemo(() =>
|
||||
import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'),
|
||||
[]
|
||||
);
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!fileId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
||||
|
||||
// Load page images manifest
|
||||
const manifestRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (manifestRes.ok) {
|
||||
const m: PageImagesManifest = await manifestRes.json();
|
||||
setManifest(m);
|
||||
}
|
||||
|
||||
// Load artefacts to determine processing status and structure
|
||||
const artefactsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (artefactsRes.ok) {
|
||||
const artefacts: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
extra?: {
|
||||
config?: {
|
||||
do_ocr?: boolean;
|
||||
};
|
||||
};
|
||||
}> = await artefactsRes.json();
|
||||
|
||||
// Determine processing status
|
||||
const status: ProcessingStatus = {
|
||||
tika: artefacts.some((a) => a.type === 'tika_json' && a.status === 'completed'),
|
||||
frontmatter: artefacts.some((a) => a.type === 'docling_frontmatter_json' && a.status === 'completed'),
|
||||
structure_analysis: artefacts.some((a) => a.type === 'document_outline_hierarchy' && a.status === 'completed'),
|
||||
split_map: artefacts.some((a) => a.type === 'split_map_json' && a.status === 'completed'),
|
||||
page_images: artefacts.some((a) => a.type === 'page_images' && a.status === 'completed'),
|
||||
docling_ocr: artefacts.some((a) => a.type === 'docling_standard' && (a.extra?.config?.do_ocr === true) && a.status === 'completed'),
|
||||
docling_no_ocr: artefacts.some((a) => a.type === 'docling_standard' && (a.extra?.config?.do_ocr === false) && a.status === 'completed'),
|
||||
docling_vlm: artefacts.some((a) => a.type === 'docling_vlm' && a.status === 'completed')
|
||||
};
|
||||
setProcessingStatus(status);
|
||||
|
||||
// Load document outline/structure
|
||||
const outlineArt = artefacts.find((a) => a.type === 'document_outline_hierarchy' && a.status === 'completed');
|
||||
if (outlineArt) {
|
||||
const structureRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (structureRes.ok) {
|
||||
const structureData = await structureRes.json();
|
||||
const sections = (structureData.sections || []) as OutlineSection[];
|
||||
setOutline(sections);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load file data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [fileId, API_BASE]);
|
||||
|
||||
// Build hierarchical section tree
|
||||
const sectionTree = useMemo(() => {
|
||||
const buildTree = (sections: OutlineSection[]): SectionNode[] => {
|
||||
const roots: SectionNode[] = [];
|
||||
const stack: SectionNode[] = [];
|
||||
const sorted = [...sections].sort((a, b) => a.start_page - b.start_page);
|
||||
|
||||
for (const sec of sorted) {
|
||||
const level = Math.max(1, Number(sec.level || 1));
|
||||
const node: SectionNode = { sec, children: [] };
|
||||
|
||||
// Maintain proper hierarchy based on level
|
||||
while (stack.length && stack.length >= level) stack.pop();
|
||||
const parent = stack[stack.length - 1];
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
stack.push(node);
|
||||
}
|
||||
return roots;
|
||||
};
|
||||
|
||||
return buildTree(outline);
|
||||
}, [outline]);
|
||||
|
||||
// Thumbnail fetching with lazy loading
|
||||
const fetchThumbnail = useCallback(async (page: number): Promise<string | undefined> => {
|
||||
if (!manifest) return undefined;
|
||||
const cached = thumbUrls.get(page);
|
||||
if (cached) return cached;
|
||||
|
||||
const pageIndex = Math.max(0, Math.min((manifest.page_count || 1) - 1, page - 1));
|
||||
const pageInfo = manifest.page_images[pageIndex];
|
||||
if (!pageInfo) return undefined;
|
||||
|
||||
try {
|
||||
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pageInfo.thumbnail_path)}`;
|
||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
||||
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
thumbUrls.set(page, objectUrl);
|
||||
return objectUrl;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [manifest, API_BASE, thumbUrls]);
|
||||
|
||||
// Render overview panel
|
||||
const renderOverview = () => (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Processing Status</Typography>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1, fontWeight: 600 }}>Phase 1: Structure Discovery</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<StatusItem label="Tika Metadata" status={processingStatus.tika} />
|
||||
<StatusItem label="Document Frontmatter" status={processingStatus.frontmatter} />
|
||||
<StatusItem label="Structure Analysis" status={processingStatus.structure_analysis} />
|
||||
<StatusItem label="Split Map" status={processingStatus.split_map} />
|
||||
<StatusItem label="Page Images" status={processingStatus.page_images} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1, fontWeight: 600 }}>Phase 2: Content Processing</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<StatusItem label="OCR Processing" status={processingStatus.docling_ocr} icon={<Visibility />} />
|
||||
<StatusItem label="No-OCR Processing" status={processingStatus.docling_no_ocr} icon={<Description />} />
|
||||
<StatusItem label="VLM Analysis" status={processingStatus.docling_vlm} icon={<Psychology />} />
|
||||
</Box>
|
||||
|
||||
{manifest && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>Document Info</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>
|
||||
{manifest.page_count} pages
|
||||
</Typography>
|
||||
{outline.length > 0 && (
|
||||
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>
|
||||
{outline.length} sections identified
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
// Render document structure tree
|
||||
const renderStructureTree = () => (
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
{sectionTree.length === 0 ? (
|
||||
<Box sx={{ p: 2, color: 'var(--color-text-2)', textAlign: 'center' }}>
|
||||
<Typography variant="body2">
|
||||
{processingStatus.structure_analysis ? 'No document structure detected' : 'Structure analysis pending...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List dense sx={{ py: 0 }}>
|
||||
{sectionTree.map((node) => (
|
||||
<SectionTreeItem
|
||||
key={node.sec.id}
|
||||
node={node}
|
||||
level={1}
|
||||
selectedPage={selectedPage}
|
||||
onSelectPage={onSelectPage}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={(id) => {
|
||||
const newCollapsed = new Set(collapsed);
|
||||
if (newCollapsed.has(id)) {
|
||||
newCollapsed.delete(id);
|
||||
} else {
|
||||
newCollapsed.add(id);
|
||||
}
|
||||
setCollapsed(newCollapsed);
|
||||
}}
|
||||
processingStatus={processingStatus}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
// Render page thumbnails with lazy loading
|
||||
const renderThumbnails = () => {
|
||||
if (!manifest) return null;
|
||||
|
||||
const pages = Array.from({ length: manifest.page_count }, (_, i) => i + 1);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={thumbnailsRef}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
p: 1,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
||||
gap: 1,
|
||||
alignContent: 'start'
|
||||
}}
|
||||
>
|
||||
{pages.map((page) => (
|
||||
<LazyThumbnail
|
||||
key={page}
|
||||
page={page}
|
||||
isSelected={page === selectedPage}
|
||||
isInSection={currentSection ? page >= currentSection.start && page <= currentSection.end : false}
|
||||
fetchThumbnail={fetchThumbnail}
|
||||
onSelect={onSelectPage}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ p: 2, color: 'var(--color-error)' }}>
|
||||
<Typography variant="body2">{error}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Header with navigation tabs */}
|
||||
<Box sx={{
|
||||
borderBottom: '1px solid var(--color-divider)',
|
||||
bgcolor: 'var(--color-panel)',
|
||||
p: 1
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSelectedView('overview')}
|
||||
color={selectedView === 'overview' ? 'primary' : 'default'}
|
||||
title="Processing Overview"
|
||||
>
|
||||
<OverviewIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSelectedView('structure')}
|
||||
color={selectedView === 'structure' ? 'primary' : 'default'}
|
||||
title="Document Structure"
|
||||
>
|
||||
<Description />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSelectedView('thumbnails')}
|
||||
color={selectedView === 'thumbnails' ? 'primary' : 'default'}
|
||||
title="Page Thumbnails"
|
||||
>
|
||||
<Visibility />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content based on selected view */}
|
||||
{selectedView === 'overview' && renderOverview()}
|
||||
{selectedView === 'structure' && renderStructureTree()}
|
||||
{selectedView === 'thumbnails' && renderThumbnails()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Status indicator component
|
||||
const StatusItem: React.FC<{
|
||||
label: string;
|
||||
status: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}> = ({ label, status, icon }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{icon || <Description fontSize="small" />}
|
||||
<Typography variant="body2" sx={{ flex: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{status ? (
|
||||
<Check fontSize="small" color="success" />
|
||||
) : (
|
||||
<Schedule fontSize="small" sx={{ color: 'var(--color-text-3)' }} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
// Section tree item component
|
||||
const SectionTreeItem: React.FC<{
|
||||
node: SectionNode;
|
||||
level: number;
|
||||
selectedPage: number;
|
||||
onSelectPage: (page: number) => void;
|
||||
collapsed: Set<string>;
|
||||
onToggleCollapse: (id: string) => void;
|
||||
processingStatus: ProcessingStatus;
|
||||
}> = ({ node, level, selectedPage, onSelectPage, collapsed, onToggleCollapse, processingStatus }) => {
|
||||
const isCollapsed = collapsed.has(node.sec.id);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isCurrentSection = selectedPage >= node.sec.start_page && selectedPage <= node.sec.end_page;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton
|
||||
sx={{
|
||||
pl: level * 2,
|
||||
py: 0.5,
|
||||
bgcolor: isCurrentSection ? 'var(--color-selected)' : 'transparent',
|
||||
'&:hover': { bgcolor: 'var(--color-hover)' }
|
||||
}}
|
||||
onClick={() => onSelectPage(node.sec.start_page)}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>
|
||||
{hasChildren ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleCollapse(node.sec.id);
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
) : (
|
||||
<Box sx={{ width: 24 }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={node.sec.title || `Section ${node.sec.start_page}`}
|
||||
secondary={`Pages ${node.sec.start_page}-${node.sec.end_page}`}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
sx: { fontWeight: isCurrentSection ? 600 : 400 }
|
||||
}}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
{/* Processing indicators */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{processingStatus.docling_ocr && <Chip size="small" label="OCR" sx={{ fontSize: '10px' }} />}
|
||||
{processingStatus.docling_no_ocr && <Chip size="small" label="Text" sx={{ fontSize: '10px' }} />}
|
||||
{processingStatus.docling_vlm && <Chip size="small" label="VLM" sx={{ fontSize: '10px' }} />}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{hasChildren && !isCollapsed && (
|
||||
<Collapse in={!isCollapsed} timeout="auto">
|
||||
{node.children.map((child) => (
|
||||
<SectionTreeItem
|
||||
key={child.sec.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
selectedPage={selectedPage}
|
||||
onSelectPage={onSelectPage}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
processingStatus={processingStatus}
|
||||
/>
|
||||
))}
|
||||
</Collapse>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Lazy loading thumbnail component
|
||||
const LazyThumbnail: React.FC<{
|
||||
page: number;
|
||||
isSelected: boolean;
|
||||
isInSection: boolean;
|
||||
fetchThumbnail: (page: number) => Promise<string | undefined>;
|
||||
onSelect: (page: number) => void;
|
||||
}> = ({ page, isSelected, isInSection, fetchThumbnail, onSelect }) => {
|
||||
const [src, setSrc] = useState<string | undefined>(undefined);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const imgRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Intersection observer for lazy loading
|
||||
useEffect(() => {
|
||||
if (!imgRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '50px' }
|
||||
);
|
||||
|
||||
observer.observe(imgRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Load thumbnail when visible
|
||||
useEffect(() => {
|
||||
if (isVisible && !src) {
|
||||
fetchThumbnail(page).then(setSrc);
|
||||
}
|
||||
}, [isVisible, page, fetchThumbnail, src]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={imgRef}
|
||||
onClick={() => onSelect(page)}
|
||||
sx={{
|
||||
aspectRatio: '3/4',
|
||||
border: isSelected ? '2px solid var(--color-primary)' : '1px solid var(--color-divider)',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
bgcolor: isInSection ? 'var(--color-selected)' : 'var(--color-panel)',
|
||||
'&:hover': { borderColor: 'var(--color-primary-light)' },
|
||||
transition: 'border-color 0.2s'
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={`Page ${page}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
) : isVisible ? (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%'
|
||||
}}>
|
||||
<CircularProgress size={16} />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 2,
|
||||
right: 2,
|
||||
bgcolor: 'rgba(0,0,0,0.7)',
|
||||
color: 'white',
|
||||
px: 0.5,
|
||||
py: 0.25,
|
||||
borderRadius: 0.5,
|
||||
fontSize: '11px',
|
||||
lineHeight: 1
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CCEnhancedFilePanel;
|
||||
Reference in New Issue
Block a user