graph-rag / frontend-react /src /views /Process.tsx
GitHub Action
Automated sync to Hugging Face
674fb4e
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import type { DocumentInfo } from '../types/api';
import { Upload, FilePlus, Activity, Trash2, Eye, Globe, X, FileText, Link, Hash, BookOpen } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
interface PreviewData {
filename: string;
file_type: string;
word_count: number;
char_count: number;
content: string;
}
const Process: React.FC = () => {
const { token, logout } = useAuth();
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
const [loadingDocs, setLoadingDocs] = useState(true);
const [uploading, setUploading] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [url, setUrl] = useState('');
const [message, setMessage] = useState('');
// Preview modal state
const [previewOpen, setPreviewOpen] = useState(false);
const [previewData, setPreviewData] = useState<PreviewData | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
interface TaskState {
status: string;
progress?: {
current_chunk?: number;
total_chunks?: number;
file?: string;
};
}
const [tasks, setTasks] = useState<Record<string, TaskState>>({});
const fetchDocuments = async () => {
setLoadingDocs(true);
try {
const res = await fetch(`${API_BASE}/documents`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.status === 401) { logout(); return; }
if (res.ok) {
const data = await res.json();
setDocuments(data.documents);
}
} catch (err) {
console.error('Failed to fetch docs', err);
} finally {
setLoadingDocs(false);
}
};
useEffect(() => {
fetchDocuments();
const interval = setInterval(fetchDocuments, 10000);
return () => clearInterval(interval);
}, [token]);
useEffect(() => {
const activeTasks = Object.keys(tasks).filter(
tid => tasks[tid].status !== 'completed' && tasks[tid].status !== 'failure'
);
if (activeTasks.length === 0) return;
const interval = setInterval(() => {
activeTasks.forEach(async (tid) => {
try {
const res = await fetch(`${API_BASE}/documents/status/${tid}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.status === 401) { logout(); return; }
if (res.ok) {
const data = await res.json();
setTasks(prev => ({ ...prev, [tid]: { status: data.status, progress: data.progress } }));
if (data.status === 'completed' || data.status === 'failure') fetchDocuments();
}
} catch (e) {}
});
}, 2000);
return () => clearInterval(interval);
}, [tasks, token]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) setFile(e.target.files[0]);
};
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) return;
setUploading(true);
setMessage('');
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(`${API_BASE}/documents/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData
});
if (res.status === 401) { logout(); return; }
const data = await res.json();
if (!res.ok) {
setMessage(`UPLOAD FAILED: ${data.detail}`);
} else {
setMessage('FILE UPLOADED. INGESTION TASK QUEUED.');
if (data.task_id) setTasks(prev => ({ ...prev, [data.task_id]: { status: 'pending' } }));
setFile(null);
fetchDocuments();
}
} catch (err: any) {
setMessage(`ERROR: ${err.message}`);
} finally {
setUploading(false);
}
};
const handleUrlScrape = async (e: React.FormEvent) => {
e.preventDefault();
if (!url.trim()) return;
setUploading(true);
setMessage('');
try {
const res = await fetch(`${API_BASE}/documents/scrape`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ url: url.trim() })
});
if (res.status === 401) { logout(); return; }
const data = await res.json();
if (!res.ok) {
setMessage(`SCRAPE FAILED: ${data.detail}`);
} else {
setMessage('URL SCRAPED. INGESTION TASK QUEUED.');
if (data.task_id) setTasks(prev => ({ ...prev, [data.task_id]: { status: 'pending' } }));
setUrl('');
fetchDocuments();
}
} catch (err: any) {
setMessage(`ERROR: ${err.message}`);
} finally {
setUploading(false);
}
};
const handleDelete = async (docId: string) => {
if (!window.confirm('WARNING: Delete this document and all its graph data?')) return;
try {
const res = await fetch(`${API_BASE}/documents/${docId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
if (res.status === 401) { logout(); return; }
fetchDocuments();
} catch (err) {
console.error(err);
}
};
const handleView = async (doc: any) => {
const fileType = (doc.file_type || '').toLowerCase();
// Text/scraped files → use inline preview modal
if (fileType === '.txt' || fileType === '.md' || fileType === '') {
setPreviewOpen(true);
setPreviewLoading(true);
setPreviewData(null);
try {
const res = await fetch(`${API_BASE}/documents/${doc.id}/preview`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.status === 401) { logout(); return; }
if (res.ok) {
const data = await res.json();
setPreviewData(data);
} else {
const err = await res.json();
setMessage(`PREVIEW ERROR: ${err.detail}`);
setPreviewOpen(false);
}
} catch (err) {
setMessage('ERROR: Failed to load preview.');
setPreviewOpen(false);
} finally {
setPreviewLoading(false);
}
return;
}
// Binary files (PDF) → open in new tab as before
const newTab = window.open('', '_blank');
if (!newTab) { setMessage('ERROR: Please allow popups to view PDFs.'); return; }
newTab.document.write('<div style="font-family:monospace;padding:20px;">Fetching secure document...</div>');
try {
const res = await fetch(`${API_BASE}/documents/${doc.id}/download`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.status === 401) { newTab.close(); logout(); return; }
if (res.ok) {
const contentType = res.headers.get('content-type') || 'application/pdf';
const blob = await res.blob();
const fileBlob = new Blob([blob], { type: contentType });
const blobUrl = window.URL.createObjectURL(fileBlob);
newTab.location.href = blobUrl;
} else {
newTab.close();
setMessage('ERROR: Document file not found on server.');
}
} catch (err) {
newTab.close();
setMessage('ERROR: Failed to open document.');
}
};
const isTxtDoc = (doc: any) => {
const ft = (doc.file_type || '').toLowerCase();
return ft === '.txt' || ft === '.md' || ft === '';
};
return (
<div className="container" style={{ animation: 'fadeIn 0.5s ease' }}>
<div className="page-header flex-between">
<div>
<h1>INGESTION PIPELINE</h1>
<p className="mono-text">DATA PROCESSING &amp; GRAPH CONSTRUCTION</p>
</div>
<Activity size={32} />
</div>
<div className="process-layout">
{/* Upload / Scrape Section */}
<div className="card upload-card">
<h2 className="mono-text flex-center"><FilePlus style={{ marginRight: '0.5rem' }}/> ADD DOCUMENT</h2>
<form onSubmit={handleUpload} className="upload-form">
<div className="file-input-wrapper">
<input type="file" id="file-upload" onChange={handleFileChange} accept=".pdf,.txt,.md" />
<label htmlFor="file-upload" className="file-label">
{file ? file.name : 'SELECT FILE FOR INGESTION'}
</label>
</div>
<button
type="submit"
className="app-btn full-width"
disabled={!file || uploading}
style={{ marginTop: '1rem', display: 'flex', justifyContent: 'center', gap: '8px' }}
>
{uploading && file ? 'UPLOADING...' : <><Upload size={18} /> INITIALIZE FILE INGESTION</>}
</button>
</form>
<hr style={{ margin: '2rem 0', borderColor: 'var(--border-color)', borderStyle: 'dotted' }} />
<h2 className="mono-text flex-center"><Globe style={{ marginRight: '0.5rem' }}/> SCRAPE URL</h2>
<p style={{ fontSize: '0.85rem', color: '#666', marginBottom: '1rem', lineHeight: 1.5 }}>
Paste any public URL — articles, docs, Wikipedia pages — to scrape and ingest the content into your knowledge graph.
</p>
<form onSubmit={handleUrlScrape} className="upload-form">
<input
type="url"
className="query-input mono-text"
placeholder="https://example.com/article"
value={url}
onChange={(e) => setUrl(e.target.value)}
required
style={{ width: '100%', marginBottom: '1rem' }}
/>
<button
type="submit"
className="app-btn full-width"
disabled={!url || uploading}
style={{ display: 'flex', justifyContent: 'center', gap: '8px' }}
>
{uploading && url ? 'SCRAPING...' : <><Activity size={18} /> INGEST FROM URL</>}
</button>
</form>
{Object.keys(tasks).length > 0 && (
<div className="task-tracker" style={{ marginTop: '1rem' }}>
<h4 className="mono-text">ACTIVE TASKS</h4>
{Object.entries(tasks).map(([tid, taskObj]) => {
const pct = (taskObj.progress?.current_chunk && taskObj.progress?.total_chunks)
? Math.round((taskObj.progress.current_chunk / taskObj.progress.total_chunks) * 100)
: 0;
return (
<div key={tid} className="task-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '8px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<span title={tid}>{tid.substring(0,8)}...</span>
<span className={`status-badge ${taskObj.status}`}>
{taskObj.status.toUpperCase()}{taskObj.status === 'processing' && pct > 0 ? ` ${pct}%` : ''}
</span>
</div>
{taskObj.status === 'processing' && taskObj.progress?.total_chunks && (
<div style={{ width: '100%', height: '4px', background: '#e0e0e0', borderRadius: '2px', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: '#000', transition: 'width 0.3s ease' }}></div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Corpus Table */}
<div className="card documents-card">
<h2 className="mono-text">CORPUS INDEX</h2>
<div className="table-wrapper">
<table className="docs-table">
<thead>
<tr>
<th>DOCUMENT NAME</th>
<th>TYPE</th>
<th>SIZE</th>
<th>INGESTED</th>
<th>ACTIONS</th>
</tr>
</thead>
<tbody>
{documents.length === 0 && !loadingDocs ? (
<tr><td colSpan={5} style={{ textAlign: 'center', padding: '3rem' }} className="mono-text">
<p style={{ marginBottom: '1rem' }}>NO DOCUMENTS INDEXED IN GRAPH DATABASE</p>
<p style={{ color: '#666', fontSize: '0.85rem' }}>Upload a file or scrape a URL above to begin.</p>
</td></tr>
) : loadingDocs ? (
<tr><td colSpan={5} style={{ textAlign: 'center', padding: '3rem' }} className="mono-text">
FETCHING CORPUS DATA...
</td></tr>
) : (
documents.map((doc: any) => (
<tr key={doc.id}>
<td className="mono-text" style={{ maxWidth: '220px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{isTxtDoc(doc) ? <Link size={14} /> : <FileText size={14} />}
{doc.filename}
</span>
</td>
<td>
<span className={`type-badge ${isTxtDoc(doc) ? 'scraped' : 'pdf'}`}>
{isTxtDoc(doc) ? 'WEB / TXT' : (doc.file_type || 'PDF').toUpperCase()}
</span>
</td>
<td className="mono-text">{(doc.size_bytes / 1024).toFixed(1)} KB</td>
<td className="mono-text" style={{ fontSize: '0.8rem' }}>{doc.upload_date?.substring(0,19) || '—'}</td>
<td style={{ whiteSpace: 'nowrap' }}>
<button
className="icon-btn"
onClick={() => handleView(doc)}
title={isTxtDoc(doc) ? 'Preview Content' : 'Open PDF'}
style={{ marginRight: '10px' }}
>
<Eye size={16} />
</button>
<button className="icon-btn" onClick={() => handleDelete(doc.id)} title="Delete Document">
<Trash2 size={16} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Document Preview Modal */}
{previewOpen && (
<div className="preview-overlay" onClick={() => setPreviewOpen(false)}>
<div className="preview-modal" onClick={(e) => e.stopPropagation()}>
{/* Modal Header */}
<div className="preview-header">
<div className="preview-header-left">
<BookOpen size={20} />
<div>
<div className="preview-title">{previewData?.filename || 'Loading...'}</div>
{previewData && (
<div className="preview-meta-row">
<span className="preview-meta-chip"><Hash size={12} /> {previewData.word_count.toLocaleString()} words</span>
<span className="preview-meta-chip"><FileText size={12} /> {(previewData.char_count / 1000).toFixed(1)}K chars</span>
<span className="type-badge scraped">WEB SCRAPE</span>
</div>
)}
</div>
</div>
<button className="icon-btn" onClick={() => setPreviewOpen(false)} style={{ fontSize: '1.5rem' }}>
<X size={22} />
</button>
</div>
{/* Modal Body */}
<div className="preview-body">
{previewLoading ? (
<div className="loading-spinner"><Activity size={32} style={{ animation: 'pulse 1.5s infinite' }} /> Fetching Content...</div>
) : previewData ? (
<div className="markdown-content formatted">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{previewData.content}</ReactMarkdown>
</div>
) : (
<div className="error-text">Failed to load content.</div>
)}
</div>
</div>
</div>
)}
{message && (
<div className={`status-toast ${message.includes('ERROR') || message.includes('FAILED') ? 'error' : ''}`}>
<Activity size={20} /> {message}
<button
onClick={() => setMessage('')}
className="toast-dismiss-btn"
>
&times;
</button>
</div>
)}
<style>{`
.process-layout {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 2rem;
}
@media (max-width: 900px) {
.process-layout { grid-template-columns: 1fr; }
}
.file-input-wrapper {
position: relative;
overflow: hidden;
display: inline-block;
width: 100%;
border: 2px dashed var(--border-color);
text-align: center;
background: var(--hover-bg);
transition: var(--transition-speed);
}
.file-input-wrapper:hover { background: #e0e0e0; }
.file-input-wrapper input[type=file] {
font-size: 100px;
position: absolute;
left: 0; top: 0;
opacity: 0;
cursor: pointer;
}
.file-label {
display: block;
padding: 2rem;
font-family: var(--font-mono);
font-weight: 600;
cursor: pointer;
}
.task-tracker {
margin-top: 2rem;
border-top: 2px solid var(--border-color);
padding-top: 1rem;
}
.task-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
font-family: var(--font-mono);
font-size: 0.85rem;
border-bottom: 1px dotted var(--border-color);
}
.status-badge {
padding: 0.1rem 0.5rem;
background: var(--text-color);
color: var(--bg-color);
font-weight: 700;
font-size: 0.75rem;
font-family: var(--font-mono);
}
.status-badge.processing { background: #666; }
.status-badge.completed { background: #000; }
.status-badge.failure { background: #990000; color: white; }
.type-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
font-size: 0.7rem;
font-family: var(--font-mono);
font-weight: 700;
letter-spacing: 0.5px;
border: 1.5px solid var(--border-color);
}
.type-badge.scraped { background: #000; color: #fff; border-color: #000; }
.type-badge.pdf { background: transparent; color: #444; }
.table-wrapper {
overflow-x: auto;
margin-top: 1rem;
width: 100%;
-webkit-overflow-scrolling: touch;
}
.docs-table {
width: 100%;
border-collapse: collapse;
border: 2px solid var(--border-color);
}
.docs-table th, .docs-table td {
border: 1px solid var(--border-color);
padding: 0.75rem;
text-align: left;
font-size: 0.9rem;
}
.docs-table th {
background-color: var(--text-color);
color: var(--bg-color);
font-family: var(--font-display);
letter-spacing: 1px;
white-space: nowrap;
}
.docs-table tr:hover { background-color: var(--hover-bg); }
.icon-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-color);
padding: 4px;
}
.icon-btn:hover { color: #ff0000; }
/* Preview Modal */
.preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
z-index: 5000;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
animation: fadeIn 0.2s ease;
}
.preview-modal {
background: var(--bg-color);
border: 3px solid var(--border-color);
box-shadow: 8px 8px 0 var(--border-color);
width: 100%;
max-width: 860px;
max-height: 85vh;
display: flex;
flex-direction: column;
animation: scaleIn 0.2s ease;
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.preview-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 2px solid var(--border-color);
background: var(--text-color);
color: var(--bg-color);
gap: 1rem;
}
.preview-header-left {
display: flex;
align-items: flex-start;
gap: 1rem;
flex: 1;
min-width: 0;
}
.preview-title {
font-family: var(--font-mono);
font-weight: 700;
font-size: 0.95rem;
word-break: break-all;
margin-bottom: 0.4rem;
}
.preview-meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.preview-meta-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(255,255,255,0.15);
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
font-family: var(--font-mono);
border-radius: 2px;
}
.preview-header .icon-btn { color: var(--bg-color); }
.preview-header .icon-btn:hover { color: #ffaaaa; }
.preview-body {
flex: 1;
overflow-y: auto;
padding: 0;
}
.markdown-content.formatted {
padding: 2rem;
font-family: var(--font-sans);
line-height: 1.6;
color: #333;
font-size: 1rem;
}
.markdown-content.formatted h1,
.markdown-content.formatted h2,
.markdown-content.formatted h3 {
font-family: var(--font-mono);
color: #000;
margin-top: 1.5em;
margin-bottom: 0.5em;
border-bottom: 2px solid #eaeaea;
padding-bottom: 0.3em;
}
.markdown-content.formatted p {
margin-bottom: 1em;
}
.markdown-content.formatted ul,
.markdown-content.formatted ol {
margin-bottom: 1em;
padding-left: 2em;
}
.markdown-content.formatted a {
color: #0056b3;
text-decoration: underline;
}
.markdown-content.formatted code {
font-family: var(--font-mono);
background: #f4f4f4;
padding: 0.1em 0.3em;
border-radius: 3px;
font-size: 0.9em;
}
.markdown-content.formatted pre {
background: #111;
color: #fff;
padding: 1em;
overflow-x: auto;
border-radius: 4px;
}
.markdown-content.formatted pre code {
background: transparent;
color: inherit;
padding: 0;
}
.markdown-content.formatted blockquote {
border-left: 4px solid #ccc;
margin: 0;
padding-left: 1em;
color: #666;
font-style: italic;
}
.preview-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
height: 300px;
font-family: var(--font-mono);
font-weight: bold;
letter-spacing: 2px;
color: #666;
}
.preview-content {
font-family: var(--font-mono);
font-size: 0.82rem;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
padding: 2rem;
margin: 0;
color: #111;
border: none;
background: #fafafa;
}
.spin-slow {
animation: spin 1.5s linear infinite;
}
@keyframes spin {
100% { transform: rotate(360deg); }
}
.status-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--text-color);
color: var(--bg-color);
padding: 1rem 1.5rem;
border-left: 6px solid var(--text-color);
box-shadow: 4px 4px 0 var(--border-color);
display: flex;
align-items: center;
gap: 1rem;
z-index: 9999;
font-family: var(--font-mono);
font-weight: bold;
font-size: 0.9rem;
animation: slideUp 0.3s ease-out;
max-width: 420px;
}
.status-toast.error { border-left-color: #ff0000; }
@keyframes slideUp {
from { transform: translateY(100px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`}</style>
</div>
);
};
export default Process;