2025-11-14 14:47:26 +00:00

472 lines
21 KiB
TypeScript

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;