DocsBot / app /templates /index.html
BabaK07's picture
Polish retrieval workflow and UI
d197c9d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DocsQA Assignment</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="bg-orb orb-one"></div>
<div class="bg-orb orb-two"></div>
<main class="shell">
{% if not user %}
<section class="hero card">
<div class="hero-topline">
<p class="eyebrow">LangGraph Assignment</p>
<span class="badge">FastAPI + Supabase + PGVector</span>
</div>
<h1>DocsQA Workspace</h1>
<p class="lede">
Upload PDFs, avoid duplicate reprocessing by file hash, and ask an agent that uses user-scoped document retrieval with optional web search.
</p>
<p class="developer-credit">Developed by Baba Kattubadi</p>
{% if db_unavailable %}
<p class="db-warning">
Database connection is temporarily unavailable. This is usually a transient DNS/network issue with the Supabase host. Please retry shortly.
</p>
{% endif %}
</section>
{% else %}
{% endif %}
{% if not user %}
<section class="grid two auth-grid">
<form class="card panel" id="register-form" method="post" action="/register">
<div class="panel-head">
<h2>Create account</h2>
<p>Start by creating your personal docs workspace.</p>
</div>
<label>Email <input type="email" name="email" required /></label>
<label>Password <input type="password" name="password" required /></label>
<button type="submit">Register</button>
<pre class="result ok" id="register-result"></pre>
</form>
<form class="card panel" id="login-form" method="post" action="/login">
<div class="panel-head">
<h2>Sign in</h2>
<p>Continue with your existing account.</p>
</div>
<label>Email <input type="email" name="email" required /></label>
<label>Password <input type="password" name="password" required /></label>
<button type="submit">Login</button>
<pre class="result ok" id="login-result"></pre>
</form>
</section>
{% else %}
<section class="app-layout">
<aside class="card panel sidebar-panel">
<div class="panel-head">
<p class="eyebrow">LangGraph Assignment</p>
<div class="sidebar-title-row">
<div>
<h1 class="sidebar-title">DocsQA Workspace</h1>
<p class="muted">Private document chat with structured sources.</p>
<p class="developer-credit">Developed by Baba Kattubadi</p>
</div>
<span class="badge">FastAPI + Supabase + PGVector</span>
</div>
</div>
<div class="panel-head account-head">
<h2 class="user-email">{{ user.email }}</h2>
<p class="muted">Your uploaded docs are private to this account.</p>
</div>
<form id="upload-form" class="panel">
<label class="muted">Upload PDFs (max 5 files, 10 pages each)</label>
<input type="file" id="file" name="file" accept="application/pdf" multiple required />
<button type="submit">Upload</button>
</form>
<pre class="result" id="upload-result"></pre>
<div class="panel-head panel-head-inline">
<h3>Your documents</h3>
<span class="badge">{{ documents|length }}</span>
</div>
<div class="docs sidebar-docs">
{% for document in documents %}
<article class="doc">
<header class="doc-head">
<h3>{{ document.filename }}</h3>
<span class="doc-pages">{{ document.page_count }} pages</span>
</header>
<div class="doc-actions">
<button
type="button"
class="danger doc-delete-btn"
data-document-id="{{ document.id }}"
data-document-name="{{ document.filename }}"
>
Delete
</button>
</div>
</article>
{% else %}
<p class="muted">No documents uploaded yet.</p>
{% endfor %}
</div>
<form method="post" action="/logout" id="logout-form">
<button type="submit" class="secondary">Sign out</button>
</form>
</aside>
<section class="card panel chat-shell chat-panel">
<div class="panel-head panel-head-inline">
<h2>DocsQA Chat</h2>
<div style="display: flex; gap: 8px; align-items: center;">
<button type="button" id="new-chat-btn" class="secondary" style="font-size: 0.875rem; padding: 6px 12px;">New Chat</button>
<span class="badge">Markdown enabled</span>
</div>
</div>
<div id="chat-thread" class="chat-thread">
<article class="chat-msg assistant">
<div class="chat-bubble chat-bubble-assistant chat-markdown">
<p>Ask anything about your uploaded PDFs and I will answer with citations from retrieved chunks.</p>
</div>
</article>
</div>
<form id="ask-form" class="chat-composer">
<textarea
id="query"
rows="3"
placeholder="Message DocsQA..."
required
></textarea>
<button type="submit">Send</button>
</form>
</section>
</section>
{% endif %}
</main>
<script>
// Session management
let currentSessionId = sessionStorage.getItem("chat_session_id") || `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
sessionStorage.setItem("chat_session_id", currentSessionId);
const chatStorageKey = () => `chat_thread_${currentSessionId}`;
const registerForm = document.getElementById("register-form");
const loginForm = document.getElementById("login-form");
const logoutForm = document.getElementById("logout-form");
const registerResult = document.getElementById("register-result");
const loginResult = document.getElementById("login-result");
const uploadForm = document.getElementById("upload-form");
const askForm = document.getElementById("ask-form");
const uploadResult = document.getElementById("upload-result");
const chatThread = document.getElementById("chat-thread");
const queryInput = document.getElementById("query");
const docDeleteButtons = document.querySelectorAll(".doc-delete-btn");
const newChatBtn = document.getElementById("new-chat-btn");
const saveChatThread = () => {
if (!chatThread) return;
sessionStorage.setItem(chatStorageKey(), chatThread.innerHTML);
};
const restoreChatThread = () => {
if (!chatThread) return;
const savedThread = sessionStorage.getItem(chatStorageKey());
if (savedThread) {
chatThread.innerHTML = savedThread;
}
};
const resetChatThread = () => {
if (!chatThread) return;
chatThread.innerHTML = `
<article class="chat-msg assistant">
<div class="chat-bubble chat-bubble-assistant chat-markdown">
<p>Ask anything about your uploaded PDFs and I will answer with citations from retrieved chunks.</p>
</div>
</article>
`;
saveChatThread();
};
restoreChatThread();
// New Chat button handler
newChatBtn?.addEventListener("click", () => {
currentSessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
sessionStorage.setItem("chat_session_id", currentSessionId);
resetChatThread();
});
const safeJson = async (response) => {
try {
return await response.json();
} catch {
return { detail: "Unexpected non-JSON response" };
}
};
const prettyError = (body) => {
if (!body) return "Request failed.";
if (typeof body.detail === "string") return body.detail;
if (typeof body.message === "string") return body.message;
return "Request failed.";
};
const setBusy = (form, busy) => {
if (!form) return;
const button = form.querySelector("button[type='submit']");
if (!button) return;
button.disabled = busy;
if (busy) {
button.dataset.originalText = button.textContent;
button.textContent = "Please wait...";
} else if (button.dataset.originalText) {
button.textContent = button.dataset.originalText;
}
};
const escapeHtml = (value) =>
value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
const normalizeTabularAnswer = (text) => {
const lines = text.split("\n");
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.includes("\t")) {
out.push(line);
i += 1;
continue;
}
const rows = [];
while (i < lines.length && lines[i].includes("\t")) {
rows.push(lines[i].split("\t").map((cell) => cell.trim()));
i += 1;
}
if (rows.length < 2) {
out.push(...rows.map((row) => row.join(" ")));
continue;
}
const columnCount = Math.max(...rows.map((row) => row.length));
const paddedRows = rows.map((row) => {
const copy = [...row];
while (copy.length < columnCount) copy.push("");
return copy.map((cell) => cell.replaceAll("|", "\\|").replaceAll("\n", "<br>"));
});
out.push(`| ${paddedRows[0].join(" | ")} |`);
out.push(`| ${Array(columnCount).fill("---").join(" | ")} |`);
for (const row of paddedRows.slice(1)) {
out.push(`| ${row.join(" | ")} |`);
}
}
return out.join("\n");
};
const sanitizeHtml = (html) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
doc.querySelectorAll("script,style,iframe,object,embed").forEach((node) => node.remove());
for (const el of doc.querySelectorAll("*")) {
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
const value = attr.value.toLowerCase();
if (name.startsWith("on")) {
el.removeAttribute(attr.name);
}
if ((name === "href" || name === "src") && value.startsWith("javascript:")) {
el.removeAttribute(attr.name);
}
}
}
return doc.body.innerHTML;
};
const renderMarkdown = (text) => {
const normalized = normalizeTabularAnswer(text || "");
if (window.marked?.parse) {
const html = window.marked.parse(normalized, { gfm: true, breaks: true });
return sanitizeHtml(html);
}
return `<pre>${escapeHtml(normalized)}</pre>`;
};
const stripSourceStatusLines = (text) => {
if (!text) return "";
const withoutSourcesSection = text.replace(/\n+\s*(?:#+\s*)?sources\s*:?\s*\n[\s\S]*$/i, "").trim();
return withoutSourcesSection
.split("\n")
.filter((line) => !/^\s*no sources were used for this response\.?\s*$/i.test(line.trim()))
.filter((line) => !/^\s*no citations available for this turn\.?\s*$/i.test(line.trim()))
.join("\n")
.trim();
};
const buildSourcesMarkdown = (sources) => {
const vectorSources = Array.isArray(sources?.vector) ? sources.vector : [];
const webSources = Array.isArray(sources?.web) ? sources.web : [];
const lines = [];
if (vectorSources.length) {
lines.push("### Document Sources");
for (const src of vectorSources) {
const doc = src.document || "Unknown document";
const page = src.page || "unknown";
const excerpt = src.excerpt || "";
lines.push(`- **${doc}**, page **${page}**`);
if (excerpt) {
lines.push(` - "${excerpt}"`);
}
}
}
if (webSources.length) {
if (lines.length) lines.push("");
lines.push("### Web Sources");
for (const src of webSources) {
const title = src.title || "Untitled source";
const url = src.url || "";
if (url && url !== "N/A") {
lines.push(`- [${title}](${url})`);
} else {
lines.push(`- ${title}`);
}
}
}
if (lines.length) return lines.join("\n");
return "_No citations available for this turn._";
};
const renderSourcesHtml = (sources, queryText = "") => {
const vectorSources = Array.isArray(sources?.vector) ? sources.vector : [];
const webSources = Array.isArray(sources?.web) ? sources.web : [];
const sections = [];
if (vectorSources.length) {
const vectorCards = vectorSources
.map((src) => {
const documentId = (src.document_id || "").toString().trim();
const doc = escapeHtml(src.document || "Unknown document");
const page = escapeHtml(src.page || "unknown");
const excerptHtml = escapeHtml(src.excerpt || "");
const pageNumber = Number.parseInt(src.page || "", 10);
const pageAnchor = Number.isFinite(pageNumber) && pageNumber > 0 ? `#page=${pageNumber}` : "";
const pdfUrl = documentId ? `/documents/${encodeURIComponent(documentId)}/pdf${pageAnchor}` : "";
return `
<article class="source-card">
<div class="source-meta">
<span class="source-doc">${doc}</span>
<div class="source-meta-right">
<span class="source-page">Page ${page}</span>
</div>
</div>
<p class="source-excerpt">${excerptHtml || "No excerpt available."}</p>
${
pdfUrl
? `<div class="source-actions"><a class="source-link" href="${pdfUrl}" target="_blank" rel="noopener noreferrer">Open PDF</a></div>`
: ""
}
</article>
`;
})
.join("");
sections.push(`<section><h4>Document Sources</h4><div class="source-list">${vectorCards}</div></section>`);
}
if (webSources.length) {
const webItems = webSources
.map((src) => {
const title = escapeHtml(src.title || "Untitled source");
const url = src.url || "";
if (url && url !== "N/A") {
return `<li><a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${title}</a></li>`;
}
return `<li>${title}</li>`;
})
.join("");
sections.push(`<section><h4>Web Sources</h4><ul class="source-web-list">${webItems}</ul></section>`);
}
if (!sections.length) {
return `<p class="muted">No citations available for this turn.</p>`;
}
return sections.join("");
};
const renderAssistantResponse = (container, fullAnswerText, sourceDict = null, queryText = "") => {
if (!container) return;
const answerContent = stripSourceStatusLines(fullAnswerText) || "Response received.";
const sourcesContent = renderSourcesHtml(sourceDict, queryText);
container.innerHTML = "";
const answerPanel = document.createElement("div");
answerPanel.className = "message-panel";
answerPanel.innerHTML = renderMarkdown(answerContent);
container.appendChild(answerPanel);
const details = document.createElement("details");
details.className = "source-dropdown";
const summary = document.createElement("summary");
summary.textContent = "Sources";
const body = document.createElement("div");
body.className = "sources-panel";
body.innerHTML = sourcesContent;
details.appendChild(summary);
details.appendChild(body);
container.appendChild(details);
};
const readStreamingAnswer = async ({ query, target }) => {
const response = await fetch("/ask/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Session-Id": currentSessionId
},
body: JSON.stringify({ query }),
});
if (!response.ok || !response.body) {
const body = await safeJson(response);
throw new Error(prettyError(body));
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let answerText = "";
let sources = null;
const processEvent = (rawEvent) => {
const lines = rawEvent.split("\n");
let eventName = "message";
const dataLines = [];
for (const line of lines) {
if (line.startsWith("event:")) {
eventName = line.slice(6).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trim());
}
}
if (!dataLines.length) return;
const payload = JSON.parse(dataLines.join("\n"));
if (eventName === "token") {
answerText += payload.content || "";
target.classList.remove("chat-pending");
target.classList.add("chat-markdown");
target.innerHTML = renderMarkdown(answerText || "Thinking...");
chatThread.scrollTop = chatThread.scrollHeight;
return;
}
if (eventName === "sources") {
sources = payload.sources || null;
return;
}
if (eventName === "done") {
answerText = payload.answer || answerText || "Response received.";
target.classList.remove("chat-pending");
target.classList.add("chat-markdown");
renderAssistantResponse(target, answerText, sources, query);
chatThread.scrollTop = chatThread.scrollHeight;
return;
}
if (eventName === "error") {
throw new Error(payload.detail || "Streaming failed.");
}
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop() || "";
for (const rawEvent of events) {
if (rawEvent.trim()) processEvent(rawEvent);
}
}
buffer += decoder.decode();
if (buffer.trim()) processEvent(buffer);
return { answer: answerText, sources };
};
const appendMessage = ({ role, text, markdown = false, pending = false, isError = false }) => {
if (!chatThread) return null;
const row = document.createElement("article");
row.className = `chat-msg ${role}`;
const bubble = document.createElement("div");
bubble.className = `chat-bubble ${role === "user" ? "chat-bubble-user" : "chat-bubble-assistant"}`;
if (markdown) bubble.classList.add("chat-markdown");
if (pending) bubble.classList.add("chat-pending");
if (isError) bubble.classList.add("chat-error");
if (markdown) {
bubble.innerHTML = renderMarkdown(text);
} else {
bubble.textContent = text;
}
row.appendChild(bubble);
chatThread.appendChild(row);
chatThread.scrollTop = chatThread.scrollHeight;
saveChatThread();
return bubble;
};
registerForm?.addEventListener("submit", async (event) => {
event.preventDefault();
setBusy(registerForm, true);
const formData = new FormData(registerForm);
const response = await fetch("/register", { method: "POST", body: formData });
const body = await safeJson(response);
registerResult.textContent = response.ok
? "Registration successful. Redirecting to your workspace..."
: prettyError(body);
registerResult.classList.toggle("error", !response.ok);
setBusy(registerForm, false);
if (response.ok) {
window.location.reload();
}
});
loginForm?.addEventListener("submit", async (event) => {
event.preventDefault();
setBusy(loginForm, true);
const formData = new FormData(loginForm);
const response = await fetch("/login", { method: "POST", body: formData });
const body = await safeJson(response);
loginResult.textContent = response.ok
? "Login successful. Redirecting..."
: prettyError(body);
loginResult.classList.toggle("error", !response.ok);
setBusy(loginForm, false);
if (response.ok) {
window.location.reload();
}
});
logoutForm?.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch("/logout", { method: "POST" });
if (response.ok) {
sessionStorage.removeItem(chatStorageKey());
window.location.reload();
}
});
uploadForm?.addEventListener("submit", async (event) => {
event.preventDefault();
setBusy(uploadForm, true);
const formData = new FormData();
const fileInput = document.getElementById("file");
const files = Array.from(fileInput?.files || []);
if (!files.length) {
uploadResult.textContent = "Please choose at least one PDF.";
uploadResult.classList.add("error");
setBusy(uploadForm, false);
return;
}
for (const file of files) {
formData.append("file", file);
}
const response = await fetch("/upload", { method: "POST", body: formData });
const body = await safeJson(response);
if (response.ok) {
const createdCount = (body.documents || []).filter((doc) => doc.created).length;
const reusedCount = (body.documents || []).length - createdCount;
uploadResult.textContent =
`Uploaded ${body.count} file(s). ${createdCount} indexed, ${reusedCount} reused.\n` +
(body.documents || [])
.map((doc) => `- ${doc.filename} (${doc.page_count} pages)`)
.join("\n");
} else {
uploadResult.textContent = prettyError(body);
}
uploadResult.classList.toggle("error", !response.ok);
setBusy(uploadForm, false);
if (response.ok) {
saveChatThread();
window.location.reload();
}
});
docDeleteButtons.forEach((button) => {
button.addEventListener("click", async () => {
const documentId = button.dataset.documentId;
const documentName = button.dataset.documentName || "this document";
if (!documentId) return;
const confirmed = window.confirm(`Delete ${documentName} from your documents?`);
if (!confirmed) return;
button.disabled = true;
const response = await fetch(`/documents/${documentId}`, { method: "DELETE" });
const body = await safeJson(response);
if (!response.ok) {
button.disabled = false;
uploadResult.textContent = prettyError(body);
uploadResult.classList.add("error");
return;
}
window.location.reload();
});
});
askForm?.addEventListener("submit", async (event) => {
event.preventDefault();
const query = queryInput?.value?.trim() || "";
if (!query) return;
appendMessage({ role: "user", text: query });
if (queryInput) queryInput.value = "";
setBusy(askForm, true);
const pendingBubble = appendMessage({ role: "assistant", text: "Thinking...", markdown: false, pending: true });
const target = pendingBubble || appendMessage({ role: "assistant", text: "", markdown: false });
if (!target) {
setBusy(askForm, false);
return;
}
try {
await readStreamingAnswer({ query, target });
} catch (error) {
const message = error instanceof Error ? error.message : "Request failed.";
target.classList.remove("chat-pending");
target.classList.add("chat-error");
target.textContent = message;
} finally {
chatThread.scrollTop = chatThread.scrollHeight;
saveChatThread();
setBusy(askForm, false);
}
});
queryInput?.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
askForm?.requestSubmit();
}
});
</script>
</body>
</html>