This commit is contained in:
2025-11-14 14:47:26 +00:00
parent 69ecf2c7c1
commit 3b4876793e
104 changed files with 231517 additions and 1029 deletions
+11 -3
View File
@@ -28,7 +28,8 @@ import {
AssignmentTurnedIn as ExamMarkerIcon,
Settings as SettingsIcon,
Search as SearchIcon,
AdminPanelSettings as AdminIcon
AdminPanelSettings as AdminIcon,
Home as HomeIcon
} from '@mui/icons-material';
import { HEADER_HEIGHT } from './Layout';
import { logger } from '../debugConfig';
@@ -125,7 +126,7 @@ const Header: React.FC = () => {
},
fontSize: { xs: '1rem', sm: '1.25rem' }
}}
onClick={() => navigate(isAuthenticated ? '/single-player' : '/')}
onClick={() => navigate(isAuthenticated ? '/dashboard' : '/')}
>
ClassroomCopilot
</Typography>
@@ -192,6 +193,13 @@ const Header: React.FC = () => {
}}
>
{isAuthenticated ? [
<MenuItem key="dashboard" onClick={() => handleNavigation('/dashboard')}>
<ListItemIcon>
<HomeIcon />
</ListItemIcon>
<ListItemText primary="Dashboard" />
</MenuItem>,
<Divider key="dashboard-divider" />,
// Development Tools Section
<MenuItem key="tldraw" onClick={() => handleNavigation('/tldraw-dev')}>
<ListItemIcon>
@@ -302,4 +310,4 @@ const Header: React.FC = () => {
);
};
export default Header;
export default Header;
+3 -3
View File
@@ -37,8 +37,8 @@ export default function AdminDashboard() {
});
const handleReturn = () => {
logger.info('admin-page', '🏠 Returning to single player page');
navigate('/single-player');
logger.info('admin-page', '🏠 Returning to dashboard');
navigate('/dashboard');
};
if (!isSuperAdmin) {
@@ -110,4 +110,4 @@ export default function AdminDashboard() {
</Paper>
</Container>
);
}
}
+3 -3
View File
@@ -17,7 +17,7 @@ const LoginPage: React.FC = () => {
useEffect(() => {
if (user) {
navigate('/single-player');
navigate('/dashboard');
}
}, [user, navigate]);
@@ -25,7 +25,7 @@ const LoginPage: React.FC = () => {
try {
setError(null);
await signIn(credentials.email, credentials.password);
navigate('/single-player');
navigate('/dashboard');
} catch (error) {
logger.error('login-page', '❌ Login failed', error);
setError(error instanceof Error ? error.message : 'Login failed');
@@ -72,4 +72,4 @@ const LoginPage: React.FC = () => {
);
};
export default LoginPage;
export default LoginPage;
+2 -3
View File
@@ -32,7 +32,7 @@ const SignupPage: React.FC = () => {
useEffect(() => {
if (user) {
navigate('/single-player');
navigate('/dashboard');
}
}, [user, navigate]);
@@ -46,7 +46,7 @@ const SignupPage: React.FC = () => {
displayName
);
if (result.user) {
navigate('/single-player');
navigate('/dashboard');
}
} catch (error) {
logger.error('signup-page', '❌ Registration failed', error);
@@ -117,4 +117,3 @@ const SignupPage: React.FC = () => {
};
export default SignupPage;
+876
View File
@@ -0,0 +1,876 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
CardHeader,
Grid,
Alert,
Chip,
LinearProgress,
List,
ListItem,
ListItemText,
ListItemIcon,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Divider,
Select,
MenuItem,
FormControl,
InputLabel,
Paper,
TextField,
Pagination,
Stack
} from '@mui/material';
import {
CloudUpload as UploadIcon,
Folder as FolderIcon,
FolderOpen as FolderOpenIcon,
Description as FileIcon,
Delete as DeleteIcon,
Refresh as RefreshIcon,
PlayArrow as ProcessIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Info as InfoIcon
} from '@mui/icons-material';
import { supabase } from '../../supabaseClient';
import {
pickDirectory,
processDirectoryFiles,
calculateDirectoryStats,
formatFileSize,
isDirectoryPickerSupported,
FileWithPath
} from '../../utils/folderPicker';
interface Cabinet {
id: string;
name: string;
}
interface FileRecord {
id: string;
name: string;
mime_type?: string;
is_directory?: boolean;
size_bytes?: number;
processing_status?: string;
relative_path?: string;
created_at?: string;
}
interface PaginationInfo {
page: number;
per_page: number;
total_count: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
offset: number;
}
interface FileListResponse {
files: FileRecord[];
pagination: PaginationInfo;
filters: {
search?: string;
sort_by: string;
sort_order: string;
include_directories: boolean;
parent_directory_id?: string;
};
}
interface UploadProgress {
path: string;
size: number;
status: 'queued' | 'uploading' | 'done' | 'error';
progress: number;
error?: string;
}
const SimpleUploadTest: React.FC = () => {
// State management
const [cabinets, setCabinets] = useState<Cabinet[]>([]);
const [selectedCabinet, setSelectedCabinet] = useState<string>('');
const [files, setFiles] = useState<FileRecord[]>([]);
const [pagination, setPagination] = useState<PaginationInfo | null>(null);
const [loading, setLoading] = useState(false);
// Pagination and filtering state
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
const [showUploadDialog, setShowUploadDialog] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<FileWithPath[]>([]);
const [directoryStats, setDirectoryStats] = useState<any>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info', text: string } | null>(null);
const [uploadType, setUploadType] = useState<'old' | 'new'>('new');
// Refs
const fileInputRef = useRef<HTMLInputElement>(null);
const dirInputRef = useRef<HTMLInputElement>(null);
const API_BASE = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8080';
const apiFetch = useCallback(async (url: string, init?: { method?: string; body?: FormData | string; headers?: Record<string, string> }) => {
const session = await supabase.auth.getSession();
const token = session?.data?.session?.access_token;
if (!token) {
throw new Error('No authentication token available');
}
const headers = {
'Authorization': `Bearer ${token}`,
...((init?.headers as Record<string, string>) || {})
};
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
const res = await fetch(fullUrl, { ...init, headers });
if (!res.ok) {
const errorText = await res.text();
throw new Error(`HTTP ${res.status}: ${errorText}`);
}
return res.json();
}, [API_BASE]);
// Load cabinets and files
const loadCabinets = useCallback(async () => {
setLoading(true);
try {
const data = await apiFetch('/database/cabinets');
const all = [...(data.owned || []), ...(data.shared || [])];
setCabinets(all);
if (all.length && !selectedCabinet) {
setSelectedCabinet(all[0].id);
}
setMessage({ type: 'success', text: `Loaded ${all.length} cabinets` });
} catch (error: unknown) {
console.error('Failed to load cabinets:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setMessage({ type: 'error', text: `Failed to load cabinets: ${errorMessage}` });
} finally {
setLoading(false);
}
}, [selectedCabinet, apiFetch]);
const loadFiles = useCallback(async (cabinetId: string, page: number = currentPage) => {
if (!cabinetId) return;
setLoading(true);
try {
// Build query parameters for pagination, search, and sorting
const params = new URLSearchParams({
cabinet_id: cabinetId,
page: page.toString(),
per_page: itemsPerPage.toString(),
sort_by: sortBy,
sort_order: sortOrder,
include_directories: 'true'
});
if (searchTerm) {
params.append('search', searchTerm);
}
// Use the new simple upload endpoint for listing files with pagination
const data: FileListResponse = await apiFetch(`/simple-upload/files?${params.toString()}`);
setFiles(data.files || []);
setPagination(data.pagination);
setMessage({
type: 'success',
text: `Loaded ${data.files?.length || 0} files (${data.pagination.total_count} total)`
});
} catch (error: unknown) {
console.error('Failed to load files:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setMessage({ type: 'error', text: `Failed to load files: ${errorMessage}` });
} finally {
setLoading(false);
}
}, [currentPage, itemsPerPage, sortBy, sortOrder, searchTerm, apiFetch]);
useEffect(() => {
loadCabinets();
}, [loadCabinets]);
useEffect(() => {
if (selectedCabinet) {
setCurrentPage(1); // Reset to first page when cabinet changes
loadFiles(selectedCabinet, 1);
}
}, [selectedCabinet, loadFiles]);
// Reload files when pagination/filtering parameters change
useEffect(() => {
if (selectedCabinet) {
loadFiles(selectedCabinet, currentPage);
}
}, [selectedCabinet, loadFiles, currentPage]);
// Search with debouncing
useEffect(() => {
if (selectedCabinet) {
const timeoutId = setTimeout(() => {
setCurrentPage(1); // Reset to first page when searching
loadFiles(selectedCabinet, 1);
}, 500); // 500ms debounce
return () => clearTimeout(timeoutId);
}
}, [searchTerm, selectedCabinet, loadFiles]);
// Single file upload
const handleSingleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || !selectedCabinet) return;
const file = e.target.files[0];
const formData = new FormData();
formData.append('cabinet_id', selectedCabinet);
formData.append('path', file.name);
formData.append('scope', 'teacher');
formData.append('file', file);
try {
setLoading(true);
// Choose endpoint based on upload type
const endpoint = uploadType === 'new' ? '/simple-upload/files/upload' : '/database/files/upload';
const result = await apiFetch(endpoint, {
method: 'POST',
body: formData
});
console.log('Upload result:', result);
setMessage({
type: 'success',
text: `File uploaded successfully using ${uploadType === 'new' ? 'NEW' : 'OLD'} endpoint: ${file.name}`
});
await loadFiles(selectedCabinet);
e.target.value = '';
} catch (error: unknown) {
console.error('Upload failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setMessage({ type: 'error', text: `Upload failed: ${errorMessage}` });
} finally {
setLoading(false);
}
};
// Directory upload handling
const handleDirectoryPicker = async () => {
try {
const files = await pickDirectory();
prepareDirectoryUpload(files);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (errorMessage === 'fallback-input') {
dirInputRef.current?.click();
} else if (errorMessage === 'user-cancelled') {
setMessage({ type: 'info', text: 'Directory selection cancelled' });
} else {
console.error('Directory picker error:', error);
setMessage({ type: 'error', text: 'Failed to pick directory. Trying fallback method...' });
dirInputRef.current?.click();
}
}
};
const handleFallbackDirectorySelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
const files = processDirectoryFiles(e.target.files);
prepareDirectoryUpload(files);
e.target.value = '';
};
const prepareDirectoryUpload = (files: FileWithPath[]) => {
if (files.length === 0) {
setMessage({ type: 'error', text: 'No files selected' });
return;
}
setSelectedFiles(files);
setDirectoryStats(calculateDirectoryStats(files));
const progress: UploadProgress[] = files.map(file => ({
path: file.relativePath,
size: file.size,
status: 'queued',
progress: 0
}));
setUploadProgress(progress);
setShowUploadDialog(true);
};
const startDirectoryUpload = async () => {
if (!selectedCabinet || selectedFiles.length === 0) return;
setIsUploading(true);
try {
const firstFilePath = selectedFiles[0].relativePath;
const directoryName = firstFilePath.split('/')[0] || 'uploaded-folder';
const formData = new FormData();
formData.append('cabinet_id', selectedCabinet);
formData.append('scope', 'teacher');
formData.append('directory_name', directoryName);
selectedFiles.forEach(file => {
formData.append('files', file);
});
const relativePaths = selectedFiles.map(f => f.relativePath);
formData.append('file_paths', JSON.stringify(relativePaths));
const result = await apiFetch('/simple-upload/files/upload-directory', {
method: 'POST',
body: formData
});
console.log('Directory upload result:', result);
setUploadProgress(prev => prev.map(item => ({
...item,
status: 'done',
progress: 100
})));
setMessage({
type: 'success',
text: `Directory uploaded successfully: ${directoryName} (${selectedFiles.length} files)`
});
await loadFiles(selectedCabinet);
setTimeout(() => {
setShowUploadDialog(false);
setIsUploading(false);
setSelectedFiles([]);
setUploadProgress([]);
}, 2000);
} catch (error: unknown) {
console.error('Directory upload failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setMessage({ type: 'error', text: `Directory upload failed: ${errorMessage}` });
setUploadProgress(prev => prev.map(item => ({
...item,
status: 'error',
error: String(error)
})));
setIsUploading(false);
}
};
// Delete file
const handleDelete = async (fileId: string) => {
try {
await apiFetch(`/simple-upload/files/${fileId}`, { method: 'DELETE' });
setMessage({ type: 'success', text: 'File deleted successfully' });
await loadFiles(selectedCabinet);
} catch (error: unknown) {
console.error('Delete failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setMessage({ type: 'error', text: `Delete failed: ${errorMessage}` });
}
};
// Manual processing trigger
const handleManualProcessing = async (fileId: string) => {
try {
const formData = new FormData();
formData.append('processing_type', 'basic');
const result = await apiFetch(`/simple-upload/files/${fileId}/process-manual`, {
method: 'POST',
body: formData
});
console.log('Manual processing result:', result);
setMessage({ type: 'info', text: 'Manual processing triggered (not yet implemented)' });
} catch (error: unknown) {
console.error('Manual processing failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setMessage({ type: 'error', text: `Manual processing failed: ${errorMessage}` });
}
};
const getStatusColor = (status?: string) => {
switch (status) {
case 'uploaded': return 'primary';
case 'processing': return 'warning';
case 'completed': return 'success';
case 'failed': return 'error';
default: return 'default';
}
};
return (
<Box sx={{ p: 3, maxWidth: 1200, mx: 'auto' }}>
<Typography variant="h4" gutterBottom>
🧪 Simple Upload Test Page
</Typography>
<Alert severity="info" sx={{ mb: 3 }}>
<Typography variant="body2">
This page tests the NEW simple upload system (no auto-processing) vs the OLD system (with auto-processing).
Use this to verify that files upload without triggering Docling bundles, Tika runs, etc.
</Typography>
</Alert>
{message && (
<Alert severity={message.type} sx={{ mb: 2 }} onClose={() => setMessage(null)}>
{message.text}
</Alert>
)}
<Grid container spacing={3}>
{/* Upload Controls */}
<Grid item xs={12} md={6}>
<Card>
<CardHeader
title="Upload Controls"
avatar={<UploadIcon />}
/>
<CardContent>
<Box sx={{ mb: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Cabinet</InputLabel>
<Select
value={selectedCabinet}
label="Cabinet"
onChange={(e) => setSelectedCabinet(e.target.value)}
>
{cabinets.map(cabinet => (
<MenuItem key={cabinet.id} value={cabinet.id}>
{cabinet.name}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Box sx={{ mb: 2 }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Upload Type</InputLabel>
<Select
value={uploadType}
label="Upload Type"
onChange={(e) => setUploadType(e.target.value as 'old' | 'new')}
>
<MenuItem value="new">🆕 NEW (Simple, No Auto-Processing)</MenuItem>
<MenuItem value="old">🔄 OLD (Auto-Processing)</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<input
ref={fileInputRef}
type="file"
style={{ display: 'none' }}
onChange={handleSingleUpload}
/>
<Button
variant="outlined"
startIcon={<UploadIcon />}
onClick={() => fileInputRef.current?.click()}
disabled={!selectedCabinet || loading}
>
Upload File
</Button>
<input
ref={dirInputRef}
type="file"
style={{ display: 'none' }}
{...({ webkitdirectory: '' } as any)}
multiple
onChange={handleFallbackDirectorySelect}
/>
<Button
variant="outlined"
startIcon={<FolderOpenIcon />}
onClick={handleDirectoryPicker}
disabled={!selectedCabinet || loading}
>
Upload Directory
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => {
setCurrentPage(1);
setSearchTerm('');
loadFiles(selectedCabinet, 1);
}}
disabled={loading}
>
Refresh
</Button>
</Box>
{isDirectoryPickerSupported() ? (
<Alert severity="success" sx={{ mt: 1 }}>
Modern directory picker supported (Chromium browser)
</Alert>
) : (
<Alert severity="info" sx={{ mt: 1 }}>
Using fallback directory picker (webkitdirectory)
</Alert>
)}
</CardContent>
</Card>
</Grid>
{/* System Info */}
<Grid item xs={12} md={6}>
<Card>
<CardHeader
title="System Info"
avatar={<InfoIcon />}
/>
<CardContent>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="textSecondary">
<strong>Current Endpoints:</strong>
</Typography>
<Typography variant="body2">
NEW: <code>/simple-upload/files/upload</code> (no auto-processing)
</Typography>
<Typography variant="body2">
OLD: <code>/database/files/upload</code> (auto-processing disabled for testing)
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="textSecondary">
<strong>Storage Buckets:</strong>
</Typography>
<Typography variant="body2">
Both systems now use: <code>cc.users</code> (teacher scope)
</Typography>
<Typography variant="caption" color="textSecondary">
Files will be stored in the same bucket for consistency
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="textSecondary">
<strong>Selected Cabinet:</strong>
</Typography>
<Typography variant="body2">
{selectedCabinet || 'None selected'}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="textSecondary">
<strong>Upload Mode:</strong>
</Typography>
<Chip
label={uploadType === 'new' ? 'NEW (Simple)' : 'OLD (Auto-Processing)'}
color={uploadType === 'new' ? 'success' : 'warning'}
/>
</Box>
<Box>
<Typography variant="body2" color="textSecondary">
<strong>Files in Cabinet:</strong>
</Typography>
<Typography variant="h6">
{pagination ? `${pagination.total_count} total (${files.length} on page ${pagination.page})` : files.length}
</Typography>
</Box>
<Box>
<Typography variant="body2" color="textSecondary">
<strong>Pagination:</strong>
</Typography>
<Typography variant="body2">
{itemsPerPage} per page Sort by {sortBy} ({sortOrder})
</Typography>
{searchTerm && (
<Typography variant="caption" color="textSecondary">
Searching: "{searchTerm}"
</Typography>
)}
</Box>
</CardContent>
</Card>
</Grid>
{/* File List */}
<Grid item xs={12}>
<Card>
<CardHeader
title={
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FileIcon />
<Typography variant="h6">
Files {pagination && `(${pagination.total_count} total)`}
</Typography>
</Box>
{pagination && (
<Typography variant="body2" color="textSecondary">
Page {pagination.page} of {pagination.total_pages}
</Typography>
)}
</Box>
}
/>
<CardContent>
{/* Search and Filter Controls */}
<Box sx={{ mb: 2, display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
size="small"
label="Search files"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
sx={{ minWidth: 200 }}
/>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Sort by</InputLabel>
<Select
value={sortBy}
label="Sort by"
onChange={(e) => setSortBy(e.target.value)}
>
<MenuItem value="name">Name</MenuItem>
<MenuItem value="created_at">Date Created</MenuItem>
<MenuItem value="size_bytes">Size</MenuItem>
<MenuItem value="processing_status">Status</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel>Order</InputLabel>
<Select
value={sortOrder}
label="Order"
onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}
>
<MenuItem value="asc">Ascending</MenuItem>
<MenuItem value="desc">Descending</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel>Per page</InputLabel>
<Select
value={itemsPerPage}
label="Per page"
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
>
<MenuItem value={5}>5</MenuItem>
<MenuItem value={10}>10</MenuItem>
<MenuItem value={20}>20</MenuItem>
<MenuItem value={50}>50</MenuItem>
</Select>
</FormControl>
</Box>
{/* File List with Fixed Height */}
<Box sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
height: 400, // Fixed height
overflow: 'auto'
}}>
{loading ? (
<Box sx={{ p: 2 }}>
<LinearProgress />
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
Loading files...
</Typography>
</Box>
) : files.length === 0 ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="textSecondary">
{searchTerm ? 'No files found matching your search.' : 'No files found. Upload some files to test!'}
</Typography>
</Box>
) : (
<List disablePadding>
{files.map((file, index) => (
<React.Fragment key={file.id}>
<ListItem
secondaryAction={
<Box>
<IconButton
size="small"
onClick={() => handleManualProcessing(file.id)}
title="Trigger manual processing"
>
<ProcessIcon />
</IconButton>
<IconButton
size="small"
onClick={() => handleDelete(file.id)}
title="Delete file"
color="error"
>
<DeleteIcon />
</IconButton>
</Box>
}
>
<ListItemIcon>
{file.is_directory ? <FolderIcon /> : <FileIcon />}
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ wordBreak: 'break-all' }}>
{file.name}
</Typography>
{file.is_directory && <Chip label="Directory" size="small" />}
<Chip
label={file.processing_status || 'unknown'}
size="small"
color={getStatusColor(file.processing_status)}
/>
</Box>
}
secondary={
<Box>
<Typography variant="caption" color="textSecondary">
{file.size_bytes ? formatFileSize(file.size_bytes) : 'Unknown size'} {file.mime_type || 'Unknown type'}
</Typography>
{file.relative_path && (
<Typography variant="caption" color="textSecondary" display="block">
Path: {file.relative_path}
</Typography>
)}
</Box>
}
/>
</ListItem>
{index < files.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
)}
</Box>
{/* Pagination Controls */}
{pagination && pagination.total_pages > 1 && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
<Stack spacing={2} alignItems="center">
<Pagination
count={pagination.total_pages}
page={pagination.page}
onChange={(event, value) => setCurrentPage(value)}
color="primary"
showFirstButton
showLastButton
/>
<Typography variant="caption" color="textSecondary">
Showing {pagination.offset + 1}-{Math.min(pagination.offset + pagination.per_page, pagination.total_count)} of {pagination.total_count} files
</Typography>
</Stack>
</Box>
)}
</CardContent>
</Card>
</Grid>
</Grid>
{/* Directory Upload Dialog */}
<Dialog open={showUploadDialog} onClose={() => !isUploading && setShowUploadDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<FolderOpenIcon />
Directory Upload Progress
{isUploading && <LinearProgress sx={{ flexGrow: 1, ml: 2 }} />}
</Box>
</DialogTitle>
<DialogContent>
{directoryStats && (
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
<strong>{directoryStats.fileCount} files</strong> in{' '}
<strong>{directoryStats.directoryCount} folders</strong><br/>
Total size: <strong>{directoryStats.formattedSize}</strong>
</Typography>
</Alert>
)}
<Paper variant="outlined" sx={{ p: 2, maxHeight: 300, overflow: 'auto' }}>
{uploadProgress.map((item, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.5 }}>
<Typography variant="body2" sx={{ flex: 1, mr: 2 }}>
{item.path}
</Typography>
<Typography variant="body2" sx={{ mr: 2, minWidth: 80 }}>
{formatFileSize(item.size)}
</Typography>
<Chip
label={item.status}
size="small"
color={
item.status === 'done' ? 'success' :
item.status === 'error' ? 'error' :
item.status === 'uploading' ? 'primary' : 'default'
}
icon={
item.status === 'done' ? <SuccessIcon /> :
item.status === 'error' ? <ErrorIcon /> : undefined
}
/>
</Box>
))}
</Paper>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowUploadDialog(false)} disabled={isUploading}>
Cancel
</Button>
<Button
onClick={startDirectoryUpload}
variant="contained"
disabled={isUploading || selectedFiles.length === 0}
>
{isUploading ? 'Uploading...' : 'Start Upload'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default SimpleUploadTest;
@@ -0,0 +1,471 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
import { SelectChangeEvent } from '@mui/material/Select';
import { supabase } from '../../../supabaseClient';
type Manifest = {
bucket: string;
entries: Array<{
// Old format
name?: string;
path?: string;
size: number;
content_type?: string;
// New docling_bundle format
filename?: string;
rel_path?: string;
mime_type?: string;
}>;
markdown_full?: string;
markdown_pages?: Array<{ page: number; path: string }>;
html_full?: string;
text_full?: string;
json_full?: string;
doctags_full?: string;
// New docling_bundle format
file_paths?: {
md?: string;
html?: string;
text?: string;
json?: string;
doctags?: string;
};
bundle_type?: string;
};
type Mode = 'markdown_full'|'markdown_pages'|'html_full'|'text_full'|'json_full'|'doctags_full';
export const CCBundleViewer: React.FC<{
fileId: string;
bundleId: string | undefined;
currentPage?: number;
combinedBundles?: Array<{ id: string }>;
}> = ({ fileId, bundleId, currentPage, combinedBundles }) => {
const [manifest, setManifest] = useState<Manifest | null>(null);
const [combinedManifests, setCombinedManifests] = useState<Manifest[] | null>(null);
const [mode, setMode] = useState<Mode>('markdown_full');
const [content, setContent] = useState<string>('');
const [renderHtml, setRenderHtml] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
const API_BASE_FALLBACK = 'http://127.0.0.1:8080';
const proxyUrl = useCallback(async (bucket: string, relPath: string) => {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
return `${API_BASE}/database/files/proxy_signed?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(relPath)}&token=${encodeURIComponent(token)}`;
}, [API_BASE]);
const replaceAllSafe = useCallback((s: string, search: string, replacement: string) => {
if (!s || typeof s !== 'string') return s || '';
return s.split(search).join(replacement);
}, []);
// Normalize manifest format - convert new docling_bundle format to expected format
const normalizeManifest = useCallback((manifest: Manifest): Manifest => {
// If this is a new docling_bundle format with file_paths, convert to expected format
if (manifest.file_paths && manifest.bundle_type === 'docling_bundle') {
return {
...manifest,
markdown_full: manifest.file_paths.md,
html_full: manifest.file_paths.html,
text_full: manifest.file_paths.text,
json_full: manifest.file_paths.json,
doctags_full: manifest.file_paths.doctags,
// Keep original file_paths for reference
file_paths: manifest.file_paths
};
}
return manifest;
}, []);
const buildNameToPath = (m: Manifest | null): Record<string, string> => {
const map: Record<string, string> = {};
if (!m) return map;
console.log('🖼️ Building name-to-path map. Manifest entries:', m.entries?.length || 0);
for (const e of (m.entries || [])) {
if (!e) continue;
// Handle both old format (name/path) and new docling_bundle format (filename/rel_path)
const entryName = e.name || e.filename;
const entryPath = e.path || e.rel_path;
if (!entryName || !entryPath) {
console.log('🖼️ Skipping entry with missing name/path:', e);
continue;
}
// Map filename only (e.g., "image_000000_...png")
const filename = entryName.split('/').pop() || entryName;
map[filename] = entryPath;
// Map full relative path (e.g., "artifacts/image_000000_...png")
map[entryName] = entryPath;
// Map relative path with "./" prefix (e.g., "./artifacts/image_000000_...png")
map[`./${entryName}`] = entryPath;
// For debugging - map any path component variations
if (entryName.includes('/')) {
// Map path without leading directory (e.g., if name is "artifacts/image.png", also map "image.png")
const pathParts = entryName.split('/');
for (let i = 1; i < pathParts.length; i++) {
const partialPath = pathParts.slice(i).join('/');
map[partialPath] = entryPath;
}
}
}
// Debug: log the first few mappings for images
const imageKeys = Object.keys(map).filter(k => k.includes('image_')).slice(0, 3);
console.log('🖼️ Total mappings created:', Object.keys(map).length);
if (imageKeys.length > 0) {
console.log('🖼️ Image path mappings:', imageKeys.map(k => `${k}${map[k]}`));
} else {
console.log('🖼️ No image mappings found. All keys:', Object.keys(map).slice(0, 10));
}
return map;
};
const rewriteHtmlImageSrcs = useCallback((html: string, m: Manifest): string => {
if (!html || typeof html !== 'string') return html || '';
const nameToPath = buildNameToPath(m);
return html.replace(/<img\s+([^>]*?)src=("|')([^"']+)(\2)([^>]*?)>/gi, (_match, pre, q, src, _q2, post) => {
const s = (src || '').trim();
if (s.startsWith('http') || s.startsWith('data:')) return _match; // leave
// Try multiple path resolution strategies
const normalizedKey = s.replace(/^\.\//, '').replace(/^\//, '');
let rel = nameToPath[s] || nameToPath[normalizedKey] || nameToPath[`./${s}`] || nameToPath[`./${normalizedKey}`];
// If still not found, try finding by filename only
if (!rel) {
const filename = normalizedKey.split('/').pop() || normalizedKey;
rel = nameToPath[filename];
}
// If still not found, try partial path matching
if (!rel) {
const matchingKey = Object.keys(nameToPath).find(k =>
k.endsWith(normalizedKey) || k.endsWith(`/${normalizedKey}`) || normalizedKey.endsWith(k)
);
if (matchingKey) {
rel = nameToPath[matchingKey];
}
}
// Debug logging for failed image resolution (less verbose)
if (!rel && s.includes('image_')) {
console.log(`🖼️ HTML: Failed to resolve image: "${s}"`);
}
if (!rel) return _match;
// token added at runtime later; leave placeholder and replace after
const url = `__PROXY__::${rel}`;
return `<img ${pre || ''}src="${url}"${post || ''}>`;
});
}, []);
const markdownToHtmlWithImages = useCallback((md: string, m: Manifest): string => {
if (!md || typeof md !== 'string') return '';
// Replace images ![alt](path) with img tags that proxy to storage
const nameToPath = buildNameToPath(m);
let html = md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
const s = String(url || '').trim();
const altText = String(alt || '');
if (s.startsWith('http') || s.startsWith('data:')) return `<img alt="${altText}" src="${s}">`;
// Try multiple path resolution strategies (same as HTML rewriting)
const normalizedKey = s.replace(/^\.\//, '').replace(/^\//, '');
let rel = nameToPath[s] || nameToPath[normalizedKey] || nameToPath[`./${s}`] || nameToPath[`./${normalizedKey}`];
// If still not found, try finding by filename only
if (!rel) {
const filename = normalizedKey.split('/').pop() || normalizedKey;
rel = nameToPath[filename];
}
// If still not found, try partial path matching
if (!rel) {
const matchingKey = Object.keys(nameToPath).find(k =>
k.endsWith(normalizedKey) || k.endsWith(`/${normalizedKey}`) || normalizedKey.endsWith(k)
);
if (matchingKey) {
rel = nameToPath[matchingKey];
}
}
// Debug logging for failed image resolution (less verbose)
if (!rel && s.includes('image_')) {
console.log(`🖼️ Markdown: Failed to resolve image: "${s}"`);
}
const prox = rel ? `__PROXY__::${rel}` : s; // Use original path as fallback
return `<img alt="${altText}" src="${prox}">`;
});
// Minimal paragraph handling
if (html && typeof html === 'string') {
html = html
.split(/\n{2,}/).map(p => `<p>${p.replace(/\n/g, '<br/>')}</p>`).join('\n');
}
return html || '';
}, []);
useEffect(() => {
const load = async () => {
setError(null);
setCombinedManifests(null);
setManifest(null);
if (combinedBundles && combinedBundles.length > 0) {
try {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const ms: Manifest[] = [];
for (const b of combinedBundles) {
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(b.id)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) continue;
const rawManifest = await res.json();
ms.push(normalizeManifest(rawManifest));
}
setCombinedManifests(ms);
} catch (e: unknown) {
setCombinedManifests(null);
setError(e instanceof Error ? e.message : 'Failed to load combined manifests');
}
return;
}
if (!bundleId) return;
try {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(bundleId)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(await res.text());
const rawManifest: Manifest = await res.json();
const normalizedManifest = normalizeManifest(rawManifest);
setManifest(normalizedManifest);
} catch (e: unknown) {
setManifest(null);
setError(e instanceof Error ? e.message : 'Failed to load bundle manifest');
}
};
load();
}, [fileId, bundleId, API_BASE, combinedBundles, normalizeManifest]);
useEffect(() => {
const loadContent = async () => {
// Combined mode
if (combinedManifests && combinedManifests.length > 0) {
setLoading(true); setError(null);
try {
const bucket = combinedManifests[0]?.bucket || '';
// Build combined output depending on selected mode. If selected mode
// is not available for a part, fall back: markdown_full → html_full → text_full → json_full
let htmlParts: string[] = [];
let textParts: string[] = [];
let jsonParts: string[] = [];
for (const m of combinedManifests) {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
let rel: string | undefined;
if (mode === 'markdown_full') rel = m.markdown_full || m.html_full || m.text_full || m.json_full;
else if (mode === 'html_full') rel = m.html_full || m.markdown_full || m.text_full || m.json_full;
else if (mode === 'text_full') rel = m.text_full || m.markdown_full || m.html_full || m.json_full;
else if (mode === 'json_full') rel = m.json_full || m.text_full || m.markdown_full || m.html_full;
else if (mode === 'doctags_full') rel = m.doctags_full || m.json_full || m.text_full || m.markdown_full;
else if (mode === 'markdown_pages') rel = m.markdown_full || m.html_full || m.text_full || m.json_full;
if (!rel) continue;
const url = await proxyUrl(m.bucket || bucket, rel);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) continue;
const ct = res.headers.get('Content-Type') || '';
if ((mode === 'markdown_full' && m.markdown_full && rel === m.markdown_full) || (!m.html_full && !ct.includes('text/html') && !ct.includes('application/json'))) {
// Treat as markdown
const md = await res.text();
if (!md || typeof md !== 'string') continue;
let h = markdownToHtmlWithImages(md, m);
// Replace placeholders with signed proxy URLs
const matches = [...h.matchAll(/__PROXY__::([^"'>\s]+)/g)].map((mm: RegExpMatchArray) => mm[1]);
const unique = Array.from(new Set(matches));
for (const r of unique) {
const p = await proxyUrl(m.bucket || bucket, r);
h = replaceAllSafe(h, `__PROXY__::${r}`, p);
}
htmlParts.push(h);
textParts.push(md);
} else if ((mode === 'html_full' && m.html_full && rel === m.html_full) || ct.includes('text/html')) {
let htxt = await res.text();
if (!htxt || typeof htxt !== 'string') continue;
let h = rewriteHtmlImageSrcs(htxt, m);
const matches = [...h.matchAll(/__PROXY__::([^"'>\s]+)/g)].map((mm: RegExpMatchArray) => mm[1]);
const unique = Array.from(new Set(matches));
for (const r of unique) {
const p = await proxyUrl(m.bucket || bucket, r);
h = replaceAllSafe(h, `__PROXY__::${r}`, p);
}
htmlParts.push(h);
textParts.push(htxt);
} else if (ct.includes('application/json') || mode === 'doctags_full' || (mode === 'json_full' && rel === m.doctags_full)) {
const js = await res.json();
jsonParts.push(JSON.stringify(js, null, 2));
} else {
const t = await res.text();
if (t && typeof t === 'string') {
textParts.push(t);
}
}
}
if ((mode === 'json_full' || mode === 'doctags_full') && jsonParts.length > 0 && htmlParts.length === 0) {
setRenderHtml('');
setContent(jsonParts.join('\n\n'));
} else if (htmlParts.length > 0) {
setRenderHtml(htmlParts.join('<hr/>'));
setContent('');
} else {
setContent(textParts.join('\n\n'));
setRenderHtml('');
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load combined content');
} finally {
setLoading(false);
}
return;
}
if (!manifest) { setContent(''); return; }
setLoading(true); setError(null);
try {
const bucket = manifest.bucket || '';
let relPath: string | undefined = undefined;
if (mode === 'markdown_full') relPath = manifest.markdown_full;
else if (mode === 'html_full') relPath = manifest.html_full;
else if (mode === 'text_full') relPath = manifest.text_full;
else if (mode === 'json_full') relPath = manifest.json_full;
else if (mode === 'doctags_full') relPath = manifest.doctags_full;
else if (mode === 'markdown_pages') {
const p = Math.max(1, (currentPage || 1));
const rec = (manifest.markdown_pages || []).find(x => x.page === p) || (manifest.markdown_pages || [])[0];
relPath = rec?.path;
}
if (!relPath) { setContent(''); setLoading(false); return; }
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const url = await proxyUrl(bucket, relPath);
let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(await res.text());
const ct = res.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const js = await res.json();
setContent(JSON.stringify(js, null, 2));
setRenderHtml('');
} else {
let txt = await res.text();
if (!txt || typeof txt !== 'string') {
setContent('');
setRenderHtml('');
setLoading(false);
return;
}
// Dev fallback: if we accidentally hit Vite index, refetch from fallback base
if ((ct.includes('text/html') && txt.includes('/@vite/client')) && API_BASE !== API_BASE_FALLBACK) {
const alt = url.replace(API_BASE, API_BASE_FALLBACK);
res = await fetch(alt, { headers: { Authorization: `Bearer ${token}` } });
if (res.ok) {
txt = await res.text();
}
}
setContent(txt);
// Prepare renderable HTML if markdown or html modes
if ((mode === 'markdown_full' || mode === 'markdown_pages') && txt && typeof txt === 'string') {
let html = markdownToHtmlWithImages(txt, manifest);
// Replace placeholders with signed proxy URLs
if (html && typeof html === 'string') {
const tokenUrl = async (rel: string) => await proxyUrl(bucket, rel);
const matches = [...html.matchAll(/__PROXY__::([^"'>\s]+)/g)].map(m => m[1]);
const unique = Array.from(new Set(matches));
for (const rel of unique) {
const p = await tokenUrl(rel);
html = replaceAllSafe(html, `__PROXY__::${rel}`, p);
}
setRenderHtml(html);
} else {
setRenderHtml('');
}
} else if (mode === 'html_full' && txt && typeof txt === 'string') {
let html = rewriteHtmlImageSrcs(txt, manifest);
if (html && typeof html === 'string') {
const matches = [...html.matchAll(/__PROXY__::([^"'>\s]+)/g)].map(m => m[1]);
const unique = Array.from(new Set(matches));
for (const rel of unique) {
const p = await proxyUrl(bucket, rel);
html = replaceAllSafe(html, `__PROXY__::${rel}`, p);
}
setRenderHtml(html);
} else {
setRenderHtml('');
}
} else {
setRenderHtml('');
}
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load bundle content');
} finally { setLoading(false); }
};
loadContent();
}, [manifest, mode, currentPage, API_BASE, fileId, combinedManifests, markdownToHtmlWithImages, rewriteHtmlImageSrcs, proxyUrl, replaceAllSafe]);
const availableModes = useMemo(() => {
const hasCombined = !!(combinedManifests && combinedManifests.length);
const has = (field: keyof Manifest): boolean => {
if (hasCombined) {
return combinedManifests!.some((cm: Manifest) => {
const v = cm[field as keyof Manifest] as unknown;
return Array.isArray(v) ? v.length > 0 : Boolean(v);
});
}
const v = manifest ? (manifest[field as keyof Manifest] as unknown) : undefined;
return Array.isArray(v) ? (v as unknown[]).length > 0 : Boolean(v);
};
const m: Array<{ key: typeof mode; label: string; enabled: boolean }> = [
{ key: 'markdown_full', label: 'Markdown (full)', enabled: has('markdown_full') },
{ key: 'markdown_pages', label: 'Markdown (pages)', enabled: !hasCombined && !!manifest?.markdown_pages?.length },
{ key: 'html_full', label: 'HTML (full)', enabled: has('html_full') },
{ key: 'text_full', label: 'Text (full)', enabled: has('text_full') },
{ key: 'json_full', label: 'JSON (full)', enabled: has('json_full') },
{ key: 'doctags_full', label: 'DocTags (full)', enabled: has('doctags_full') },
];
const first = m.find(x => x.enabled)?.key;
if (first && !m.find(x => x.key === mode && x.enabled)) setMode(first);
return m;
}, [manifest, combinedManifests, mode]);
return (
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 1, borderBottom: '1px solid var(--color-divider)', display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>Docling artefact</Typography>
<Select size="small" value={mode} onChange={(e: SelectChangeEvent<Mode>) => setMode(e.target.value as Mode)}>
{availableModes.map(m => (
<MenuItem key={m.key} value={m.key} disabled={!m.enabled}>{m.label}</MenuItem>
))}
</Select>
</Box>
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
{loading ? <CircularProgress size={18} /> : error ? <Box sx={{ color: 'var(--color-text-2)' }}>{error}</Box> : (
(mode === 'markdown_full' || mode === 'markdown_pages' || mode === 'html_full') && renderHtml ? (
<iframe
title="docling-html"
style={{ width: '100%', height: '100%', border: 'none' }}
srcDoc={`<!doctype html><html><head><meta charset='utf-8'><style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Arial,sans-serif;padding:16px;color:#222} img{max-width:100%;height:auto} pre,code{white-space:pre-wrap;word-break:break-word}</style></head><body>${renderHtml}</body></html>`}
/>
) : (
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{content}</pre>
)
)}
</Box>
</Box>
);
};
export default CCBundleViewer;
@@ -0,0 +1,403 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, CircularProgress, IconButton } from '@mui/material';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import { supabase } from '../../../supabaseClient';
type Artefact = { id: string; type: string; rel_path: string; created_at: string };
type DoclingJson = Record<string, unknown> & {
pages?: Array<{
image_base64?: string;
image?: { uri?: string; image_base64?: string; mimetype?: string };
width?: number;
height?: number;
}> | Record<string, unknown>;
page_images?: Array<{ uri?: string; image_base64?: string }>;
images?: Array<{ uri?: string; image_base64?: string }>;
frontpage?: { image_base64?: string };
cover?: { image_base64?: string };
};
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 };
}>
};
export const CCDoclingViewer: React.FC<{
fileId: string;
currentPage?: number;
onPageChange?: (page: number) => void;
onExtractedText?: (text: string) => void;
onTotalPagesChange?: (total: number) => void;
hideToolbar?: boolean;
sectionRange?: { start: number; end: number };
}> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [images, setImages] = useState<Array<{ src: string; width?: number; height?: number }>>([]);
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
const [pageLocal, setPageLocal] = useState<number>(1);
const page = typeof currentPage === 'number' ? currentPage : pageLocal;
const norm = (s: unknown): string => String(s ?? '')
.replace(/\r\n/g, '\n')
.replace(/\t/g, ' ')
.replace(/[ \f\v]{2,}/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
const asRecord = (v: unknown): Record<string, unknown> => (v && typeof v === 'object') ? (v as Record<string, unknown>) : {};
const asArray = (v: unknown): unknown[] => Array.isArray(v) ? v : [];
const getText = (n: unknown): string => {
const node = asRecord(n);
const cands = [
node['text'], node['orig'], node['content'], node['value'],
node['md'], node['markdown'], node['plain_text'], node['caption'], node['title']
];
const val = cands.find((v): v is string => typeof v === 'string' && v.trim().length > 0);
return norm(val ?? '');
};
const collectSimpleText = (doc: unknown): string => {
const docRec = asRecord(doc);
const d = asRecord(asRecord(docRec['document'])['json_content'] ?? docRec['json_content'] ?? docRec);
const parts: string[] = [];
for (const t of asArray(d['texts'])) {
const txt = getText(t);
if (txt) parts.push(txt);
}
for (const li of asArray(d['lists'])) {
const items = asArray(asRecord(li)['items']).map(getText).filter(Boolean) as string[];
if (items.length) parts.push(norm(items.join('\n')));
}
for (const tbl of asArray(d['tables'])) {
const data = asRecord(asRecord(tbl)['data']);
const grid = asArray(data['grid']);
if (grid.length) {
const rows = grid.map((row) => `| ${asArray(row).map((c) => getText(c)).join(' | ')} |`);
if (rows.length >= 2) {
const firstLen = asArray(grid[0]).length;
rows.splice(1, 0, `| ${Array(firstLen).fill('---').join(' | ')} |`);
}
if (rows.length) parts.push(norm(rows.join('\n')));
} else {
const rowsArr = asArray(data['rows']);
if (rowsArr.length) {
const rows: string[] = [];
for (const r of rowsArr) {
const rRec = asRecord(r);
const cells = (asArray(rRec['cells']).length ? asArray(rRec['cells']) : asArray(r)).map((c) => getText(c));
rows.push(`| ${cells.join(' | ')} |`);
}
if (rows.length) parts.push(norm(rows.join('\n')));
}
}
}
return norm(parts.join('\n\n'));
};
const extractImages = (rawDoc: unknown): Array<{ src: string; width?: number; height?: number }> => {
const docRec = asRecord(rawDoc);
const d = asRecord(asRecord(docRec['document'])['json_content'] ?? docRec['json_content'] ?? docRec);
const out: Array<{ src: string; width?: number; height?: number }> = [];
const pushUri = (uri?: string) => {
if (!uri) return;
if (uri.startsWith('data:')) out.push({ src: uri });
else if (/^[A-Za-z0-9+/=]+$/.test(uri)) out.push({ src: `data:image/png;base64,${uri}` });
};
// Case 1: pages is an array with image_base64 or image.uri
const pagesVal = d['pages'];
if (Array.isArray(pagesVal)) {
for (const p of pagesVal) {
const pRec = asRecord(p);
const image = asRecord(pRec['image']);
const b64 = pRec['image_base64'] as string | undefined;
const b64img = image['image_base64'] as string | undefined;
const uri = image['uri'] as string | undefined;
if (b64) out.push({ src: `data:image/png;base64,${b64}` });
else if (b64img) out.push({ src: `data:image/png;base64,${b64img}` });
else if (uri) pushUri(uri);
}
}
// Case 2: pages is an object keyed by page number, each with image.uri
if (!out.length && pagesVal && typeof pagesVal === 'object' && !Array.isArray(pagesVal)) {
const pagesRec = asRecord(pagesVal);
const keys = Object.keys(pagesRec).sort((a, b) => Number(a) - Number(b));
for (const k of keys) {
const pRec = asRecord(pagesRec[k]);
const img = asRecord(pRec['image']);
const uri = img['uri'] as string | undefined;
const b64 = pRec['image_base64'] as string | undefined;
if (uri) pushUri(uri);
else if (b64) out.push({ src: `data:image/png;base64,${b64}` });
}
}
// Case 3: page_images or images arrays with data URIs
if (!out.length && Array.isArray(d['page_images'])) {
for (const im of d['page_images'] as Array<Record<string, unknown>>) pushUri((im['uri'] as string | undefined) || (im['image_base64'] as string | undefined));
}
if (!out.length && Array.isArray(d['images'])) {
for (const im of d['images'] as Array<Record<string, unknown>>) pushUri((im['uri'] as string | undefined) || (im['image_base64'] as string | undefined));
}
// Fallback: frontpage/cover only
const front = asRecord(d['frontpage']);
const cover = asRecord(d['cover']);
const frontB64 = front['image_base64'] as string | undefined;
const coverB64 = cover['image_base64'] as string | undefined;
if (!out.length && (frontB64 || coverB64)) {
const src = frontB64 || coverB64;
if (src) out.push({ src: `data:image/png;base64,${src}` });
}
return out;
};
useEffect(() => {
const run = async () => {
if (!fileId) return;
setLoading(true);
setError(null);
try {
// Try page-images manifest first
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
try {
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (mRes.ok) {
const m: PageImagesManifest = await mRes.json();
setManifest(m);
setImages([]); // we will render via manifest in viewer
if (!currentPage) setPageLocal(1);
return; // skip legacy docling path
}
} catch (e) {
// ignore and fallback to legacy
}
// Legacy: Load artefacts for file to find docling JSON artefacts
const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api')}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (!artefactsRes.ok) throw new Error(await artefactsRes.text());
const artefacts: Artefact[] = await artefactsRes.json();
// Prefer full-file no-OCR artefact for complete page images
const noocr = artefacts.find(a => a.type === 'docling_noocr_json');
const frontmatter = artefacts.find(a => a.type === 'docling_frontmatter_json');
const target = noocr || frontmatter;
if (!target) {
setError('No Docling artefacts found. Generate initial artefacts from the file menu.');
setImages([]);
return;
}
// Download artefact JSON via backend (service-role) to avoid RLS issues
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(target.id)}/json`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (!jsonRes.ok) throw new Error(await jsonRes.text());
const doc: DoclingJson = await jsonRes.json();
const imgs = extractImages(doc);
setImages(imgs);
if (onExtractedText) {
const text = collectSimpleText(doc);
onExtractedText(text);
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load document');
setImages([]);
} finally {
setLoading(false);
}
};
run();
}, [fileId]); /* eslint-disable-line react-hooks/exhaustive-deps */
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
const pageProxyUrl = useMemo(() => {
if (!manifest) return undefined;
const idx = Math.max(0, Math.min((manifest.page_count || 1) - 1, (page || 1) - 1));
const pg = manifest.page_images[idx];
if (!pg) return undefined;
const bucket = manifest.bucket || '';
const path = pg.full_image_path;
return `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
}, [manifest, page, API_BASE]);
const [pageObjectUrl, setPageObjectUrl] = useState<string | undefined>(undefined);
const [cacheUrls] = useState<Map<number, string>>(() => new Map());
useEffect(() => {
let revoked: string | null = null;
const load = async () => {
if (!pageProxyUrl || !manifest) {
setPageObjectUrl(undefined);
return;
}
// Cache by page number to avoid repeated fetches
const key = page;
const cached = cacheUrls.get(key);
if (cached) {
setPageObjectUrl(cached);
return;
}
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok && manifest) {
// Fallback to thumbnail if the full image is not accessible yet
const idx = Math.max(0, Math.min((manifest.page_count || 1) - 1, (page || 1) - 1));
const pg = manifest.page_images[idx];
if (pg) {
const thumbUrl = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`;
resp = await fetch(thumbUrl, { headers: { Authorization: `Bearer ${token}` } });
}
}
if (!resp.ok) {
setError(`Failed to load page ${page}: ${resp.status}`);
setPageObjectUrl(undefined);
return;
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
cacheUrls.set(key, url);
setPageObjectUrl(url);
revoked = url;
};
load();
return () => {
// Do not revoke cached urls immediately; only revoke if it's a temp assignment
// We keep the cache for navigation performance.
if (revoked && ![...cacheUrls.values()].includes(revoked)) {
URL.revokeObjectURL(revoked);
}
};
}, [pageProxyUrl, manifest, page, cacheUrls]);
const totalPages = manifest?.page_count || images.length || 1;
// Inform parent about total pages when it changes
useEffect(() => {
if (onTotalPagesChange) onTotalPagesChange(totalPages);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [totalPages]);
const handlePageChange = (p: number) => {
const clamped = Math.max(1, Math.min(totalPages, p));
if (onPageChange) onPageChange(clamped);
else setPageLocal(clamped);
};
const content = useMemo(() => {
if (loading) return <Box sx={{ p: 2 }}><CircularProgress size={20} /></Box>;
if (error) return <Box sx={{ p: 2, color: 'var(--color-text-2)' }}>{error}</Box>;
// New single-page view using manifest
if (manifest) {
// Multi-page section view
const start = sectionRange?.start ?? page;
const end = sectionRange?.end ?? page;
const pages: number[] = [];
for (let p = start; p <= Math.min(end, totalPages); p++) pages.push(p);
return (
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
{!hideToolbar && (
<Box sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 1, borderBottom: '1px solid var(--color-divider)' }}>
<IconButton size="small" onClick={() => handlePageChange(start - 1)} disabled={start <= 1}><ArrowBackIosNewIcon fontSize="inherit" /></IconButton>
<Box sx={{ fontSize: 12, color: 'var(--color-text-2)' }}>Section {start}{end}</Box>
<IconButton size="small" onClick={() => handlePageChange(end + 1)} disabled={end >= totalPages}><ArrowForwardIosIcon fontSize="inherit" /></IconButton>
</Box>
)}
<Box sx={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, p: 2 }}>
{pages.map((p) => {
const idx = Math.max(0, Math.min((manifest.page_count || 1) - 1, p - 1));
const pg = manifest.page_images[idx];
if (!pg) return null;
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.full_image_path)}`;
return (
<ImageByProxy key={p} url={url} alt={`Page ${p}`} />
);
})}
</Box>
</Box>
);
}
// Fallback legacy rendering
if (!images.length) return <Box sx={{ p: 2 }}>No page images available.</Box>;
return (
<Box sx={{ width: '100%', height: '100%', overflow: 'auto', p: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
{images.map((img, i) => (
<img key={i} src={img.src} alt={`Page ${i + 1}`} style={{ boxShadow: '0 2px 8px rgba(0,0,0,0.15)', maxWidth: '100%' }} />
))}
</Box>
</Box>
);
}, [loading, error, images, manifest, pageObjectUrl, page, totalPages]);
return (
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
{content}
</Box>
);
};
export default CCDoclingViewer;
const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let revoked: string | null = null;
const load = async () => {
try {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
const obj = URL.createObjectURL(blob);
setBlobUrl(obj);
revoked = obj;
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load page');
} finally {
setLoading(false);
}
};
load();
return () => { if (revoked) URL.revokeObjectURL(revoked); };
}, [url]);
if (loading) return <Box sx={{ p: 2 }}><CircularProgress size={18} /></Box>;
if (error || !blobUrl) return <Box sx={{ p: 2, color: 'var(--color-text-2)' }}>{error || 'No image'}</Box>;
return (
<img src={blobUrl} alt={alt} style={{ maxWidth: '100%', height: 'auto', display: 'block', boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }} />
);
};
@@ -0,0 +1,605 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Box, Button, Divider, FormControlLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material';
import { SelectChangeEvent } from '@mui/material/Select';
// import { CCFilesPanel } from '../../../utils/tldraw/ui-overrides/components/shared/CCFilesPanel';
import { CCDoclingViewer } from './CCDoclingViewer.tsx';
import CCEnhancedFilePanel from './CCEnhancedFilePanel.tsx';
import CCBundleViewer from './CCBundleViewer.tsx';
import { supabase } from '../../../supabaseClient';
type CanonicalDoclingConfig = {
pipeline: 'standard' | 'vlm' | 'asr';
pdf_backend: 'dlparse_v4' | 'pypdfium2' | 'dlparse_v1' | 'dlparse_v2';
do_ocr: boolean;
force_ocr: boolean;
table_mode: 'fast' | 'accurate';
do_picture_classification: boolean;
do_picture_description: boolean;
picture_description_prompt?: string;
// Extended options
target_type?: 'inbody' | 'zip';
image_export_mode?: 'placeholder' | 'embedded' | 'referenced';
table_cell_matching?: boolean;
picture_description_local?: string; // JSON string per API
picture_description_api?: string; // JSON string per API
vlm_pipeline_model?: string;
vlm_pipeline_model_local?: string; // JSON string per API
vlm_pipeline_model_api?: string; // JSON string per API
to_formats?: string[];
do_formula_enrichment?: boolean;
do_code_enrichment?: boolean;
};
type CanonicalDoclingRequest = {
use_split_map: boolean;
config: CanonicalDoclingConfig;
threshold: number;
};
type Profile = 'default' | 'simple' | 'aggressive';
type Pipeline = 'standard' | 'vlm' | 'asr';
type PdfBackend = 'dlparse_v4' | 'pypdfium2' | 'dlparse_v1' | 'dlparse_v2';
type TableMode = 'fast' | 'accurate';
export const CCDocumentIntelligence: React.FC = () => {
const { fileId } = useParams<{ fileId: string }>();
const validFileId = useMemo(() => fileId || '', [fileId]);
const [page, setPage] = useState<number>(1);
const [outlineOptions, setOutlineOptions] = useState<Array<{ id: string; title: string; start_page: number; end_page: number }>>([]);
const [profile, setProfile] = useState<Profile>('default');
const [pipeline, setPipeline] = useState<Pipeline>('standard');
// VLM pipeline config (mutually exclusive options)
type VlmMode = 'preset' | 'local' | 'api';
const [vlmMode, setVlmMode] = useState<VlmMode>('preset');
const [vlmPreset, setVlmPreset] = useState<string>('smoldocling');
const [vlmLocalJson, setVlmLocalJson] = useState<string>('');
const [vlmApiJson, setVlmApiJson] = useState<string>('');
type VlmProvider = 'ollama' | 'openai' | '';
const [vlmProvider, setVlmProvider] = useState<VlmProvider>('');
const [vlmProviderModel, setVlmProviderModel] = useState<string>('');
const [vlmProviderBaseUrl, setVlmProviderBaseUrl] = useState<string>('');
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [pdfBackend, setPdfBackend] = useState<PdfBackend>('dlparse_v4');
const [doOCR, setDoOCR] = useState(true);
const [forceOCR, setForceOCR] = useState(false);
const [tableMode, setTableMode] = useState<TableMode>('fast');
const [doPicClass, setDoPicClass] = useState(false);
const [doPicDesc, setDoPicDesc] = useState(false);
const [picDescPrompt, setPicDescPrompt] = useState('Describe the image succinctly for study notes.');
// Picture description config (mutually exclusive local/api)
type PicDescMode = 'local' | 'api';
const [picDescMode, setPicDescMode] = useState<PicDescMode>('local');
const [picDescLocalJson, setPicDescLocalJson] = useState<string>('');
const [picDescApiJson, setPicDescApiJson] = useState<string>('');
const [busy, setBusy] = useState(false);
// Split sections (from split_map)
const [splitSections, setSplitSections] = useState<Array<{ id: string; title: string; start: number; end: number }>>([]);
const [selectedSectionId, setSelectedSectionId] = useState<string>('full');
// Load available canonical bundles
type Artefact = { id: string; type: string; rel_path: string; extra?: Record<string, unknown>; created_at?: string };
const [bundles, setBundles] = useState<Artefact[]>([]);
const [currentBundle, setCurrentBundle] = useState<string>('');
const [combineSplit, setCombineSplit] = useState<boolean>(false);
// Batch selection (group of split bundles or single bundle)
type BundleGroup = { key: string; label: string; bundleIds: string[]; isGroup: boolean };
const groupItems = useMemo<BundleGroup[]>(() => {
if (!bundles.length) return [];
const byGroup: Record<string, { ids: string[]; meta: { created_at?: string; pipeline?: string; group_pack_type?: string; producer?: string; ocr_mode?: string; processing_mode?: string; bundle_type?: string }[] }> = {};
const singles: BundleGroup[] = [];
for (const b of bundles) {
const ex = (b.extra as Record<string, unknown>) || {};
const gid = (ex.group_id as string | undefined) || '';
if (gid) {
if (!byGroup[gid]) byGroup[gid] = { ids: [], meta: [] };
byGroup[gid].ids.push(b.id);
const pipeline = (ex.pipeline as string | undefined) || (b.type === 'docling_vlm' ? 'vlm' : (b.type === 'vlm_section_page_bundle' ? 'vlm-pages' : 'standard'));
const producer = (ex.producer as string | undefined) || 'manual';
const do_ocr = ((ex.config as Record<string, unknown>)?.do_ocr as boolean) ?? true;
const ocrLabel = do_ocr ? 'OCR' : 'no-OCR';
byGroup[gid].meta.push({
created_at: b.created_at,
pipeline,
group_pack_type: ex.group_pack_type as string | undefined,
producer,
ocr_mode: ocrLabel,
processing_mode: ex.processing_mode as string | undefined,
bundle_type: ex.bundle_type as string | undefined
});
} else {
const pipeline = (ex.pipeline as string | undefined) || (b.type === 'docling_vlm' ? 'vlm' : (b.type === 'vlm_section_page_bundle' ? 'vlm-pages' : 'standard'));
const producer = (ex.producer as string | undefined) || 'manual';
const producerLabel = producer === 'auto_split' ? 'auto' : 'manual';
singles.push({
key: `single:${b.id}`,
label: `${new Date(b.created_at || '').toLocaleString()}${pipeline}${producerLabel}`,
bundleIds: [b.id],
isGroup: false
});
}
}
const groups: BundleGroup[] = Object.entries(byGroup)
.map(([gid, v]) => {
const newest = v.meta.sort((a,b)=> new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime())[0];
const producerLabel = newest.producer === 'auto_split' ? 'auto' : 'manual';
// Determine pack type - use processing_mode as fallback for better detection
let packType = newest.group_pack_type;
if (!packType) {
// Smart fallback based on bundle characteristics
if (newest.processing_mode === 'whole_document' || newest.bundle_type === 'docling_bundle') {
packType = 'whole';
} else if (v.ids.length === 1) {
packType = 'single';
} else {
packType = 'split';
}
}
const ocrInfo = v.meta.length > 0 ? `${newest.ocr_mode || 'mixed'}` : '';
const label = `${new Date(newest.created_at || '').toLocaleString()}${packType}${newest.pipeline || 'standard'}${ocrInfo}${v.ids.length} parts • ${producerLabel}`;
return { key: `group:${gid}`, label, bundleIds: v.ids, isGroup: v.ids.length > 1 };
})
.sort((a,b)=> new Date(byGroup[b.key.split(':')[1]]?.meta[0]?.created_at || 0).getTime() - new Date(byGroup[a.key.split(':')[1]]?.meta[0]?.created_at || 0).getTime());
return [...groups, ...singles];
}, [bundles]);
const [selectedGroupKey, setSelectedGroupKey] = useState<string>('');
useEffect(() => {
const loadBundles = async () => {
if (!validFileId) return;
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) return;
const arts: Artefact[] = await res.json();
const list = arts.filter(a => a.type === 'docling_standard' || a.type === 'docling_vlm' || a.type === 'vlm_section_page_bundle' || a.type === 'docling_bundle' || a.type === 'docling_bundle_split' || a.type === 'docling_bundle_split_pages' || a.type === 'canonical_docling_json')
.sort((a, b) => {
// Sort by creation time, newest first
const ta = new Date(a.created_at || 0).getTime();
const tb = new Date(b.created_at || 0).getTime();
return tb - ta;
});
setBundles(list);
// Initialize currentBundle if not set
if (list.length && !currentBundle) {
setCurrentBundle(list[0].id);
}
// Initialize selected group key to latest group or single
const gi = (() => {
const arr = list;
const withGroup = arr.filter(a => ((a.extra as Record<string, unknown>)||{}).group_id);
if (withGroup.length) {
const gid = ((withGroup[0].extra as Record<string, unknown>).group_id as string);
return `group:${gid}`;
}
return `single:${arr[0]?.id || ''}`;
})();
if (!selectedGroupKey && gi) {
setSelectedGroupKey(gi);
}
};
loadBundles();
}, [validFileId]); // Remove circular dependencies to prevent timing issues
// eslint-disable-next-line react-hooks/exhaustive-deps
// Separate effect to handle initialization after bundles are loaded
useEffect(() => {
if (bundles.length > 0 && !currentBundle) {
setCurrentBundle(bundles[0].id);
}
}, [bundles, currentBundle]);
// Separate effect to sync selectedGroupKey with currentBundle
useEffect(() => {
if (bundles.length > 0 && currentBundle && !selectedGroupKey) {
const bundle = bundles.find(b => b.id === currentBundle);
if (bundle) {
const extra = bundle.extra as Record<string, unknown> || {};
const groupId = extra.group_id as string;
if (groupId) {
setSelectedGroupKey(`group:${groupId}`);
} else {
setSelectedGroupKey(`single:${currentBundle}`);
}
}
}
}, [bundles, currentBundle, selectedGroupKey]);
const [splitThreshold] = useState<number>(50);
const autoSplit = useMemo(() => {
const pages = splitSections.reduce((m, s) => Math.max(m, s.end), 0);
return pages >= splitThreshold && splitSections.length > 0;
}, [splitSections, splitThreshold]);
const [doFormula, setDoFormula] = useState(false);
const [doCode, setDoCode] = useState(false);
const [tableCellMatching, setTableCellMatching] = useState<boolean>(false);
// Outputs are fixed to all formats for canonical bundles
useEffect(() => {
const run = async () => {
if (!validFileId) return;
setOutlineOptions([]);
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
try {
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (!artsRes.ok) return;
const arts: Array<{ id: string; type: string; rel_path?: string }> = await artsRes.json();
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
if (!outlineArt) return;
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (!jsonRes.ok) return;
const doc = await jsonRes.json();
const sections = (doc.sections || []) as Array<{ id: string; title: string; start_page: number; end_page: number }>;
setOutlineOptions(sections.map(s => ({ id: s.id, title: s.title, start_page: s.start_page, end_page: s.end_page })));
// Load split map
const splitArt = arts.find(a => a.type === 'split_map_json');
if (splitArt) {
const smRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(splitArt.id)}/json`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (smRes.ok) {
const sm = await smRes.json();
const entries = Array.isArray(sm.entries) ? sm.entries : [];
const secs = (entries as Array<Record<string, unknown>>)
.map((e) => ({
id: String((e.id as string) || `${e.start_page as number}-${e.end_page as number}`),
title: String((e.title as string) || ''),
start: Number((e.start_page as number) || 1),
end: Number((e.end_page as number) || 1)
}))
.filter((e) => Number.isFinite(e.start) && Number.isFinite(e.end));
setSplitSections(secs);
}
}
} catch {
// ignore
}
};
run();
}, [validFileId]);
return (
<Box sx={{ width: '100%', height: '100%', display: 'flex', overflow: 'hidden' }}>
<Box sx={{ width: 320, height: '100%', borderRight: '1px solid var(--color-divider)', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ flex: 1, minHeight: 0 }}>
<CCEnhancedFilePanel
fileId={validFileId}
selectedPage={page}
onSelectPage={setPage}
currentSection={(function(){
const s = [...outlineOptions].sort((a,b)=>a.start_page-b.start_page).find(x => page >= x.start_page && page <= x.end_page);
return s ? { start: s.start_page, end: s.end_page } : undefined;
})()}
/>
</Box>
</Box>
<Box sx={{ flex: 1, height: '100%', position: 'relative', display: 'flex', flexDirection: 'row' }}>
<Box sx={{ flex: 1, minWidth: 0, borderRight: '1px solid var(--color-divider)', display: 'flex', flexDirection: 'column' }}>
<CCDoclingViewer
fileId={validFileId}
currentPage={page}
onPageChange={setPage}
hideToolbar
sectionRange={(function(){
const s = [...outlineOptions].sort((a,b)=>a.start_page-b.start_page).find(x => page >= x.start_page && page <= x.end_page);
return s ? { start: s.start_page, end: s.end_page } : undefined;
})()}
/>
</Box>
<Box sx={{ width: '42%', minWidth: 320, display: 'flex', flexDirection: 'column' }}>
<CCBundleViewer
fileId={validFileId}
bundleId={!combineSplit ? currentBundle : undefined}
currentPage={page}
combinedBundles={combineSplit ? (function(){
const grp = groupItems.find(g => g.key === selectedGroupKey);
if (!grp) return [];
// Order split parts by split_order if present
const inGroup = bundles.filter(b => grp.bundleIds.includes(b.id));
const ordered = inGroup.sort((a,b) => {
const ao = Number(((a.extra as Record<string, unknown>)||{}).split_order) || 0;
const bo = Number(((b.extra as Record<string, unknown>)||{}).split_order) || 0;
return ao - bo;
});
return ordered.map(b => ({ id: b.id }));
})() : undefined}
/>
</Box>
</Box>
<Box sx={{ width: 360, height: '100%', borderLeft: '1px solid var(--color-divider)', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2, fontWeight: 600 }}>AI Document Intelligence</Box>
<Divider />
<Box sx={{ p: 2, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)', fontWeight: 600 }}>Canonical Docling</Typography>
{bundles.length > 0 && (
<>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>Existing bundles</Typography>
{/* Batch selector (groups and singles) */}
<Select size="small" value={selectedGroupKey} onChange={(e: SelectChangeEvent<string>) => {
const key = e.target.value as string;
setSelectedGroupKey(key);
const grp = groupItems.find(g => g.key === key);
if (grp && grp.bundleIds.length) setCurrentBundle(grp.bundleIds[0]);
setCombineSplit(Boolean(grp && grp.isGroup));
}}>
{groupItems.map(g => (
<MenuItem key={g.key} value={g.key}>{g.label}</MenuItem>
))}
</Select>
{/* Only show combine toggle if multi-bundle group selected */}
{(() => {
const grp = groupItems.find(g => g.key === selectedGroupKey);
return grp && grp.isGroup ? (
<FormControlLabel control={<Switch checked={combineSplit} onChange={(e) => setCombineSplit(e.target.checked)} />} label="Combine split bundles" />
) : null;
})()}
{/* When not combining, allow selecting a single bundle within selected group */}
{!combineSplit && (() => {
const grp = groupItems.find(g => g.key === selectedGroupKey);
return grp && grp.isGroup; // Only show for groups with multiple bundles
})() && (
<Select size="small" value={currentBundle} onChange={(e) => setCurrentBundle(e.target.value as string)}>
{bundles.filter(b => {
const grp = groupItems.find(g => g.key === selectedGroupKey);
return grp ? grp.bundleIds.includes(b.id) : true;
}).sort((a,b) => {
const ao = Number(((a.extra as Record<string, unknown>)||{}).split_order) || 0;
const bo = Number(((b.extra as Record<string, unknown>)||{}).split_order) || 0;
return ao - bo;
}).map(b => {
const ex = (b.extra as Record<string, unknown>) || {};
const splitOrder = Number(ex.split_order ?? NaN);
const heading = ex.split_heading as string | undefined;
const pipeline = (ex.pipeline as string) || (b.type === 'docling_vlm' ? 'vlm' : 'standard');
const base = heading ? `${heading}${Number.isFinite(splitOrder) ? ` (#${splitOrder})` : ''}` : (new Date(b.created_at || '').toLocaleString() || b.id);
return (<MenuItem key={b.id} value={b.id}>{`${base} [${pipeline}]`}</MenuItem>);
})}
</Select>
)}
</>
)}
<Select size="small" value={profile} onChange={(e: SelectChangeEvent<Profile>) => setProfile(e.target.value as Profile)}>
<MenuItem value="default">Default</MenuItem>
<MenuItem value="simple">Simple</MenuItem>
<MenuItem value="aggressive">Aggressive</MenuItem>
</Select>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>Pipeline</Typography>
<Select size="small" value={pipeline} onChange={(e: SelectChangeEvent<Pipeline>) => setPipeline(e.target.value as Pipeline)}>
<MenuItem value="standard">Standard</MenuItem>
<MenuItem value="vlm">VLM</MenuItem>
<MenuItem value="asr">ASR</MenuItem>
</Select>
{pipeline === 'vlm' && (
<>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>VLM configuration</Typography>
<Select size="small" value={vlmMode} onChange={(e: SelectChangeEvent<VlmMode>) => setVlmMode(e.target.value as VlmMode)}>
<MenuItem value="preset">Preset</MenuItem>
<MenuItem value="local">Local (JSON)</MenuItem>
<MenuItem value="api">API (JSON)</MenuItem>
</Select>
{vlmMode === 'preset' && (
<Select size="small" value={vlmPreset} onChange={(e) => setVlmPreset(e.target.value as string)}>
<MenuItem value="smoldocling">smoldocling</MenuItem>
<MenuItem value="smoldocling_vllm">smoldocling_vllm</MenuItem>
<MenuItem value="granite_vision">granite_vision</MenuItem>
<MenuItem value="granite_vision_vllm">granite_vision_vllm</MenuItem>
<MenuItem value="granite_vision_ollama">granite_vision_ollama</MenuItem>
<MenuItem value="got_ocr_2">got_ocr_2</MenuItem>
</Select>
)}
{vlmMode === 'local' && (
<TextField size="small" label="VLM Local JSON" placeholder='{"repo_id":"..."}' value={vlmLocalJson} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setVlmLocalJson(e.target.value)} multiline minRows={2} />
)}
{vlmMode === 'api' && (
<>
<Select size="small" value={vlmProvider} onChange={(e: SelectChangeEvent<VlmProvider>) => setVlmProvider(e.target.value as VlmProvider)}>
<MenuItem value="">Custom JSON</MenuItem>
<MenuItem value="ollama">Ollama</MenuItem>
<MenuItem value="openai">OpenAI</MenuItem>
</Select>
{vlmProvider === 'ollama' && (
<>
<TextField size="small" label="Ollama Base URL" placeholder="http://localhost:11434" value={vlmProviderBaseUrl} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setVlmProviderBaseUrl(e.target.value)} />
<Select size="small" value={vlmProviderModel} onOpen={async () => {
try {
const base = vlmProviderBaseUrl || (import.meta.env.VITE_OLLAMA_BASE_URL || 'http://localhost:11434');
const resp = await fetch(`${base.replace(/\/$/, '')}/api/tags`);
if (resp.ok) {
const data = await resp.json();
const models = Array.isArray(data.models) ? (data.models as Array<{ model?: string; name?: string }>).map((m) => m.model || m.name || '').filter(Boolean) : [];
setOllamaModels(models);
}
} catch (_e) { /* no-op */ }
}} onChange={(e) => setVlmProviderModel(e.target.value as string)}>
{ollamaModels.map(m => (<MenuItem key={m} value={m}>{m}</MenuItem>))}
</Select>
</>
)}
{vlmProvider === 'openai' && (
<>
<TextField size="small" label="OpenAI Base URL (optional)" placeholder="https://api.openai.com/v1" value={vlmProviderBaseUrl} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setVlmProviderBaseUrl(e.target.value)} />
<Select size="small" value={vlmProviderModel} onChange={(e) => setVlmProviderModel(e.target.value as string)}>
<MenuItem value="gpt-4o-mini">gpt-4o-mini</MenuItem>
<MenuItem value="gpt-4o">gpt-4o</MenuItem>
<MenuItem value="gpt-4.1-mini">gpt-4.1-mini</MenuItem>
</Select>
</>
)}
{vlmProvider === '' && (
<TextField size="small" label="VLM API JSON" placeholder='{"provider":"ollama","base_url":"http://...","model":"..."}' value={vlmApiJson} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setVlmApiJson(e.target.value)} multiline minRows={2} />
)}
</>
)}
</>
)}
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>PDF Backend</Typography>
<Select size="small" value={pdfBackend} onChange={(e: SelectChangeEvent<PdfBackend>) => setPdfBackend(e.target.value as PdfBackend)}>
<MenuItem value="dlparse_v4">dlparse_v4 (default)</MenuItem>
<MenuItem value="pypdfium2">pypdfium2</MenuItem>
<MenuItem value="dlparse_v1">dlparse_v1</MenuItem>
<MenuItem value="dlparse_v2">dlparse_v2</MenuItem>
</Select>
<FormControlLabel control={<Switch checked={doOCR} onChange={(e) => setDoOCR(e.target.checked)} />} label="OCR" />
<FormControlLabel control={<Switch checked={forceOCR} onChange={(e) => setForceOCR(e.target.checked)} />} label="Force OCR" />
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>Table Mode</Typography>
<Select size="small" value={tableMode} onChange={(e: SelectChangeEvent<TableMode>) => setTableMode(e.target.value as TableMode)}>
<MenuItem value="fast">Fast</MenuItem>
<MenuItem value="accurate">Accurate</MenuItem>
</Select>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>Section</Typography>
<Select size="small" value={selectedSectionId} onChange={(e: SelectChangeEvent<string>) => setSelectedSectionId(e.target.value as string)}>
<MenuItem value="full">{autoSplit ? 'Full document (auto split)' : 'Full document'}</MenuItem>
{splitSections.map(sec => (
<MenuItem key={sec.id} value={sec.id}>{sec.title ? `${sec.title} (${sec.start}-${sec.end})` : `Pages ${sec.start}-${sec.end}`}</MenuItem>
))}
</Select>
<FormControlLabel control={<Switch checked={doPicClass} onChange={(e) => setDoPicClass(e.target.checked)} />} label="Picture classification" />
<FormControlLabel control={<Switch checked={doPicDesc} onChange={(e) => setDoPicDesc(e.target.checked)} />} label="Picture description" />
{doPicDesc && (
<>
<TextField size="small" label="Description prompt" value={picDescPrompt} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPicDescPrompt(e.target.value)} />
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>Picture description configuration</Typography>
<Select size="small" value={picDescMode} onChange={(e: SelectChangeEvent<PicDescMode>) => setPicDescMode(e.target.value as PicDescMode)}>
<MenuItem value="local">Local (JSON)</MenuItem>
<MenuItem value="api">API (JSON)</MenuItem>
</Select>
{picDescMode === 'local' && (
<TextField size="small" label="Picture Description Local JSON" placeholder='{"repo_id":"..."}' value={picDescLocalJson} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPicDescLocalJson(e.target.value)} multiline minRows={2} />
)}
{picDescMode === 'api' && (
<TextField size="small" label="Picture Description API JSON" placeholder='{"base_url":"..."}' value={picDescApiJson} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPicDescApiJson(e.target.value)} multiline minRows={2} />
)}
</>
)}
<FormControlLabel control={<Switch checked={doFormula} onChange={(e) => setDoFormula(e.target.checked)} />} label="Formula enrichment" />
<FormControlLabel control={<Switch checked={doCode} onChange={(e) => setDoCode(e.target.checked)} />} label="Code enrichment" />
<FormControlLabel control={<Switch checked={tableCellMatching} onChange={(e) => setTableCellMatching(e.target.checked)} />} label="Table cell matching" />
{/* Outputs are always all formats for canonical bundles; UI omitted */}
<Button variant="contained" disabled={busy || !validFileId} onClick={async () => {
try {
setBusy(true);
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const body: CanonicalDoclingRequest = {
use_split_map: selectedSectionId === 'full' ? autoSplit : false,
config: {
pipeline,
pdf_backend: pdfBackend,
do_ocr: doOCR,
force_ocr: forceOCR,
table_mode: tableMode,
do_picture_classification: doPicClass,
do_picture_description: doPicDesc,
picture_description_prompt: doPicDesc ? picDescPrompt : undefined,
target_type: 'zip',
image_export_mode: 'referenced',
table_cell_matching: tableCellMatching
},
threshold: splitThreshold
};
body.config.to_formats = ['json','html','text','md','doctags'];
body.config.do_formula_enrichment = doFormula;
body.config.do_code_enrichment = doCode;
// Apply selected section as custom range
const sel = selectedSectionId !== 'full' ? splitSections.find(s => s.id === selectedSectionId) : undefined;
if (sel) {
(body as unknown as { custom_range: [number, number]; custom_label: string; selected_section_id: string; selected_section_title: string }).custom_range = [sel.start, sel.end];
(body as unknown as { custom_range: [number, number]; custom_label: string; selected_section_id: string; selected_section_title: string }).custom_label = sel.title || `Pages ${sel.start}-${sel.end}`;
(body as unknown as { custom_range: [number, number]; custom_label: string; selected_section_id: string; selected_section_title: string }).selected_section_id = sel.id;
(body as unknown as { custom_range: [number, number]; custom_label: string; selected_section_id: string; selected_section_title: string }).selected_section_title = sel.title || '';
}
// If full and autoSplit, ensure threshold present
if (selectedSectionId === 'full' && autoSplit) {
(body as unknown as { threshold: number }).threshold = splitThreshold;
}
// Picture description mutually exclusive config
if (doPicDesc) {
if (picDescMode === 'local' && picDescLocalJson.trim()) {
body.config.picture_description_local = picDescLocalJson.trim();
body.config.picture_description_api = undefined;
} else if (picDescMode === 'api' && picDescApiJson.trim()) {
body.config.picture_description_api = picDescApiJson.trim();
body.config.picture_description_local = undefined;
} else {
body.config.picture_description_local = undefined;
body.config.picture_description_api = undefined;
}
} else {
body.config.picture_description_local = undefined;
body.config.picture_description_api = undefined;
}
// VLM mutually exclusive config + provider presets
if (pipeline === 'vlm') {
if (vlmMode === 'preset') {
body.config.vlm_pipeline_model = vlmPreset;
body.config.vlm_pipeline_model_local = undefined;
body.config.vlm_pipeline_model_api = undefined;
} else if (vlmMode === 'local' && vlmLocalJson.trim()) {
body.config.vlm_pipeline_model_local = vlmLocalJson.trim();
body.config.vlm_pipeline_model = undefined;
body.config.vlm_pipeline_model_api = undefined;
} else if (vlmMode === 'api') {
if (vlmProvider) {
(body.config as unknown as { vlm_provider: string; vlm_provider_model: string; vlm_provider_base_url: string }).vlm_provider = vlmProvider;
(body.config as unknown as { vlm_provider: string; vlm_provider_model: string; vlm_provider_base_url: string }).vlm_provider_model = vlmProviderModel.trim();
(body.config as unknown as { vlm_provider: string; vlm_provider_model: string; vlm_provider_base_url: string }).vlm_provider_base_url = vlmProviderBaseUrl.trim();
body.config.vlm_pipeline_model_api = undefined;
body.config.vlm_pipeline_model = undefined;
body.config.vlm_pipeline_model_local = undefined;
} else if (vlmApiJson.trim()) {
body.config.vlm_pipeline_model_api = vlmApiJson.trim();
body.config.vlm_pipeline_model = undefined;
body.config.vlm_pipeline_model_local = undefined;
} else {
body.config.vlm_pipeline_model = undefined;
body.config.vlm_pipeline_model_local = undefined;
body.config.vlm_pipeline_model_api = undefined;
}
}
} else {
body.config.vlm_pipeline_model = undefined;
body.config.vlm_pipeline_model_local = undefined;
body.config.vlm_pipeline_model_api = undefined;
}
const resp = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/canonical-docling`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await resp.json();
console.log('canonical-docling:', data);
// Refresh bundles list
try {
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } });
if (res.ok) {
const arts: Artefact[] = await res.json();
const list = arts.filter(a => a.type === 'docling_standard' || a.type === 'docling_vlm' || a.type === 'vlm_section_page_bundle' || a.type === 'docling_bundle' || a.type === 'docling_bundle_split' || a.type === 'docling_bundle_split_pages' || a.type === 'canonical_docling_json')
.sort((a, b) => {
// Sort by creation time, newest first
const ta = new Date(a.created_at || 0).getTime();
const tb = new Date(b.created_at || 0).getTime();
return tb - ta;
});
setBundles(list);
if (list.length && !currentBundle) setCurrentBundle(list[0].id);
}
} catch (_err: unknown) { void 0; }
} finally {
setBusy(false);
}
}}>Generate Doclings</Button>
</Box>
</Box>
</Box>
);
};
export default CCDocumentIntelligence;
@@ -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;
@@ -0,0 +1,363 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, CircularProgress, IconButton, MenuItem, Select, TextField, Typography } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import { supabase } from '../../../supabaseClient';
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 Outline = {
sections: OutlineSection[];
};
type QueueTaskBrief = {
id: string;
service?: string;
task_type?: string;
status?: string;
priority?: string;
created_at?: number;
scheduled_at?: number;
depends_on?: string[];
};
type FileTasksResponse = { file_id: string; count: number; tasks: QueueTaskBrief[] } | { error: string };
export const CCFileDetailPanel: React.FC<{
fileId: string;
selectedPage: number;
onSelectPage: (p: number) => void;
}> = ({ fileId, selectedPage, onSelectPage }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
const [outline, setOutline] = useState<Outline | null>(null);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
// outline only used for grouping thumbnails
const [thumbUrls] = useState<Map<number, string>>(() => new Map());
const [showAdmin, setShowAdmin] = useState(false);
const [adminData, setAdminData] = useState<FileTasksResponse | null>(null);
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
useEffect(() => {
const run = async () => {
if (!fileId) return;
setLoading(true);
setError(null);
try {
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (!mRes.ok) throw new Error(await mRes.text());
const m: PageImagesManifest = await mRes.json();
setManifest(m);
// Try to load outline structure artefact (for grouping only)
try {
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (artsRes.ok) {
const arts: Array<{ id: string; type: string }> = await artsRes.json();
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
if (outlineArt) {
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
if (jsonRes.ok) {
const outJson = await jsonRes.json();
const secs = (outJson.sections || []) as OutlineSection[];
setOutline({ sections: secs });
}
}
}
} catch {
// ignore
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load manifest');
} finally {
setLoading(false);
}
};
run();
}, [fileId, API_BASE]);
const fetchThumb = useCallback(async (page: number): Promise<string | undefined> => {
if (!manifest) return undefined;
const cached = thumbUrls.get(page);
if (cached) return cached;
const idx = Math.max(0, Math.min((manifest.page_count || 1) - 1, page - 1));
const pg = manifest.page_images[idx];
if (!pg) return undefined;
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`;
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok) return undefined;
const blob = await resp.blob();
const objUrl = URL.createObjectURL(blob);
thumbUrls.set(page, objUrl);
return objUrl;
}, [manifest, API_BASE, thumbUrls]);
useEffect(() => {
if (!manifest) return;
// Prefetch first few thumbs
const prefetch = async () => {
const limit = Math.min(10, manifest.page_count || 0);
for (let p = 1; p <= limit; p++) {
// eslint-disable-next-line no-await-in-loop
await fetchThumb(p);
}
};
prefetch();
}, [manifest, fetchThumb]);
if (loading) return <Box sx={{ p: 2 }}><CircularProgress size={18} /></Box>;
if (error) return <Box sx={{ p: 2, color: 'var(--color-text-2)' }}>{error}</Box>;
if (!manifest) return <Box sx={{ p: 2, color: 'var(--color-text-2)' }}>No page images manifest.</Box>;
return (
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ p: 1, borderBottom: '1px solid var(--color-divider)', fontWeight: 600, flexShrink: 0, bgcolor: 'var(--color-panel)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>File Details
<IconButton size="small" onClick={async () => {
try {
setShowAdmin(true);
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const res = await fetch(`${API_BASE}/queue/queue/tasks/by-file/${encodeURIComponent(fileId)}`, { headers: { Authorization: `Bearer ${token}` } });
const data = await res.json();
setAdminData(data);
} catch (e) {
setAdminData({ error: (e as Error)?.message || 'Failed to load' });
}
}} title="Queue debug (admin)"><AdminPanelSettingsIcon fontSize="inherit" /></IconButton>
</Box>
<Box sx={{ p: 1, borderBottom: '1px solid var(--color-divider)', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: 1, flexShrink: 0, bgcolor: 'var(--color-panel)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton size="small" onClick={() => onSelectPage(Math.max(1, selectedPage - 1))}><ArrowBackIosNewIcon fontSize="inherit" /></IconButton>
<TextField
size="small"
value={selectedPage}
onChange={(e) => onSelectPage(Number(e.target.value) || 1)}
sx={{ flexShrink: 0 }}
InputProps={{ sx: { width: 64, '& input': { textAlign: 'center', padding: '6px' } } }}
inputProps={{ inputMode: 'numeric', pattern: '[0-9]*', maxLength: 4 }}
/>
<IconButton size="small" onClick={() => onSelectPage(Math.min(manifest.page_count, selectedPage + 1))}><ArrowForwardIosIcon fontSize="inherit" /></IconButton>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>/ {manifest.page_count}</Typography>
</Box>
</Box>
<Box sx={{ p: 1, borderBottom: '1px solid var(--color-divider)', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0, bgcolor: 'var(--color-panel)' }}>
<Select size="small" value={getCurrentSectionStart(outline, selectedPage)} onChange={(e) => onSelectPage(Number(e.target.value))} displayEmpty sx={{ width: '100%', flexShrink: 0, '& .MuiSelect-select': { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }}>
{!outline || outline.sections.length === 0 ? (
<MenuItem value={selectedPage} disabled>No outline</MenuItem>
) : (
outline.sections.sort((a, b) => a.start_page - b.start_page).map((sec) => (
<MenuItem key={sec.id} value={sec.start_page}>{sec.title.length > 60 ? `${sec.title.slice(0,60)}` : sec.title} (p{sec.start_page})</MenuItem>
))
)}
</Select>
{outline && outline.sections.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton size="small" title="Expand all" onClick={() => setCollapsed(new Set())}><ExpandMoreIcon fontSize="inherit" /></IconButton>
<IconButton size="small" title="Collapse all" onClick={() => setCollapsed(new Set(collectAllIds(outline.sections)))}><ChevronRightIcon fontSize="inherit" /></IconButton>
</Box>
)}
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', display: 'block', p: 1 }}>
{showAdmin && (
<Box sx={{ mb: 1, p: 1, border: '1px dashed var(--color-divider)', borderRadius: 1, bgcolor: 'var(--color-panel)' }}>
<Typography variant="caption" sx={{ color: 'var(--color-text-3)' }}>Queue tasks for this file</Typography>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 11, margin: 0 }}>{JSON.stringify(adminData, null, 2)}</pre>
</Box>
)}
{outline && outline.sections.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" sx={{ color: 'var(--color-text-2)', fontWeight: 600 }}>Sections</Typography>
<Box>
<IconButton size="small" title="Expand all" onClick={() => setCollapsed(new Set())}><ExpandMoreIcon fontSize="inherit" /></IconButton>
<IconButton size="small" title="Collapse all" onClick={() => setCollapsed(new Set(collectAllIds(outline.sections)))}><ChevronRightIcon fontSize="inherit" /></IconButton>
</Box>
</Box>
)}
{renderGroupedTiles(manifest, outline, fetchThumb, selectedPage, onSelectPage, collapsed, (id) => setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
}))}
</Box>
</Box>
);
};
type SectionNode = { sec: OutlineSection; children: SectionNode[] };
const buildSectionTree = (sections: OutlineSection[]): SectionNode[] => {
const roots: SectionNode[] = [];
const stack: SectionNode[] = [];
const sorted = [...sections].sort((a, b) => a.start_page - b.start_page);
for (const s of sorted) {
const level = Math.max(1, Number(s.level || 1));
const node: SectionNode = { sec: s, children: [] };
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;
};
const renderGroupedTiles = (
manifest: PageImagesManifest,
outline: Outline | null,
fetchThumb: (p: number) => Promise<string | undefined>,
selectedPage: number,
onSelectPage: (p: number) => void,
collapsed: Set<string>,
toggleCollapse: (id: string) => void
) => {
if (!outline || outline.sections.length === 0) {
// No outline: show a simple grid of page tiles
return (
<Box sx={{ width: '100%', display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 1 }}>
{manifest.page_images.map((pg) => (
<PageTile key={pg.page} page={pg.page} fetchSrc={() => fetchThumb(pg.page)} selected={pg.page === selectedPage} onClick={() => onSelectPage(pg.page)} />
))}
</Box>
);
}
const tree = buildSectionTree(outline.sections);
return tree.map((node) => (
<SectionTile
key={node.sec.id}
node={node}
manifest={manifest}
fetchThumb={fetchThumb}
selectedPage={selectedPage}
onSelectPage={onSelectPage}
collapsed={collapsed}
toggleCollapse={toggleCollapse}
level={Math.max(1, Number(node.sec.level || 1))}
/>
));
};
// OutlineTree UI has been moved to top navigation; grouping-by-section thumbnails remain below.
const SectionTile: React.FC<{
node: SectionNode;
manifest: PageImagesManifest;
fetchThumb: (p: number) => Promise<string | undefined>;
selectedPage: number;
onSelectPage: (p: number) => void;
collapsed: Set<string>;
toggleCollapse: (id: string) => void;
level: number;
}> = ({ node, manifest, fetchThumb, selectedPage, onSelectPage, collapsed, toggleCollapse, level }) => {
const s = node.sec;
const ml = (level - 1) * 1;
const isCollapsed = collapsed.has(s.id);
return (
<Box sx={{ width: '100%', mt: 1, ml, border: '1px solid var(--color-divider)', borderRadius: 1, overflow: 'hidden', bgcolor: 'var(--color-panel)' }}>
<Box sx={{ px: 1, py: 0.75, fontWeight: 700, color: 'var(--color-text-1)', display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'pointer', background:
level === 1 ? 'rgba(0,0,0,0.03)' : level === 2 ? 'rgba(0,0,0,0.02)' : 'transparent',
borderBottom: '1px solid var(--color-divider)'
}}>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); toggleCollapse(s.id); }}>
{isCollapsed ? <ChevronRightIcon fontSize="inherit" /> : <ExpandMoreIcon fontSize="inherit" />}
</IconButton>
<Box onClick={() => onSelectPage(Math.max(1, s.start_page))}>
<Typography component="span" sx={{ color: 'var(--color-text-1)' }}>{s.title}</Typography>
</Box>
<Typography component="span" sx={{ fontSize: 12, color: 'var(--color-text-3)', ml: 1 }}>({s.start_page}{s.end_page})</Typography>
</Box>
{!isCollapsed && (
<Box sx={{ p: 1 }}>
<Box sx={{ width: '100%', display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 1 }}>
{Array.from({ length: Math.max(0, Math.min(s.end_page, manifest.page_count) - s.start_page + 1) }).map((_, i) => {
const p = s.start_page + i;
return (
<PageTile key={`p-${p}`} page={p} fetchSrc={() => fetchThumb(p)} selected={p === selectedPage} onClick={() => onSelectPage(p)} />
);
})}
</Box>
</Box>
)}
{!isCollapsed && node.children.length > 0 && (
<Box sx={{ p: 1 }}>
{node.children.map((child) => (
<SectionTile key={child.sec.id} node={child} manifest={manifest} fetchThumb={fetchThumb} selectedPage={selectedPage} onSelectPage={onSelectPage} collapsed={collapsed} toggleCollapse={toggleCollapse} level={Math.max(1, Number(child.sec.level || level + 1))} />
))}
</Box>
)}
</Box>
);
};
function getCurrentSectionStart(outline: Outline | null, selectedPage: number): number {
if (!outline || outline.sections.length === 0) return selectedPage;
const secs = [...outline.sections].sort((a, b) => a.start_page - b.start_page);
for (let i = 0; i < secs.length; i++) {
const s = secs[i];
const end = s.end_page ?? (i + 1 < secs.length ? secs[i + 1].start_page - 1 : Number.MAX_SAFE_INTEGER);
if (selectedPage >= s.start_page && selectedPage <= end) return s.start_page;
}
return selectedPage;
}
function collectAllIds(sections: OutlineSection[]): string[] {
const ids: string[] = [];
for (const s of sections) ids.push(s.id);
return ids;
}
const PageTile: React.FC<{
page: number;
selected: boolean;
fetchSrc: () => Promise<string | undefined>;
onClick: () => void;
}> = ({ page, selected, fetchSrc, onClick }) => {
const [src, setSrc] = useState<string | undefined>(undefined);
useEffect(() => { (async () => setSrc(await fetchSrc()))(); }, [fetchSrc, page]);
return (
<Box onClick={onClick} sx={{ width: '100%', minWidth: 0, display: 'flex', flexDirection: 'column', borderRadius: 1, overflow: 'hidden', cursor: 'pointer', border: selected ? '2px solid #1976d2' : '1px solid var(--color-divider)', boxShadow: selected ? '0 0 0 2px rgba(25,118,210,0.15) inset' : 'none', bgcolor: 'rgba(0,0,0,0.02)' }}>
<Box sx={{ position: 'relative', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'rgba(0,0,0,0.05)' }}>
{src ? <img src={src} alt={`p${page}`} style={{ maxHeight: '100%', maxWidth: '100%', display: 'block' }} /> : <CircularProgress size={16} />}
<Box sx={{ position: 'absolute', top: 6, right: 6, bgcolor: 'rgba(0,0,0,0.6)', color: '#fff', borderRadius: 1, px: 0.5, fontSize: 11, lineHeight: '16px' }}>{page}</Box>
</Box>
</Box>
);
};
// Replaced old ThumbRow with PageTile-based grid tiles
export default CCFileDetailPanel;
+178 -50
View File
@@ -123,37 +123,73 @@ export default function SinglePlayerPage() {
logger.debug('single-player-page', '✅ TLStore created');
// 2. Initialize snapshot service
const snapshotService = new NavigationSnapshotService(newStore);
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
snapshotServiceRef.current = snapshotService;
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
// 3. Load initial snapshot if we have a node
if (context.node) {
logger.debug('single-player-page', '📥 Loading snapshot from database', {
dbName: user.user_db_name,
tldraw_snapshot: context.node.tldraw_snapshot,
user_type: user.user_type,
username: user.username
});
const nodeStoragePath = getNodeStoragePath(context.node);
if (nodeStoragePath) {
logger.debug('single-player-page', '📥 Loading snapshot from database', {
dbName: user.user_db_name,
node: context.node,
node_storage_path: nodeStoragePath,
user_type: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
context.node.tldraw_snapshot,
user.user_db_name,
newStore,
setLoadingState
);
logger.debug('single-player-page', '✅ Snapshot loaded from database');
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
nodeStoragePath,
user.user_db_name,
newStore,
setLoadingState,
undefined, // sharedStore
editorRef.current || undefined // editor
);
logger.debug('single-player-page', '✅ Snapshot loaded from database');
} else {
logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', {
node: context.node
});
}
} else {
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
}
// 4. Set up auto-save
// 4. Set up auto-save with debouncing (only after initial load is complete)
let autoSaveTimeout: ReturnType<typeof setTimeout> | null = null;
let isAutoSaving = false;
newStore.listen(() => {
if (snapshotServiceRef.current && context.node) {
logger.debug('single-player-page', '💾 Auto-saving changes');
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
logger.error('single-player-page', '❌ Auto-save failed', error);
});
if (snapshotServiceRef.current && context.node && snapshotServiceRef.current.getCurrentNodePath()) {
// Skip if already saving
if (isAutoSaving) {
logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving');
return;
}
// Clear existing timeout
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout);
}
// Debounce auto-save to prevent excessive saves
autoSaveTimeout = setTimeout(async () => {
if (isAutoSaving) return; // Double-check
isAutoSaving = true;
try {
logger.debug('single-player-page', '💾 Auto-saving changes (debounced)');
await snapshotServiceRef.current?.forceSaveCurrentNode();
} catch (error) {
logger.error('single-player-page', '❌ Auto-save failed', error);
} finally {
isAutoSaving = false;
}
}, 2000); // Increased to 2 seconds debounce
} else if (snapshotServiceRef.current && context.node && !snapshotServiceRef.current.getCurrentNodePath()) {
logger.debug('single-player-page', '⚠️ Skipping auto-save - no current node path set yet');
}
});
@@ -185,12 +221,43 @@ export default function SinglePlayerPage() {
};
initializeStoreAndSnapshot();
}, [isEditorReady, user, context.node, editorRef.current]);
}, [isEditorReady, user, context.node]);
// Handle initial node placement
useEffect(() => {
const placeInitialNode = async () => {
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
logger.debug('single-player-page', '⚠️ Skipping placeInitialNode - missing dependencies', {
hasNode: !!context.node,
hasEditor: !!editorRef.current,
hasStore: !!store,
isInitialLoad
});
return;
}
// Debug: Log the actual node structure
logger.debug('single-player-page', '🔍 Node structure for placeInitialNode', {
node: context.node,
nodeKeys: Object.keys(context.node),
hasId: !!context.node.id,
hasStoragePath: !!context.node.node_storage_path,
hasData: !!context.node.data,
dataKeys: context.node.data ? Object.keys(context.node.data) : null
});
// Validate that the node has required properties
const nodeStoragePath = getNodeStoragePath(context.node);
if (!context.node.id || !nodeStoragePath) {
logger.error('single-player-page', '❌ Node missing required properties', {
nodeId: context.node.id,
hasStoragePath: !!nodeStoragePath,
node: context.node
});
setLoadingState({
status: 'error',
error: 'Node is missing required information'
});
return;
}
@@ -231,7 +298,7 @@ export default function SinglePlayerPage() {
setLoadingState({ status: 'loading', error: '' });
logger.debug('single-player-page', '🔄 Loading node data', {
nodeId: currentNode.id,
tldraw_snapshot: currentNode.tldraw_snapshot,
node_storage_path: currentNode.node_storage_path,
isInitialLoad
});
@@ -258,7 +325,7 @@ export default function SinglePlayerPage() {
};
handleNodeChange();
}, [context.node?.id, context.history, store]);
}, [context.node, context.history, store, isInitialLoad]);
// Initialize preferences when user is available
useEffect(() => {
@@ -270,7 +337,7 @@ export default function SinglePlayerPage() {
// Redirect if no user or incorrect role
useEffect(() => {
if (!user || user.user_type !== 'admin') {
if (!user || !['admin', 'email_teacher', 'school_admin', 'teacher'].includes(user.user_type || '')) {
logger.info('single-player-page', '🚪 Redirecting to home - no user or incorrect role', {
hasUser: !!user,
userType: user?.user_type
@@ -467,6 +534,11 @@ export default function SinglePlayerPage() {
editorRef.current = editor;
logger.debug('single-player-page', '✅ Editor ref set');
// Update snapshot service with editor reference
if (snapshotServiceRef.current) {
snapshotServiceRef.current.setEditor(editor);
}
setIsEditorReady(true);
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
editorId: editor.store.id,
@@ -482,33 +554,89 @@ export default function SinglePlayerPage() {
);
}
// Helper function to safely extract node_storage_path from different node structures
const getNodeStoragePath = (node: NavigationNode): string | null => {
// Try direct access first
if (node.node_storage_path) {
return node.node_storage_path;
}
// Try nested under data
if (node.data?.node_storage_path) {
return node.data.node_storage_path;
}
// Try other possible locations
if (node.data?.storage_path && typeof node.data.storage_path === 'string') {
return node.data.storage_path;
}
return null;
};
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
// 1. Always fetch fresh data
const dbName = UserNeoDBService.getNodeDatabaseName(node);
const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
// Validate the node parameter
if (!node) {
throw new Error('Node parameter is required');
}
if (!node.id) {
throw new Error('Node must have an ID');
}
const nodeStoragePath = getNodeStoragePath(node);
if (!nodeStoragePath) {
throw new Error(`Node ${node.id} is missing node_storage_path`);
}
if (!fetchedData?.node_data) {
throw new Error('Failed to fetch node data');
logger.debug('single-player-page', '🔄 Loading node data', {
nodeId: node.id,
nodeType: node.type,
nodeLabel: node.label,
nodeStoragePath: nodeStoragePath
});
try {
// 1. Always fetch fresh data
// Create a temporary node object with the correct structure for the service
const normalizedNode = {
...node,
node_storage_path: nodeStoragePath
};
const dbName = UserNeoDBService.getNodeDatabaseName(normalizedNode);
const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
if (!fetchedData?.node_data) {
throw new Error('Failed to fetch node data');
}
// 2. Process the data into the correct shape
const theme = getThemeFromLabel(node.type);
return {
...fetchedData.node_data,
title: String(fetchedData.node_data.title || node.label || ''),
w: 500,
h: 350,
state: {
parentId: null,
isPageChild: true,
hasChildren: null,
bindings: null
},
headerColor: theme.headerColor,
backgroundColor: theme.backgroundColor,
isLocked: false,
__primarylabel__: node.type,
uuid_string: node.id,
node_storage_path: nodeStoragePath
};
} catch (error) {
logger.error('single-player-page', '❌ Error in loadNodeData', {
nodeId: node.id,
nodeType: node.type,
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
// 2. Process the data into the correct shape
const theme = getThemeFromLabel(node.type);
return {
...fetchedData.node_data,
title: fetchedData.node_data.title || node.label,
w: 500,
h: 350,
state: {
parentId: null,
isPageChild: true,
hasChildren: null,
bindings: null
},
headerColor: theme.headerColor,
backgroundColor: theme.backgroundColor,
isLocked: false,
__primarylabel__: node.type,
unique_id: node.id,
tldraw_snapshot: node.tldraw_snapshot
};
};
+8 -8
View File
@@ -22,7 +22,7 @@ interface Event {
subjectClass: string;
color: string;
periodCode: string;
tldraw_snapshot?: string;
node_storage_path?: string;
};
}
@@ -155,12 +155,12 @@ const CalendarPage: React.FC = () => {
try {
logger.debug('calendar', 'Fetching events', {
unique_id: workerNode.nodeData.unique_id,
uuid_string: workerNode.nodeData.uuid_string,
school_db_name: workerDbName
});
const events = await TimetableNeoDBService.fetchTeacherTimetableEvents(
workerNode.nodeData.unique_id,
workerNode.nodeData.uuid_string,
workerDbName || ''
);
@@ -168,7 +168,7 @@ const CalendarPage: React.FC = () => {
...event,
extendedProps: {
...event.extendedProps,
tldraw_snapshot: workerNode?.nodeData?.tldraw_snapshot
node_storage_path: workerNode?.nodeData?.node_storage_path
}
}));
@@ -196,11 +196,11 @@ const CalendarPage: React.FC = () => {
}, [fetchEvents]);
const handleEventClick = useCallback((clickInfo: EventClickArg) => {
const tldraw_snapshot = clickInfo.event.extendedProps?.tldraw_snapshot;
if (tldraw_snapshot) {
// TODO: Implement tldraw_snapshot retrieval from storage API
const node_storage_path = clickInfo.event.extendedProps?.node_storage_path;
if (node_storage_path) {
// TODO: Implement node_storage_path retrieval from storage API
// For now, we'll just log it
console.log('TLDraw snapshot:', tldraw_snapshot);
console.log('TLDraw snapshot:', node_storage_path);
}
}, []);
+96
View File
@@ -0,0 +1,96 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Button,
Container,
Grid,
Paper,
Stack,
Typography
} from '@mui/material';
import { useAuth } from '../../contexts/AuthContext';
import { useUser } from '../../contexts/UserContext';
const DashboardPage: React.FC = () => {
const navigate = useNavigate();
const { user: authUser } = useAuth();
const { profile, loading } = useUser();
const displayName = profile?.display_name || authUser?.display_name || authUser?.username || 'Member';
const emailAddress = profile?.email || authUser?.email || '';
const userType = profile?.user_type || authUser?.user_type || '';
return (
<Container maxWidth="lg" sx={{ py: 6 }}>
<Stack spacing={6}>
<Box>
<Typography variant="h3" component="h1" gutterBottom>
Welcome back{displayName ? `, ${displayName}` : ''}!
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
This is your starting point inside ClassroomCopilot. We keep things simple here so you
can decide what to explore next.
</Typography>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Paper elevation={2} sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Account overview
</Typography>
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
Signed in as
</Typography>
<Typography variant="body1">
{emailAddress || 'No email on file'}
</Typography>
{userType && (
<Typography variant="body2" color="text.secondary">
Role: {userType}
</Typography>
)}
<Typography variant="body2" color="text.secondary">
{loading ? 'Checking profile details...' : 'Profile ready'}
</Typography>
</Stack>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper elevation={2} sx={{ p: 3, height: '100%' }}>
<Typography variant="h6" gutterBottom>
Quick actions
</Typography>
<Stack spacing={2}>
<Button
variant="contained"
color="primary"
onClick={() => navigate('/single-player')}
>
Open workspace
</Button>
<Button
variant="outlined"
onClick={() => navigate('/calendar')}
>
View calendar
</Button>
<Button
variant="outlined"
onClick={() => navigate('/settings')}
>
Update settings
</Button>
</Stack>
</Paper>
</Grid>
</Grid>
</Stack>
</Container>
);
};
export default DashboardPage;