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.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();