472 lines
21 KiB
TypeScript
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  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;
|
|
|
|
|