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(null); const [combinedManifests, setCombinedManifests] = useState(null); const [mode, setMode] = useState('markdown_full'); const [content, setContent] = useState(''); const [renderHtml, setRenderHtml] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(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 => { const map: Record = {}; 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(/]*?)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 ``; }); }, []); 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 `${altText}`; // 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 `${altText}`; }); // Minimal paragraph handling if (html && typeof html === 'string') { html = html .split(/\n{2,}/).map(p => `

${p.replace(/\n/g, '
')}

`).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('
')); 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 ( Docling artefact {loading ? : error ? {error} : ( (mode === 'markdown_full' || mode === 'markdown_pages' || mode === 'html_full') && renderHtml ? (