|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let currentPdfId = null; |
|
|
let graphData = { nodes: [], edges: [] }; |
|
|
let selectedNodeId = null; |
|
|
|
|
|
|
|
|
const API_BASE = window.location.origin; |
|
|
|
|
|
|
|
|
function showProcessingOverlay(title = 'Processing PDF', message = 'Starting...', percent = 0) { |
|
|
const overlay = document.getElementById('processing-overlay'); |
|
|
const titleEl = document.getElementById('processing-title'); |
|
|
const messageEl = document.getElementById('processing-message'); |
|
|
const percentEl = document.getElementById('processing-percent'); |
|
|
const progressFill = document.getElementById('progress-fill'); |
|
|
|
|
|
titleEl.textContent = title; |
|
|
messageEl.textContent = message; |
|
|
percentEl.textContent = `${percent}%`; |
|
|
progressFill.style.width = `${percent}%`; |
|
|
|
|
|
overlay.hidden = false; |
|
|
} |
|
|
|
|
|
function updateProcessingOverlay(message, percent) { |
|
|
const messageEl = document.getElementById('processing-message'); |
|
|
const percentEl = document.getElementById('processing-percent'); |
|
|
const progressFill = document.getElementById('progress-fill'); |
|
|
|
|
|
messageEl.textContent = message; |
|
|
percentEl.textContent = `${percent}%`; |
|
|
progressFill.style.width = `${percent}%`; |
|
|
} |
|
|
|
|
|
function hideProcessingOverlay() { |
|
|
const overlay = document.getElementById('processing-overlay'); |
|
|
overlay.hidden = true; |
|
|
} |
|
|
|
|
|
|
|
|
async function apiCall(endpoint, options = {}) { |
|
|
try { |
|
|
const response = await fetch(`${API_BASE}${endpoint}`, options); |
|
|
if (!response.ok) { |
|
|
throw new Error(`API Error: ${response.statusText}`); |
|
|
} |
|
|
return await response.json(); |
|
|
} catch (error) { |
|
|
console.error('API call failed:', error); |
|
|
showNotification(error.message, 'error'); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
function showNotification(message, type = 'info') { |
|
|
const statusEl = document.getElementById('upload-status'); |
|
|
statusEl.textContent = message; |
|
|
statusEl.style.color = type === 'error' ? '#f44336' : type === 'success' ? '#4caf50' : '#4f9eff'; |
|
|
|
|
|
setTimeout(() => { |
|
|
statusEl.textContent = ''; |
|
|
}, 5000); |
|
|
} |
|
|
|
|
|
|
|
|
document.getElementById('pdf-upload').addEventListener('change', async (e) => { |
|
|
const file = e.target.files[0]; |
|
|
if (!file) return; |
|
|
|
|
|
|
|
|
showProcessingOverlay('Uploading PDF', `Uploading ${file.name}...`, 0); |
|
|
|
|
|
const formData = new FormData(); |
|
|
formData.append('file', file); |
|
|
|
|
|
try { |
|
|
const result = await apiCall('/upload', { |
|
|
method: 'POST', |
|
|
body: formData |
|
|
}); |
|
|
|
|
|
currentPdfId = result.pdf_id; |
|
|
updateProcessingOverlay('Upload complete, starting processing...', 5); |
|
|
|
|
|
|
|
|
pollProcessingStatus(result.pdf_id); |
|
|
|
|
|
} catch (error) { |
|
|
hideProcessingOverlay(); |
|
|
showNotification('Upload failed', 'error'); |
|
|
} |
|
|
}); |
|
|
|
|
|
async function pollProcessingStatus(pdfId) { |
|
|
const interval = setInterval(async () => { |
|
|
try { |
|
|
|
|
|
const status = await apiCall(`/status/${pdfId}`); |
|
|
|
|
|
|
|
|
if (status.progress) { |
|
|
const { message, percent } = status.progress; |
|
|
updateProcessingOverlay(message, percent); |
|
|
} |
|
|
|
|
|
|
|
|
if (status.status === 'completed') { |
|
|
clearInterval(interval); |
|
|
|
|
|
|
|
|
updateProcessingOverlay( |
|
|
`✓ Complete! ${status.num_nodes} nodes, ${status.num_edges} edges`, |
|
|
100 |
|
|
); |
|
|
|
|
|
|
|
|
setTimeout(async () => { |
|
|
hideProcessingOverlay(); |
|
|
await loadGraph(); |
|
|
await updateStats(); |
|
|
showNotification(`✓ Graph loaded: ${status.num_nodes} nodes, ${status.num_edges} edges`, 'success'); |
|
|
}, 1500); |
|
|
|
|
|
} else if (status.status === 'failed') { |
|
|
clearInterval(interval); |
|
|
hideProcessingOverlay(); |
|
|
showNotification(`Error: ${status.error}`, 'error'); |
|
|
} |
|
|
} catch (error) { |
|
|
clearInterval(interval); |
|
|
hideProcessingOverlay(); |
|
|
showNotification('Failed to check status', 'error'); |
|
|
} |
|
|
}, 1000); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
clearInterval(interval); |
|
|
hideProcessingOverlay(); |
|
|
showNotification('Processing timeout', 'error'); |
|
|
}, 300000); |
|
|
} |
|
|
|
|
|
|
|
|
let network = null; |
|
|
|
|
|
async function loadGraph() { |
|
|
try { |
|
|
const data = await apiCall('/graph'); |
|
|
graphData = data; |
|
|
|
|
|
|
|
|
renderGraph(data); |
|
|
|
|
|
} catch (error) { |
|
|
console.error('Failed to load graph:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
function renderGraph(data) { |
|
|
const container = document.getElementById('graph-container'); |
|
|
|
|
|
|
|
|
container.innerHTML = ''; |
|
|
|
|
|
console.log(`Rendering graph: ${data.nodes.length} nodes, ${data.edges.length} edges`); |
|
|
|
|
|
|
|
|
const rect = container.getBoundingClientRect(); |
|
|
const containerHeight = rect.height || 600; |
|
|
const containerWidth = rect.width || 800; |
|
|
|
|
|
|
|
|
container.style.position = 'relative'; |
|
|
container.style.width = containerWidth + 'px'; |
|
|
container.style.height = containerHeight + 'px'; |
|
|
container.style.overflow = 'hidden'; |
|
|
|
|
|
|
|
|
const visNodes = data.nodes.map(node => ({ |
|
|
id: node.node_id, |
|
|
label: node.label, |
|
|
title: `${node.label}\nType: ${node.type}\nImportance: ${node.importance_score.toFixed(2)}`, |
|
|
value: node.importance_score * 20, |
|
|
group: node.type, |
|
|
font: { color: '#e6eef8' } |
|
|
})); |
|
|
|
|
|
|
|
|
const visEdges = data.edges.map(edge => ({ |
|
|
from: edge.from || edge.from_node, |
|
|
to: edge.to || edge.to_node, |
|
|
label: edge.relation, |
|
|
title: `${edge.relation} (${edge.confidence.toFixed(2)})`, |
|
|
width: 1.5, |
|
|
|
|
|
color: { |
|
|
color: '#00ff00', |
|
|
highlight: '#ff00ff', |
|
|
hover: '#ffff00', |
|
|
opacity: 1.0 |
|
|
}, |
|
|
font: { |
|
|
size: 12, |
|
|
color: '#ffffff', |
|
|
strokeWidth: 3, |
|
|
strokeColor: '#000000', |
|
|
background: 'rgba(0, 0, 0, 0.8)', |
|
|
bold: true |
|
|
} |
|
|
})); |
|
|
|
|
|
|
|
|
const graphData = { |
|
|
nodes: new vis.DataSet(visNodes), |
|
|
edges: new vis.DataSet(visEdges) |
|
|
}; |
|
|
|
|
|
const options = { |
|
|
nodes: { |
|
|
shape: 'dot', |
|
|
scaling: { |
|
|
min: 10, |
|
|
max: 30 |
|
|
}, |
|
|
font: { |
|
|
size: 12, |
|
|
face: 'Arial', |
|
|
color: '#e6eef8' |
|
|
}, |
|
|
borderWidth: 2, |
|
|
shadow: true |
|
|
}, |
|
|
edges: { |
|
|
width: 1.5, |
|
|
color: { |
|
|
color: '#00ff00', |
|
|
highlight: '#ff00ff', |
|
|
hover: '#ffff00', |
|
|
opacity: 1.0 |
|
|
}, |
|
|
arrows: { |
|
|
to: { enabled: false } |
|
|
}, |
|
|
smooth: { |
|
|
type: 'continuous', |
|
|
roundness: 0.2 |
|
|
}, |
|
|
font: { |
|
|
size: 12, |
|
|
color: '#ffffff', |
|
|
strokeWidth: 3, |
|
|
strokeColor: '#000000', |
|
|
align: 'top', |
|
|
bold: true, |
|
|
background: 'rgba(0, 0, 0, 0.8)' |
|
|
}, |
|
|
selectionWidth: 3, |
|
|
hoverWidth: 2.5, |
|
|
shadow: { |
|
|
enabled: true, |
|
|
color: 'rgba(0, 255, 0, 0.5)', |
|
|
size: 5, |
|
|
x: 0, |
|
|
y: 0 |
|
|
} |
|
|
}, |
|
|
groups: { |
|
|
concept: { color: { background: '#4f9eff', border: '#3d8ae6' } }, |
|
|
function: { color: { background: '#9c27b0', border: '#7b1fa2' } }, |
|
|
class: { color: { background: '#ff5722', border: '#e64a19' } }, |
|
|
term: { color: { background: '#4caf50', border: '#388e3c' } }, |
|
|
person: { color: { background: '#ff9800', border: '#f57c00' } }, |
|
|
method: { color: { background: '#00bcd4', border: '#0097a7' } }, |
|
|
entity: { color: { background: '#607d8b', border: '#455a64' } } |
|
|
}, |
|
|
physics: { |
|
|
stabilization: { iterations: 200 }, |
|
|
barnesHut: { |
|
|
gravitationalConstant: -8000, |
|
|
springConstant: 0.04, |
|
|
springLength: 95 |
|
|
} |
|
|
}, |
|
|
interaction: { |
|
|
hover: true, |
|
|
navigationButtons: true, |
|
|
keyboard: true |
|
|
}, |
|
|
autoResize: false, |
|
|
height: containerHeight + 'px', |
|
|
width: containerWidth + 'px' |
|
|
}; |
|
|
|
|
|
|
|
|
network = new vis.Network(container, graphData, options); |
|
|
|
|
|
|
|
|
if (network) { |
|
|
network.setOptions({ autoResize: false }); |
|
|
} |
|
|
|
|
|
|
|
|
network.on('click', function(params) { |
|
|
if (params.nodes.length > 0) { |
|
|
const nodeId = params.nodes[0]; |
|
|
selectNode(nodeId); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
window.selectNode = async function(nodeId) { |
|
|
selectedNodeId = nodeId; |
|
|
|
|
|
try { |
|
|
const nodeData = await apiCall(`/node/${nodeId}`); |
|
|
displayNodeDetails(nodeData); |
|
|
} catch (error) { |
|
|
console.error('Failed to load node details:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
function displayNodeDetails(nodeData) { |
|
|
const content = document.getElementById('node-content'); |
|
|
|
|
|
const sourcesHtml = nodeData.sources.map((source, i) => ` |
|
|
<li>p.${source.page_number} - "${source.snippet}" <span style="color: #8b92a0;">(${source.chunk_id})</span></li> |
|
|
`).join(''); |
|
|
|
|
|
const relatedHtml = nodeData.related_nodes.map(related => ` |
|
|
<li onclick="selectNode('${related.node_id}')" style="cursor: pointer; padding: 0.5rem; background: #23262e; border-radius: 6px; margin-bottom: 0.25rem;"> |
|
|
<strong>${related.label}</strong> - ${related.relation} (confidence: ${related.confidence.toFixed(2)}) |
|
|
</li> |
|
|
`).join(''); |
|
|
|
|
|
content.innerHTML = ` |
|
|
<div class="node-info"> |
|
|
<h3 class="node-label">${nodeData.label}</h3> |
|
|
<span class="badge">${nodeData.type}</span> |
|
|
|
|
|
<div class="node-summary"> |
|
|
<h4>Summary</h4> |
|
|
<p>${nodeData.summary}</p> |
|
|
</div> |
|
|
|
|
|
<div class="node-sources"> |
|
|
<h4>Sources</h4> |
|
|
<button class="expand-toggle" onclick="toggleSources()">Show Sources</button> |
|
|
<ul class="sources-list" id="sources-list" hidden> |
|
|
${sourcesHtml} |
|
|
</ul> |
|
|
</div> |
|
|
|
|
|
${nodeData.related_nodes.length > 0 ? ` |
|
|
<div class="related-nodes"> |
|
|
<h4>Related Nodes</h4> |
|
|
<ul class="related-list"> |
|
|
${relatedHtml} |
|
|
</ul> |
|
|
</div> |
|
|
` : ''} |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
window.toggleSources = function() { |
|
|
const sourcesList = document.getElementById('sources-list'); |
|
|
const toggle = document.querySelector('.expand-toggle'); |
|
|
|
|
|
if (sourcesList.hidden) { |
|
|
sourcesList.hidden = false; |
|
|
toggle.textContent = 'Hide Sources'; |
|
|
} else { |
|
|
sourcesList.hidden = true; |
|
|
toggle.textContent = 'Show Sources'; |
|
|
} |
|
|
} |
|
|
|
|
|
document.getElementById('close-node-detail').addEventListener('click', () => { |
|
|
document.getElementById('node-content').innerHTML = '<p class="placeholder-text">Click a node in the graph to view details</p>'; |
|
|
selectedNodeId = null; |
|
|
}); |
|
|
|
|
|
|
|
|
document.getElementById('send-btn').addEventListener('click', sendMessage); |
|
|
document.getElementById('chat-input').addEventListener('keydown', (e) => { |
|
|
if (e.key === 'Enter' && !e.shiftKey) { |
|
|
e.preventDefault(); |
|
|
sendMessage(); |
|
|
} |
|
|
}); |
|
|
|
|
|
async function sendMessage() { |
|
|
const input = document.getElementById('chat-input'); |
|
|
const query = input.value.trim(); |
|
|
|
|
|
if (!query) return; |
|
|
if (!currentPdfId) { |
|
|
showNotification('Please upload a PDF first', 'error'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
addMessageToChat('user', query); |
|
|
input.value = ''; |
|
|
|
|
|
try { |
|
|
const includeCitations = document.getElementById('include-citations').checked; |
|
|
|
|
|
const response = await apiCall('/chat', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ |
|
|
query, |
|
|
pdf_id: currentPdfId, |
|
|
include_citations: includeCitations, |
|
|
max_sources: 5 |
|
|
}) |
|
|
}); |
|
|
|
|
|
|
|
|
addMessageToChat('assistant', response.answer, response.sources); |
|
|
|
|
|
} catch (error) { |
|
|
addMessageToChat('assistant', 'Sorry, I encountered an error processing your question.'); |
|
|
} |
|
|
} |
|
|
|
|
|
function addMessageToChat(role, content, sources = []) { |
|
|
const messagesContainer = document.getElementById('chat-messages'); |
|
|
|
|
|
const messageDiv = document.createElement('div'); |
|
|
messageDiv.className = `message ${role}`; |
|
|
|
|
|
let html = `<p>${content}</p>`; |
|
|
|
|
|
if (sources && sources.length > 0) { |
|
|
html += '<div style="margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid rgba(255,255,255,0.1);">'; |
|
|
html += '<strong style="font-size: 0.875rem;">Sources:</strong><ul style="margin-top: 0.25rem; font-size: 0.875rem;">'; |
|
|
sources.forEach(source => { |
|
|
html += `<li>p.${source.page_number}: "${source.snippet}"</li>`; |
|
|
}); |
|
|
html += '</ul></div>'; |
|
|
} |
|
|
|
|
|
messageDiv.innerHTML = html; |
|
|
messagesContainer.appendChild(messageDiv); |
|
|
|
|
|
|
|
|
messagesContainer.scrollTop = messagesContainer.scrollHeight; |
|
|
} |
|
|
|
|
|
|
|
|
async function updateStats() { |
|
|
try { |
|
|
const status = await apiCall('/admin/status'); |
|
|
|
|
|
document.getElementById('stats-nodes').textContent = `Nodes: ${status.total_nodes}`; |
|
|
document.getElementById('stats-edges').textContent = `Edges: ${status.total_edges}`; |
|
|
document.getElementById('stats-chunks').textContent = `Chunks: ${status.total_chunks}`; |
|
|
} catch (error) { |
|
|
console.error('Failed to update stats:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
document.getElementById('reindex-btn').addEventListener('click', async () => { |
|
|
if (!currentPdfId) { |
|
|
showNotification('No PDF to reindex', 'error'); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!confirm('Reindex current PDF? This will take some time.')) return; |
|
|
|
|
|
try { |
|
|
|
|
|
showProcessingOverlay('Reindexing PDF', 'Starting reindex...', 0); |
|
|
|
|
|
await apiCall(`/admin/reindex?pdf_id=${currentPdfId}`, { method: 'POST' }); |
|
|
|
|
|
|
|
|
pollProcessingStatus(currentPdfId); |
|
|
} catch (error) { |
|
|
hideProcessingOverlay(); |
|
|
showNotification('Reindex failed', 'error'); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('clear-btn').addEventListener('click', async () => { |
|
|
if (!confirm('Clear all data? This cannot be undone!')) return; |
|
|
|
|
|
try { |
|
|
await apiCall('/admin/clear', { method: 'POST' }); |
|
|
showNotification('All data cleared', 'success'); |
|
|
|
|
|
|
|
|
currentPdfId = null; |
|
|
graphData = { nodes: [], edges: [] }; |
|
|
document.getElementById('graph-container').innerHTML = '<div class="graph-placeholder"><p>Upload a PDF to generate a knowledge graph</p></div>'; |
|
|
document.getElementById('node-content').innerHTML = '<p class="placeholder-text">Click a node in the graph to view details</p>'; |
|
|
document.getElementById('chat-messages').innerHTML = '<div class="message system"><p>Ask questions about your uploaded PDF. Answers will cite page numbers.</p></div>'; |
|
|
await updateStats(); |
|
|
} catch (error) { |
|
|
showNotification('Clear failed', 'error'); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
document.getElementById('zoom-in-btn').addEventListener('click', () => { |
|
|
if (network) { |
|
|
const scale = network.getScale(); |
|
|
network.moveTo({ scale: scale * 1.2 }); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('zoom-out-btn').addEventListener('click', () => { |
|
|
if (network) { |
|
|
const scale = network.getScale(); |
|
|
network.moveTo({ scale: scale * 0.8 }); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('reset-view-btn').addEventListener('click', () => { |
|
|
if (network) { |
|
|
network.fit(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
updateStats(); |
|
|
console.log('GraphLLM Frontend Initialized'); |
|
|
}); |
|
|
|