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 = '
No documents uploaded.
';
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 = `
`;
} else {
const iconClass = doc.type === 'pdf' ? 'icon-pdf' :
doc.type === 'docx' ? 'icon-docx' :
doc.type === 'txt' ? 'icon-txt' : 'icon-default';
iconHtml = `${doc.type.toUpperCase()}
`;
}
card.innerHTML = `
${iconHtml}
${doc.name}
${sizeKb} KB
`;
// 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 = ' 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 = ' 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 = `
Prompt: --
Response: --
`;
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 = `${query}
`;
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) + '';
} 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 += `
[${idx + 1}] ${s.source} — ${label} (score: ${s.score})
${s.text.substring(0, 150)}...
`;
});
sourcesList.innerHTML = sourcesHtml || 'No sources found.
';
} 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 = "Connection error parsing stream.";
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 = '';
vectorChunksList.innerHTML = '';
tocChunksList.innerHTML = '';
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 = `
Page ${chunk.page_num}
Tokens: ${chunk.tokens || 0}
${chunk.text}
`;
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 = `Total Page RAG Tokens: ${totalPageTokens.toLocaleString()}`;
pageChunksList.appendChild(totalEl);
} else {
pageChunksList.innerHTML = 'No page chunks found.
';
}
// 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 = `
Chunk #${chunk.chunk_index}
Tokens: ${chunk.tokens || 0}
${chunk.text}
`;
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 = `Total Vector RAG Tokens: ${totalVectorTokens.toLocaleString()}`;
vectorChunksList.appendChild(totalEl);
} else {
vectorChunksList.innerHTML = 'No vector chunks found.
';
}
// 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 = `
Node ${chunk.node_id}: ${chunk.title || ''}
Tokens: ${chunk.tokens || 0}
${chunk.text}
`;
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 = `Total TOC RAG Tokens: ${totalTocTokens.toLocaleString()}`;
tocChunksList.appendChild(totalEl);
} else {
tocChunksList.innerHTML = 'No TOC nodes found.
';
}
} catch (error) {
console.error(error);
pageChunksList.innerHTML = 'Error loading data.
';
vectorChunksList.innerHTML = 'Error loading data.
';
tocChunksList.innerHTML = 'Error loading data.
';
}
}
// ── 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 = `Summary:${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 = '';
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 = `
🌳
No tree structure available for this document.
Index this document with TOC RAG mode to generate a tree.
`;
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 = 'Error loading tree data.
';
}
}
// Expose openTreeModal globally for inline onclick handlers
window.openTreeModal = openTreeModal;
});