Download static/script.js from Jack1808/Vector_RAG_vs_Page_Index_RAG: direct link, hf CLI and curl.
- Browser
- Download file 33.9 kB
-
https://huggingface.co/spaces/Jack1808/Vector_RAG_vs_Page_Index_RAG/resolve/main/static/script.js
- Command line
-
hf download hf://spaces/Jack1808/Vector_RAG_vs_Page_Index_RAG/static/script.js
-
curl -L -o script.js https://huggingface.co/spaces/Jack1808/Vector_RAG_vs_Page_Index_RAG/resolve/main/static/script.js
33.9 kB
| document.addEventListener('DOMContentLoaded', () => { | |
| const uploadBtn = document.getElementById('upload-btn'); | |
| const indexBtn = document.getElementById('index-btn'); | |
| const sendBtn = document.getElementById('send-btn'); | |
| const promptInput = document.getElementById('prompt-input'); | |
| const chatContainer = document.getElementById('chat-container'); | |
| const topKInput = document.getElementById('top-k'); | |
| const statusMsg = document.getElementById('index-status'); | |
| // Toggle helpers | |
| const toggleVector = document.getElementById('toggle-vector'); | |
| const togglePage = document.getElementById('toggle-page'); | |
| const toggleToc = document.getElementById('toggle-toc'); | |
| function getActiveRagModes() { | |
| return { | |
| vector: toggleVector.checked, | |
| page: togglePage.checked, | |
| toc: toggleToc.checked | |
| }; | |
| } | |
| function getRagModeString() { | |
| const m = getActiveRagModes(); | |
| if (m.vector && m.page && m.toc) return 'all'; | |
| const parts = []; | |
| if (m.page) parts.push('page'); | |
| if (m.vector) parts.push('vector'); | |
| if (m.toc) parts.push('toc'); | |
| return parts.join(',') || 'all'; | |
| } | |
| // Auto-resize textarea | |
| promptInput.addEventListener('input', function () { | |
| this.style.height = 'auto'; | |
| this.style.height = (this.scrollHeight) + 'px'; | |
| if (this.value.trim().length > 0) { | |
| sendBtn.removeAttribute('disabled'); | |
| } else { | |
| sendBtn.setAttribute('disabled', 'true'); | |
| } | |
| }); | |
| promptInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSend(); | |
| } | |
| }); | |
| sendBtn.addEventListener('click', handleSend); | |
| async function fetchDocuments() { | |
| const docList = document.getElementById('documents-list'); | |
| try { | |
| const res = await fetch('/api/documents'); | |
| const data = await res.json(); | |
| docList.innerHTML = ''; | |
| if (data.documents.length === 0) { | |
| docList.innerHTML = '<div style="font-size:0.8rem; color:var(--text-secondary); padding: 8px;">No documents uploaded.</div>'; | |
| return; | |
| } | |
| data.documents.forEach(doc => { | |
| const card = document.createElement('div'); | |
| card.className = 'document-card'; | |
| const sizeKb = (doc.size / 1024).toFixed(1); | |
| let iconHtml = ''; | |
| if (doc.thumbnail) { | |
| iconHtml = `<img src="${doc.thumbnail}" alt="${doc.name}" class="doc-thumbnail" onerror="this.style.display='none'">`; | |
| } else { | |
| const iconClass = doc.type === 'pdf' ? 'icon-pdf' : | |
| doc.type === 'docx' ? 'icon-docx' : | |
| doc.type === 'txt' ? 'icon-txt' : 'icon-default'; | |
| iconHtml = `<div class="doc-icon ${iconClass}">${doc.type.toUpperCase()}</div>`; | |
| } | |
| card.innerHTML = ` | |
| ${iconHtml} | |
| <div class="doc-name" title="${doc.name}">${doc.name}</div> | |
| <div class="doc-size">${sizeKb} KB</div> | |
| <div style="display:flex; gap:4px; margin-top:4px;"> | |
| <button class="doc-tree-btn" title="View TOC Tree" onclick="event.stopPropagation()">🌳</button> | |
| </div> | |
| `; | |
| // Tree button handler | |
| const treeBtn = card.querySelector('.doc-tree-btn'); | |
| treeBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| openTreeModal(doc.name); | |
| }); | |
| // Add click handler to open embeddings viewer | |
| card.style.cursor = 'pointer'; | |
| card.addEventListener('click', () => openChunksModal(doc.name)); | |
| docList.appendChild(card); | |
| }); | |
| } catch (e) { | |
| console.error("Failed to load documents", e); | |
| } | |
| } | |
| // Call once on load | |
| fetchDocuments(); | |
| fetchStats(); | |
| let isIndexing = false; | |
| let pollInterval = null; | |
| const overlay = document.getElementById('indexing-overlay'); | |
| const overlayText = document.getElementById('overlay-text'); | |
| async function checkIndexStatus() { | |
| try { | |
| const res = await fetch('/api/index/status'); | |
| const data = await res.json(); | |
| if (data.is_indexing) { | |
| if (data.progress) { | |
| overlayText.textContent = data.progress; | |
| } | |
| if (!isIndexing) { | |
| isIndexing = true; | |
| statusMsg.innerHTML = '<span class="spinner"></span> Indexing in progress...'; | |
| statusMsg.style.color = 'var(--brand-color)'; | |
| indexBtn.disabled = true; | |
| uploadBtn.disabled = true; | |
| overlay.style.display = 'flex'; | |
| if (!pollInterval) { | |
| pollInterval = setInterval(checkIndexStatus, 2000); | |
| } | |
| } | |
| } else { | |
| if (isIndexing) { | |
| isIndexing = false; | |
| statusMsg.textContent = 'Ready.'; | |
| statusMsg.style.color = 'var(--text-secondary)'; | |
| indexBtn.disabled = false; | |
| uploadBtn.disabled = false; | |
| overlay.style.display = 'none'; | |
| if (pollInterval) { | |
| clearInterval(pollInterval); | |
| pollInterval = null; | |
| // Wait a tiny bit and refresh so that thumbnails appear | |
| setTimeout(fetchDocuments, 1000); | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| console.error("Failed to check index status", e); | |
| } | |
| } | |
| // Start polling if we are currently indexing | |
| checkIndexStatus(); | |
| uploadBtn.addEventListener('click', async () => { | |
| const fileInput = document.getElementById('file-upload'); | |
| if (fileInput.files.length === 0) return alert('Select files first.'); | |
| const formData = new FormData(); | |
| for (const file of fileInput.files) { | |
| formData.append('files', file); | |
| } | |
| formData.append('rag_mode', getRagModeString()); | |
| uploadBtn.textContent = 'Uploading...'; | |
| uploadBtn.disabled = true; | |
| try { | |
| const response = await fetch('/api/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const result = await response.json(); | |
| alert(result.message); | |
| fileInput.value = ""; | |
| // Refresh documents list | |
| fetchDocuments(); | |
| // Force status check for auto-indexing | |
| checkIndexStatus(); | |
| } catch (error) { | |
| console.error(error); | |
| alert('Upload failed.'); | |
| } finally { | |
| uploadBtn.textContent = 'Upload'; | |
| if (!isIndexing) uploadBtn.disabled = false; | |
| } | |
| }); | |
| indexBtn.addEventListener('click', async () => { | |
| indexBtn.textContent = 'Starting...'; | |
| indexBtn.disabled = true; | |
| statusMsg.innerHTML = '<span class="spinner"></span> Triggering index...'; | |
| const ragMode = getRagModeString(); | |
| try { | |
| const response = await fetch('/api/index', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ rag_mode: ragMode }) | |
| }); | |
| const result = await response.json(); | |
| console.log(result.message); | |
| checkIndexStatus(); | |
| } catch (error) { | |
| console.error(error); | |
| statusMsg.textContent = "Error triggering indexing."; | |
| indexBtn.disabled = false; | |
| } finally { | |
| indexBtn.textContent = 'Re-index All'; | |
| } | |
| }); | |
| const clearBtn = document.getElementById('clear-btn'); | |
| clearBtn.addEventListener('click', async () => { | |
| if (!confirm('Are you sure you want to clear ALL indexed data? This action cannot be undone.')) { | |
| return; | |
| } | |
| clearBtn.textContent = 'Clearing...'; | |
| clearBtn.disabled = true; | |
| try { | |
| const response = await fetch('/api/index/clear', { method: 'POST' }); | |
| const result = await response.json(); | |
| alert(result.message); | |
| fetchDocuments(); | |
| fetchStats(); | |
| } catch (error) { | |
| console.error(error); | |
| alert('Failed to clear database.'); | |
| } finally { | |
| clearBtn.textContent = 'Clear All Data'; | |
| clearBtn.disabled = false; | |
| } | |
| }); | |
| async function fetchStats() { | |
| try { | |
| const response = await fetch('/api/stats'); | |
| const data = await response.json(); | |
| document.getElementById('stat-embed').textContent = data.tokens.embedding_tokens.toLocaleString(); | |
| document.getElementById('stat-prompt').textContent = data.tokens.prompt_tokens.toLocaleString(); | |
| document.getElementById('stat-response').textContent = data.tokens.completion_tokens.toLocaleString(); | |
| document.getElementById('stat-toc').textContent = (data.tokens.toc_analysis_tokens || 0).toLocaleString(); | |
| document.getElementById('stat-total').textContent = data.total_tokens.toLocaleString(); | |
| } catch (error) { | |
| console.error('Error fetching stats:', error); | |
| } | |
| } | |
| function cleanMarkdown(text) { | |
| return marked.parse(text); | |
| } | |
| // ── History Management ─────────────────────────────────────────────────── | |
| const MAX_HISTORY_TURNS = 10; // 5 user + 5 assistant | |
| function getHistory(ragMode) { | |
| try { | |
| const h = localStorage.getItem(`chat_history_${ragMode}`); | |
| return h ? JSON.parse(h) : []; | |
| } catch (e) { | |
| console.error('Error reading history', e); | |
| return []; | |
| } | |
| } | |
| function saveHistory(ragMode, history) { | |
| try { | |
| if (history.length > MAX_HISTORY_TURNS) { | |
| // Keep the most recent messages, taking care to keep user/assistant pairs intact if possible | |
| history = history.slice(history.length - MAX_HISTORY_TURNS); | |
| } | |
| localStorage.setItem(`chat_history_${ragMode}`, JSON.stringify(history)); | |
| updateHistoryBadge(ragMode); | |
| } catch (e) { | |
| console.error('Error saving history', e); | |
| } | |
| } | |
| function appendToHistory(ragMode, userMsg, assistantMsg) { | |
| const history = getHistory(ragMode); | |
| history.push({ role: 'user', content: userMsg }); | |
| history.push({ role: 'assistant', content: assistantMsg }); | |
| saveHistory(ragMode, history); | |
| } | |
| function clearHistory() { | |
| localStorage.removeItem('chat_history_page'); | |
| localStorage.removeItem('chat_history_vector'); | |
| localStorage.removeItem('chat_history_toc'); | |
| updateHistoryBadge('page'); | |
| updateHistoryBadge('vector'); | |
| updateHistoryBadge('toc'); | |
| chatContainer.innerHTML = ''; // Optionally clear screen too | |
| } | |
| function updateHistoryBadge(mode) { | |
| // Will be implemented later if UI requires it. For now, it's a hook. | |
| } | |
| // Clear history button listener | |
| const clearHistoryBtn = document.getElementById('clear-history-btn'); | |
| if (clearHistoryBtn) { | |
| clearHistoryBtn.addEventListener('click', () => { | |
| if (confirm("Are you sure you want to clear your local chat history?")) { | |
| clearHistory(); | |
| } | |
| }); | |
| } | |
| // ───────────────────────────────────────────────────────────────────────── | |
| function createResponseCard(title) { | |
| const card = document.createElement('div'); | |
| card.className = 'response-card'; | |
| card.innerHTML = ` | |
| <div class="response-header"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="ai-icon"> | |
| <path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/> | |
| <path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/> | |
| <path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/> | |
| </svg> | |
| <span>${title}</span> | |
| </div> | |
| <div class="response-content"><span class="typing-indicator"></span></div> | |
| <div class="metrics"> | |
| <div class="metrics-row"> | |
| <div class="token-badges"> | |
| <span class="badge prompt-badge" style="display:none;" title="Prompt Tokens">Prompt: --</span> | |
| <span class="badge completion-badge" style="display:none;" title="Completion Tokens">Response: --</span> | |
| </div> | |
| <button class="sources-toggle">View Sources ▼</button> | |
| </div> | |
| <div class="sources-list" style="display: none;"></div> | |
| </div> | |
| `; | |
| return card; | |
| } | |
| async function handleSend() { | |
| const query = promptInput.value.trim(); | |
| if (!query) return; | |
| const modes = getActiveRagModes(); | |
| if (!modes.vector && !modes.page && !modes.toc) { | |
| alert('Please enable at least one RAG mode.'); | |
| return; | |
| } | |
| promptInput.value = ''; | |
| promptInput.style.height = 'auto'; | |
| sendBtn.disabled = true; | |
| // Create User Message | |
| const userMsg = document.createElement('div'); | |
| userMsg.className = 'message user'; | |
| userMsg.innerHTML = `<div class="user-bubble">${query}</div>`; | |
| chatContainer.appendChild(userMsg); | |
| // Create Container for Responses | |
| const sysMsg = document.createElement('div'); | |
| sysMsg.className = 'message system'; | |
| const responsesGrid = document.createElement('div'); | |
| responsesGrid.className = 'system-responses'; | |
| // Create Cards only for enabled modes | |
| const vectorCard = modes.vector ? createResponseCard('Vector RAG') : null; | |
| const pageCard = modes.page ? createResponseCard('Page Index RAG') : null; | |
| const tocCard = modes.toc ? createResponseCard('TOC Index RAG') : null; | |
| if (vectorCard) responsesGrid.appendChild(vectorCard); | |
| if (pageCard) responsesGrid.appendChild(pageCard); | |
| if (tocCard) responsesGrid.appendChild(tocCard); | |
| sysMsg.appendChild(responsesGrid); | |
| chatContainer.appendChild(sysMsg); | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| const topK = parseInt(topKInput.value) || 5; | |
| // Trigger chosen SSE calls | |
| if (vectorCard) { | |
| const history = getHistory('vector'); | |
| fetchSSE('/api/chat/vector', { query, top_k: topK, chat_history: history }, vectorCard, 'vector', query); | |
| } | |
| if (pageCard) { | |
| const history = getHistory('page'); | |
| fetchSSE('/api/chat/page', { query, top_k: topK, chat_history: history }, pageCard, 'page', query); | |
| } | |
| if (tocCard) { | |
| const history = getHistory('toc'); | |
| fetchSSE('/api/chat/toc', { query, top_k: topK, chat_history: history }, tocCard, 'toc', query); | |
| } | |
| } | |
| async function fetchSSE(url, payload, cardElement, ragMode, userQuery) { | |
| const contentDiv = cardElement.querySelector('.response-content'); | |
| const tokenDisplay = cardElement.querySelector('.prompt-tokens'); | |
| const sourcesBtn = cardElement.querySelector('.sources-toggle'); | |
| const sourcesList = cardElement.querySelector('.sources-list'); | |
| sourcesBtn.addEventListener('click', () => { | |
| if (sourcesList.style.display === 'none') { | |
| sourcesList.style.display = 'block'; | |
| sourcesBtn.textContent = 'Hide Sources ▲'; | |
| } else { | |
| sourcesList.style.display = 'none'; | |
| sourcesBtn.textContent = 'View Sources ▼'; | |
| } | |
| }); | |
| try { | |
| const response = await fetch(url, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder("utf-8"); | |
| let fullText = ""; | |
| let buffer = ""; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const parts = buffer.split('\n\n'); | |
| buffer = parts.pop(); | |
| for (const part of parts) { | |
| if (part.startsWith('data: ')) { | |
| try { | |
| const dataStr = part.substring(6); | |
| if (!dataStr.trim()) continue; | |
| const data = JSON.parse(dataStr); | |
| if (data.type === 'token') { | |
| fullText += data.content; | |
| contentDiv.innerHTML = cleanMarkdown(fullText) + '<span class="typing-indicator"></span>'; | |
| } else if (data.type === 'sources') { | |
| // Render sources | |
| let sourcesHtml = ''; | |
| data.sources.forEach((s, idx) => { | |
| let label = s.chunk_index !== undefined ? `Chunk #${s.chunk_index}` : s.node_id !== undefined ? `Node ${s.node_id}: ${s.title || ''}` : `Page ${s.page_num}`; | |
| sourcesHtml += ` | |
| <div class="source-item"> | |
| <span class="source-meta">[${idx + 1}] ${s.source} — ${label} (score: ${s.score})</span> | |
| <span class="source-text">${s.text.substring(0, 150)}...</span> | |
| </div> | |
| `; | |
| }); | |
| sourcesList.innerHTML = sourcesHtml || '<div class="source-item">No sources found.</div>'; | |
| } else if (data.type === 'stats') { | |
| const promptBadge = cardElement.querySelector('.prompt-badge'); | |
| const completionBadge = cardElement.querySelector('.completion-badge'); | |
| if (promptBadge) { | |
| promptBadge.textContent = 'Prompt: ' + data.prompt_eval_count; | |
| promptBadge.style.display = 'inline-block'; | |
| } | |
| if (completionBadge) { | |
| completionBadge.textContent = 'Response: ' + data.eval_count; | |
| completionBadge.style.display = 'inline-block'; | |
| } | |
| fetchStats(); | |
| } else if (data.type === 'error') { | |
| fullText += `\n\n**Error:** ${data.content}`; | |
| contentDiv.innerHTML = cleanMarkdown(fullText); | |
| } | |
| } catch (e) { | |
| console.error('JSON parse error:', e, part); | |
| } | |
| } | |
| } | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| } | |
| // Remove typing indicator at the end | |
| contentDiv.innerHTML = cleanMarkdown(fullText); | |
| // Save to history once complete | |
| if (fullText.trim()) { | |
| appendToHistory(ragMode, userQuery, fullText.trim()); | |
| } | |
| } catch (error) { | |
| contentDiv.innerHTML = "<em>Connection error parsing stream.</em>"; | |
| console.error(error); | |
| } | |
| } | |
| // Modal Logic | |
| const chunksModal = document.getElementById('chunks-modal'); | |
| const modalTreeBtn = document.getElementById('modal-tree-btn'); | |
| const closeModalBtn = document.getElementById('close-modal-btn'); | |
| const modalDocTitle = document.getElementById('modal-doc-title'); | |
| const pageChunksList = document.getElementById('page-chunks-list'); | |
| const vectorChunksList = document.getElementById('vector-chunks-list'); | |
| const tocChunksList = document.getElementById('toc-chunks-list'); | |
| closeModalBtn.addEventListener('click', () => { | |
| chunksModal.style.display = 'none'; | |
| }); | |
| // Close if clicking outside | |
| window.addEventListener('click', (e) => { | |
| if (e.target === chunksModal) { | |
| chunksModal.style.display = 'none'; | |
| } | |
| }); | |
| async function openChunksModal(filename) { | |
| modalDocTitle.textContent = filename; | |
| pageChunksList.innerHTML = '<div class="spinner large-spinner"></div>'; | |
| vectorChunksList.innerHTML = '<div class="spinner large-spinner"></div>'; | |
| tocChunksList.innerHTML = '<div class="spinner large-spinner"></div>'; | |
| chunksModal.style.display = 'flex'; | |
| try { | |
| const docName = modalDocTitle.textContent; | |
| modalTreeBtn.onclick = () => openTreeModal(docName); | |
| const res = await fetch(`/api/documents/${encodeURIComponent(filename)}/chunks`); | |
| const data = await res.json(); | |
| // Render Page Chunks | |
| pageChunksList.innerHTML = ''; | |
| let totalPageTokens = 0; | |
| if (data.page_chunks && data.page_chunks.length > 0) { | |
| data.page_chunks.forEach(chunk => { | |
| const el = document.createElement('div'); | |
| el.className = 'chunk-item'; | |
| el.innerHTML = ` | |
| <span class="chunk-meta" style="display:flex; justify-content:space-between;"> | |
| <span>Page ${chunk.page_num}</span> | |
| <span style="color:var(--text-secondary); font-size:0.85em;">Tokens: ${chunk.tokens || 0}</span> | |
| </span> | |
| <div class="chunk-text">${chunk.text}</div> | |
| `; | |
| pageChunksList.appendChild(el); | |
| totalPageTokens += (chunk.tokens || 0); | |
| }); | |
| const totalEl = document.createElement('div'); | |
| totalEl.style.marginTop = '16px'; | |
| totalEl.style.padding = '12px'; | |
| totalEl.style.backgroundColor = 'var(--bg-secondary)'; | |
| totalEl.style.borderRadius = '6px'; | |
| totalEl.style.fontWeight = 'bold'; | |
| totalEl.style.display = 'flex'; | |
| totalEl.style.justifyContent = 'space-between'; | |
| totalEl.style.border = '1px solid var(--border-color)'; | |
| totalEl.innerHTML = `<span>Total Page RAG Tokens:</span> <span>${totalPageTokens.toLocaleString()}</span>`; | |
| pageChunksList.appendChild(totalEl); | |
| } else { | |
| pageChunksList.innerHTML = '<div style="padding:16px;">No page chunks found.</div>'; | |
| } | |
| // Render Vector Chunks | |
| vectorChunksList.innerHTML = ''; | |
| let totalVectorTokens = 0; | |
| if (data.vector_chunks && data.vector_chunks.length > 0) { | |
| data.vector_chunks.forEach(chunk => { | |
| const el = document.createElement('div'); | |
| el.className = 'chunk-item'; | |
| el.innerHTML = ` | |
| <span class="chunk-meta" style="display:flex; justify-content:space-between;"> | |
| <span>Chunk #${chunk.chunk_index}</span> | |
| <span style="color:var(--text-secondary); font-size:0.85em;">Tokens: ${chunk.tokens || 0}</span> | |
| </span> | |
| <div class="chunk-text">${chunk.text}</div> | |
| `; | |
| vectorChunksList.appendChild(el); | |
| totalVectorTokens += (chunk.tokens || 0); | |
| }); | |
| const totalEl = document.createElement('div'); | |
| totalEl.style.marginTop = '16px'; | |
| totalEl.style.padding = '12px'; | |
| totalEl.style.backgroundColor = 'var(--bg-secondary)'; | |
| totalEl.style.borderRadius = '6px'; | |
| totalEl.style.fontWeight = 'bold'; | |
| totalEl.style.display = 'flex'; | |
| totalEl.style.justifyContent = 'space-between'; | |
| totalEl.style.border = '1px solid var(--border-color)'; | |
| totalEl.innerHTML = `<span>Total Vector RAG Tokens:</span> <span>${totalVectorTokens.toLocaleString()}</span>`; | |
| vectorChunksList.appendChild(totalEl); | |
| } else { | |
| vectorChunksList.innerHTML = '<div style="padding:16px;">No vector chunks found.</div>'; | |
| } | |
| // Render TOC Chunks | |
| tocChunksList.innerHTML = ''; | |
| let totalTocTokens = 0; | |
| if (data.toc_chunks && data.toc_chunks.length > 0) { | |
| data.toc_chunks.forEach(chunk => { | |
| const el = document.createElement('div'); | |
| el.className = 'chunk-item'; | |
| el.innerHTML = ` | |
| <span class="chunk-meta" style="display:flex; justify-content:space-between;"> | |
| <span>Node ${chunk.node_id}: ${chunk.title || ''}</span> | |
| <span style="color:var(--text-secondary); font-size:0.85em;">Tokens: ${chunk.tokens || 0}</span> | |
| </span> | |
| <div class="chunk-text">${chunk.text}</div> | |
| `; | |
| tocChunksList.appendChild(el); | |
| totalTocTokens += (chunk.tokens || 0); | |
| }); | |
| const totalEl = document.createElement('div'); | |
| totalEl.style.marginTop = '16px'; | |
| totalEl.style.padding = '12px'; | |
| totalEl.style.backgroundColor = 'var(--bg-secondary)'; | |
| totalEl.style.borderRadius = '6px'; | |
| totalEl.style.fontWeight = 'bold'; | |
| totalEl.style.display = 'flex'; | |
| totalEl.style.justifyContent = 'space-between'; | |
| totalEl.style.border = '1px solid var(--border-color)'; | |
| totalEl.innerHTML = `<span>Total TOC RAG Tokens:</span> <span>${totalTocTokens.toLocaleString()}</span>`; | |
| tocChunksList.appendChild(totalEl); | |
| } else { | |
| tocChunksList.innerHTML = '<div style="padding:16px;">No TOC nodes found.</div>'; | |
| } | |
| } catch (error) { | |
| console.error(error); | |
| pageChunksList.innerHTML = '<div style="color:red; padding:16px;">Error loading data.</div>'; | |
| vectorChunksList.innerHTML = '<div style="color:red; padding:16px;">Error loading data.</div>'; | |
| tocChunksList.innerHTML = '<div style="color:red; padding:16px;">Error loading data.</div>'; | |
| } | |
| } | |
| // ── Tree Viewer Modal Logic ────────────────────────────────────────────── | |
| const treeModal = document.getElementById('tree-modal'); | |
| const closeTreeModalBtn = document.getElementById('close-tree-modal-btn'); | |
| const treeModalTitle = document.getElementById('tree-modal-title'); | |
| const treeContainer = document.getElementById('tree-container'); | |
| closeTreeModalBtn.addEventListener('click', () => { | |
| treeModal.style.display = 'none'; | |
| }); | |
| window.addEventListener('click', (e) => { | |
| if (e.target === treeModal) { | |
| treeModal.style.display = 'none'; | |
| } | |
| }); | |
| function renderTreeNode(node) { | |
| const hasChildren = node.nodes && node.nodes.length > 0; | |
| const hasSummary = !!node.summary; | |
| const el = document.createElement('div'); | |
| el.className = 'tree-node'; | |
| // Header row | |
| const header = document.createElement('div'); | |
| header.className = 'tree-node-header'; | |
| // Show pointer if clickable | |
| if (hasChildren || hasSummary) { | |
| header.style.cursor = 'pointer'; | |
| } else { | |
| header.style.cursor = 'default'; | |
| } | |
| // Toggle arrow | |
| const toggle = document.createElement('span'); | |
| toggle.className = 'tree-toggle' + (hasChildren ? '' : ' leaf'); | |
| toggle.textContent = '▶'; | |
| header.appendChild(toggle); | |
| // Node ID badge | |
| if (node.node_id) { | |
| const idBadge = document.createElement('span'); | |
| idBadge.className = 'tree-node-id'; | |
| idBadge.textContent = '#' + node.node_id; | |
| header.appendChild(idBadge); | |
| } | |
| // Title | |
| const title = document.createElement('span'); | |
| title.className = 'tree-node-title'; | |
| title.textContent = node.title || '(Untitled)'; | |
| header.appendChild(title); | |
| // Page range | |
| if (node.start_index != null) { | |
| const pages = document.createElement('span'); | |
| pages.className = 'tree-node-pages'; | |
| pages.textContent = node.start_index === node.end_index | |
| ? `p.${node.start_index}` | |
| : `p.${node.start_index}–${node.end_index}`; | |
| header.appendChild(pages); | |
| } | |
| el.appendChild(header); | |
| // Summary details (shown on expand) | |
| if (hasSummary) { | |
| const details = document.createElement('div'); | |
| details.className = 'tree-node-details'; | |
| details.innerHTML = `<strong style="color:var(--brand-color); display:block; margin-bottom:4px;">Summary:</strong>${node.summary}`; | |
| el.appendChild(details); | |
| } | |
| // Children container | |
| if (hasChildren) { | |
| const childrenContainer = document.createElement('div'); | |
| childrenContainer.className = 'tree-children'; | |
| node.nodes.forEach(child => { | |
| childrenContainer.appendChild(renderTreeNode(child)); | |
| }); | |
| el.appendChild(childrenContainer); | |
| } | |
| // Click to expand/collapse | |
| header.addEventListener('click', () => { | |
| if (!hasChildren && !hasSummary) return; | |
| const children = el.querySelector('.tree-children'); | |
| const details = el.querySelector('.tree-node-details'); | |
| const isExpanded = (children && children.classList.contains('expanded')) || | |
| (details && details.classList.contains('visible')); | |
| if (isExpanded) { | |
| if (children) { | |
| children.classList.remove('expanded'); | |
| toggle.classList.remove('expanded'); | |
| } | |
| if (details) details.classList.remove('visible'); | |
| } else { | |
| if (children) { | |
| children.classList.add('expanded'); | |
| toggle.classList.add('expanded'); | |
| } | |
| if (details) details.classList.add('visible'); | |
| } | |
| }); | |
| return el; | |
| } | |
| async function openTreeModal(filename) { | |
| treeModalTitle.textContent = `🌳 ${filename}`; | |
| treeContainer.innerHTML = '<div class="spinner large-spinner"></div>'; | |
| treeModal.style.display = 'flex'; | |
| try { | |
| const res = await fetch(`/api/documents/${encodeURIComponent(filename)}/tree`); | |
| const data = await res.json(); | |
| treeContainer.innerHTML = ''; | |
| if (!data.tree || data.tree.length === 0) { | |
| treeContainer.innerHTML = ` | |
| <div class="tree-no-data"> | |
| <div>🌳</div> | |
| <p>No tree structure available for this document.</p> | |
| <p style="font-size:0.8rem; margin-top:8px;">Index this document with TOC RAG mode to generate a tree.</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| data.tree.forEach(rootNode => { | |
| treeContainer.appendChild(renderTreeNode(rootNode)); | |
| }); | |
| // Auto-expand first level | |
| const firstLevelChildren = treeContainer.querySelectorAll(':scope > .tree-node > .tree-children'); | |
| firstLevelChildren.forEach(c => { | |
| c.classList.add('expanded'); | |
| const toggle = c.parentElement.querySelector('.tree-toggle'); | |
| if (toggle) toggle.classList.add('expanded'); | |
| const details = c.parentElement.querySelector('.tree-node-details'); | |
| if (details) details.classList.add('visible'); | |
| }); | |
| } catch (error) { | |
| console.error(error); | |
| treeContainer.innerHTML = '<div style="color:red; padding:16px;">Error loading tree data.</div>'; | |
| } | |
| } | |
| // Expose openTreeModal globally for inline onclick handlers | |
| window.openTreeModal = openTreeModal; | |
| }); | |