| |
| |
| |
| |
|
|
| |
| 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'); |
| }); |
|
|