const state = { documents: [], selectedDocumentIds: [], conversationId: null, messages: [], sources: [], retrievalMode: "latency", }; const uploadForm = document.getElementById("upload-form"); const fileInput = document.getElementById("file-input"); const uploadStatus = document.getElementById("upload-status"); const documentsList = document.getElementById("documents-list"); const refreshDocumentsButton = document.getElementById("refresh-documents"); const selectedDocumentTitle = document.getElementById("selected-document-title"); const selectedDocumentMeta = document.getElementById("selected-document-meta"); const questionForm = document.getElementById("question-form"); const questionInput = document.getElementById("question-input"); const questionStatus = document.getElementById("question-status"); const questionSubmitButton = document.getElementById("question-submit"); const chatMessages = document.getElementById("chat-messages"); const sourcesList = document.getElementById("sources-list"); const conversationChip = document.getElementById("conversation-chip"); const modeToggleButtons = Array.from(document.querySelectorAll("[data-retrieval-mode]")); let pendingStatusTimer = null; async function api(path, options = {}) { const response = await fetch(path, options); if (!response.ok) { const error = await response.json().catch(() => ({ detail: "Request failed." })); throw new Error(error.detail || "Request failed."); } if (response.status === 204) { return null; } return response.json(); } async function fetchDocuments() { state.documents = await api("/documents"); syncSelectedDocuments(); renderDocuments(); renderSelectionSummary(); maybePollStatuses(); } function syncSelectedDocuments() { const availableIds = new Set(state.documents.map((document) => document.id)); state.selectedDocumentIds = state.selectedDocumentIds.filter((documentId) => availableIds.has(documentId)); } function renderDocuments() { if (!state.documents.length) { documentsList.innerHTML = `

No documents yet. Upload one to start indexing.

`; return; } documentsList.innerHTML = state.documents .map((document) => { const selected = state.selectedDocumentIds.includes(document.id); const selectedClass = selected ? "active" : ""; const buttonClass = selected ? "secondary-button" : ""; const buttonLabel = selected ? "Selected" : "Select"; return `
${escapeHtml(document.title || document.filename)} ${escapeHtml(document.status)}

${escapeHtml(document.filename)}

Stage: ${escapeHtml(document.processing_stage)} • ${document.progress_percent}%

Chunks: ${document.chunk_count}

${document.error_message ? `

Error: ${escapeHtml(document.error_message)}

` : ""}
`; }) .join(""); documentsList.querySelectorAll("button[data-document-id]").forEach((button) => { button.addEventListener("click", () => toggleDocumentSelection(button.dataset.documentId)); }); } function toggleDocumentSelection(documentId) { if (!documentId) { return; } if (state.selectedDocumentIds.includes(documentId)) { state.selectedDocumentIds = state.selectedDocumentIds.filter((value) => value !== documentId); } else { state.selectedDocumentIds = [...state.selectedDocumentIds, documentId]; } resetConversationState(); renderDocuments(); renderSelectionSummary(); renderMessages(); renderSources(); } function selectUploadedDocument(documentId) { state.selectedDocumentIds = documentId ? [documentId] : []; resetConversationState(); renderDocuments(); renderSelectionSummary(); renderMessages(); renderSources(); } function resetConversationState() { state.conversationId = null; state.messages = []; state.sources = []; clearPendingStatusTimer(); questionStatus.textContent = buildModeHelperText(); conversationChip.textContent = "New conversation"; } function getSelectedDocuments() { const documentsById = new Map(state.documents.map((document) => [document.id, document])); return state.selectedDocumentIds.map((documentId) => documentsById.get(documentId)).filter(Boolean); } function renderSelectionSummary() { const selectedDocuments = getSelectedDocuments(); if (!selectedDocuments.length) { selectedDocumentTitle.textContent = "Select one or more documents to begin"; selectedDocumentMeta.textContent = "Upload a PDF or DOCX file, then select one or more processed documents from the library."; return; } if (selectedDocuments.length === 1) { const [document] = selectedDocuments; selectedDocumentTitle.textContent = document.title || document.filename; selectedDocumentMeta.textContent = `${document.filename} • ${document.processing_stage} • ${document.progress_percent}% complete`; return; } const readyCount = selectedDocuments.filter((document) => document.status === "ready").length; selectedDocumentTitle.textContent = `${selectedDocuments.length} documents selected`; selectedDocumentMeta.textContent = `${summarizeDocumentNames(selectedDocuments)} • ${readyCount}/${selectedDocuments.length} ready`; } function summarizeDocumentNames(documents) { const labels = documents.map((document) => document.title || document.filename); if (labels.length <= 2) { return labels.join(" • "); } return `${labels.slice(0, 2).join(" • ")} • +${labels.length - 2} more`; } function renderMessages() { if (!state.messages.length) { const selectedCount = state.selectedDocumentIds.length; const prompt = selectedCount > 0 ? `This will create a conversation across ${selectedCount} selected document${selectedCount === 1 ? "" : "s"} automatically.` : "Choose one or more documents first."; chatMessages.innerHTML = `

Ask the first question

${escapeHtml(prompt)}

`; return; } chatMessages.innerHTML = state.messages .map( (message) => `
${escapeHtml(message.role)}
${escapeHtml(message.content)}
` ) .join(""); chatMessages.scrollTop = chatMessages.scrollHeight; } function renderSources() { if (!state.sources.length) { sourcesList.innerHTML = `

Grounded source excerpts will show here.

`; return; } sourcesList.innerHTML = state.sources .map( (source) => `

${escapeHtml(source.document_title || source.document_filename || "Document source")}

${escapeHtml(source.section_title || "General")}

Chunk: ${escapeHtml(source.chunk_id)} Page: ${source.page_number ?? "n/a"} Score: ${source.score}

${escapeHtml(source.excerpt)}

` ) .join(""); } function maybePollStatuses() { const hasPending = state.documents.some((document) => document.status === "queued" || document.status === "processing"); if (!hasPending) { return; } window.clearTimeout(window.__documentPoller); window.__documentPoller = window.setTimeout(fetchDocuments, 3000); } uploadForm.addEventListener("submit", async (event) => { event.preventDefault(); const [file] = fileInput.files; if (!file) return; uploadStatus.textContent = "Uploading and queueing document..."; const formData = new FormData(); formData.append("file", file); try { const response = await api("/documents", { method: "POST", body: formData }); uploadStatus.textContent = `Queued ${file.name}. Task ${response.task_id}`; fileInput.value = ""; await fetchDocuments(); selectUploadedDocument(response.document_id); } catch (error) { uploadStatus.textContent = error.message; } }); refreshDocumentsButton.addEventListener("click", fetchDocuments); questionForm.addEventListener("submit", async (event) => { event.preventDefault(); if (questionSubmitButton.disabled) { return; } if (!state.selectedDocumentIds.length) { questionStatus.textContent = "Select one or more documents first."; return; } const question = questionInput.value.trim(); if (!question) return; setQuestionPending(true); state.messages.push({ role: "user", content: question }); renderMessages(); const isMultiDocumentFirstTurn = !state.conversationId && state.selectedDocumentIds.length > 1; const path = state.conversationId ? `/conversations/${state.conversationId}/questions` : isMultiDocumentFirstTurn ? "/questions" : `/documents/${state.selectedDocumentIds[0]}/questions`; const payload = state.conversationId ? { question, retrieval_mode: state.retrievalMode } : isMultiDocumentFirstTurn ? { question, document_ids: state.selectedDocumentIds, retrieval_mode: state.retrievalMode } : { question, conversation_id: null, retrieval_mode: state.retrievalMode }; try { const response = await api(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); state.conversationId = response.conversation_id; state.messages.push({ role: "assistant", content: response.answer }); state.sources = response.sources; conversationChip.textContent = `Conversation ${response.conversation_id.slice(0, 8)} • ${state.selectedDocumentIds.length} doc${state.selectedDocumentIds.length === 1 ? "" : "s"}`; questionStatus.textContent = response.status === "answered" ? buildAnsweredStatus(response.sources, response.retrieval_mode || state.retrievalMode) : "No grounded answer found in the selected document set."; questionInput.value = ""; renderMessages(); renderSources(); } catch (error) { state.messages.push({ role: "assistant", content: error.message }); questionStatus.textContent = error.message; renderMessages(); } finally { setQuestionPending(false); } }); modeToggleButtons.forEach((button) => { button.addEventListener("click", () => { state.retrievalMode = button.dataset.retrievalMode || "latency"; renderRetrievalMode(); questionStatus.textContent = buildModeHelperText(); }); }); function renderRetrievalMode() { modeToggleButtons.forEach((button) => { button.classList.toggle("active", button.dataset.retrievalMode === state.retrievalMode); }); } function buildModeHelperText() { return state.retrievalMode === "quality" ? "High Quality mode uses deeper evidence review and may take longer." : "Fast mode uses lower-latency retrieval. Switch to High Quality for deeper evidence review."; } function buildPendingStatusText() { return state.retrievalMode === "quality" ? "Running deeper retrieval and evidence review..." : "Retrieving evidence and generating answer..."; } function buildSlowPendingStatusText() { return state.retrievalMode === "quality" ? "Still working. High Quality mode can take around 15 to 30 seconds on the local model." : "Still working. Fast mode is taking longer than usual on the local model."; } function clearPendingStatusTimer() { if (pendingStatusTimer) { window.clearTimeout(pendingStatusTimer); pendingStatusTimer = null; } } function setQuestionPending(isPending) { clearPendingStatusTimer(); questionInput.disabled = isPending; questionSubmitButton.disabled = isPending; modeToggleButtons.forEach((button) => { button.disabled = isPending; }); questionSubmitButton.textContent = isPending ? "Working..." : "Ask Question"; if (isPending) { questionStatus.textContent = buildPendingStatusText(); pendingStatusTimer = window.setTimeout(() => { questionStatus.textContent = buildSlowPendingStatusText(); }, state.retrievalMode === "quality" ? 8000 : 5000); return; } if (!questionStatus.textContent || questionStatus.textContent === buildPendingStatusText() || questionStatus.textContent === buildSlowPendingStatusText()) { questionStatus.textContent = buildModeHelperText(); } } function buildAnsweredStatus(sources, retrievalMode) { const distinctDocuments = new Set( sources .map((source) => source.document_id || source.document_filename || source.document_title) .filter(Boolean) ); const modeLabel = retrievalMode === "quality" ? "High Quality" : "Fast"; return `${modeLabel} mode answered using ${sources.length} source chunk(s) from ${Math.max(distinctDocuments.size, 1)} document(s).`; } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } fetchDocuments().catch((error) => { uploadStatus.textContent = error.message; }); renderRetrievalMode(); questionStatus.textContent = buildModeHelperText();