app/src/pages/tldraw/CCDocumentIntelligence/CCEnhancedFilePanel.tsx
Agent Zero 067df34c50 fix: correct Material UI icon naming in Header.tsx and update timetable components
- Fix icon naming: remove 'Icon' suffix from MUI icon components in Header.tsx
  (AccessTime, Close, Person, School, Schedule, Class, Book, Settings, Student, Login, Logout)
- Update timetable components to use UserContext instead of ProfileContext
- Fix timetableService naming collision and circular reference
- Update various components for consistency
2026-02-26 07:28:47 +00:00

575 lines
19 KiB
TypeScript

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 from '@mui/icons-material/ExpandMore';
import ChevronRight from '@mui/icons-material/ChevronRight';
import Description from '@mui/icons-material/Description';
import Check from '@mui/icons-material/Check';
import Schedule from '@mui/icons-material/Schedule';
import Visibility from '@mui/icons-material/Visibility';
import Psychology from '@mui/icons-material/Psychology';
import Overview from '@mui/icons-material/Home';
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"
>
<CalendarViewMonth />
</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;