Agent Template
Document Q&A
Upload a PDF or text file and ask questions answered only from that document — fully in your browser, nothing uploaded to a server.
documents
pdf
rag
q&a
privacy
ozzo
Jul 10, 2026
1 use
Preview Mode
This is a preview with sample data. The template uses placeholders like
which will be replaced with actual agent data.
About This Template
Document Q&A is a browser-executable AI agent template built on AgentOp. It runs entirely in the browser using Python (via Pyodide) and can be deployed without a server — just download the generated HTML file and open it locally or host it anywhere.
Topics
documents
pdf
rag
q&a
privacy
Template Preview
Template Metadata
- Slug
- document-qa
- Created By
- ozzo
- Created
- Jul 10, 2026
- Usage Count
- 1
Tags
documents
pdf
rag
q&a
privacy
Code Statistics
- HTML Lines
- 34
- CSS Lines
- 29
- JS Lines
- 78
- Python Lines
- 50
Source Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ agent_name }}</title>
<style>{{ css_code }}</style>
</head>
<body>
<main class="doc-qa">
<header class="doc-qa__head">
<h1>{{ agent_name }}</h1>
<p class="doc-qa__sub">{{ description }}</p>
</header>
<section class="doc-qa__upload">
<label class="doc-qa__drop" for="doc-file">
<input id="doc-file" type="file" accept=".pdf,.txt,.md,.markdown" multiple hidden>
<span>📄 Upload a PDF or text file, then ask questions about it</span>
</label>
<div id="doc-status" class="doc-qa__status" role="status"></div>
</section>
<section id="results-container" class="doc-qa__chat" aria-live="polite"></section>
<form id="doc-qa-form" class="doc-qa__form" autocomplete="off">
<input id="doc-qa-input" type="text"
placeholder="Ask a question about your document…" required disabled>
<button id="doc-qa-send" type="submit" disabled>Ask</button>
</form>
</main>
<script>{{ js_code }}</script>
</body>
</html>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #f7f7f8; color: #1a1a1a; }
.doc-qa { max-width: 760px; margin: 0 auto; padding: 24px 16px 96px; }
.doc-qa__head h1 { font-size: 1.6rem; margin: 0 0 4px; }
.doc-qa__sub { margin: 0 0 20px; color: #666; }
.doc-qa__drop { display: block; border: 2px dashed #c7c7cc; border-radius: 12px;
padding: 22px; text-align: center; cursor: pointer; background: #fff;
color: #444; transition: border-color .15s, background .15s; }
.doc-qa__drop:hover { border-color: #6366f1; background: #f5f5ff; }
.doc-qa__status { font-size: .85rem; color: #555; margin: 10px 2px; min-height: 1.2em; }
.doc-qa__chat { display: flex; flex-direction: column; gap: 10px; margin: 8px 0 16px; }
.doc-qa__msg { padding: 10px 14px; border-radius: 12px; max-width: 85%;
white-space: pre-wrap; line-height: 1.5; }
.doc-qa__msg--user { align-self: flex-end; background: #6366f1; color: #fff; }
.doc-qa__msg--assistant { align-self: flex-start; background: #fff; border: 1px solid #eee; }
.doc-qa__form { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 8px;
padding: 12px 16px; background: #fff; border-top: 1px solid #eee; }
.doc-qa__form input { flex: 1; padding: 12px 14px; border: 1px solid #d0d0d5;
border-radius: 10px; font-size: 1rem; }
.doc-qa__form button { padding: 0 20px; border: 0; border-radius: 10px;
background: #6366f1; color: #fff; font-weight: 600; cursor: pointer; }
.doc-qa__form button:disabled { background: #b7b7c6; cursor: not-allowed; }
@media (prefers-color-scheme: dark) {
body { background: #16161a; color: #ececf1; }
.doc-qa__drop, .doc-qa__msg--assistant, .doc-qa__form { background: #202028; border-color: #33333c; }
.doc-qa__form input { background: #16161a; color: #ececf1; border-color: #33333c; }
}
// Document Q&A UI wiring. The RAG/embeddings/wllama infrastructure is injected
// by the generator; this only connects the upload + chat controls to it.
(function () {
const statusEl = () => document.getElementById('doc-status');
const results = () => document.getElementById('results-container');
const input = () => document.getElementById('doc-qa-input');
const sendBtn = () => document.getElementById('doc-qa-send');
let pyReady = false;
// Render helper the wllama query bridge also calls for streamed responses.
window.addMessage = function (type, content) {
const el = document.createElement('div');
el.className = 'doc-qa__msg doc-qa__msg--' + (type === 'user' ? 'user' : 'assistant');
el.textContent = content;
results().appendChild(el);
el.scrollIntoView({ behavior: 'smooth', block: 'end' });
return el;
};
function setStatus(msg) { statusEl().textContent = msg; }
function enableChat(on) {
input().disabled = !on;
sendBtn().disabled = !on;
}
async function readFileText(file) {
if (/\.pdf$/i.test(file.name)) {
const buf = new Uint8Array(await file.arrayBuffer());
window.pyodide.globals.set('__pdf_bytes', buf);
return await window.pyodide.runPythonAsync('_extract_pdf_text(bytes(__pdf_bytes.to_py()))');
}
return await file.text();
}
async function ingest(file) {
setStatus('Reading "' + file.name + '"…');
const text = await readFileText(file);
window.pyodide.globals.set('__doc_src', file.name);
window.pyodide.globals.set('__doc_text', text);
const n = await window.pyodide.runPythonAsync('await _ingest_document(__doc_src, __doc_text)');
setStatus('Indexed ' + n + ' passages from "' + file.name + '". Ask away!');
enableChat(true);
}
document.getElementById('doc-file').addEventListener('change', async (e) => {
if (!pyReady) { setStatus('Still starting up—one moment…'); return; }
for (const file of e.target.files) {
try { await ingest(file); }
catch (err) { setStatus('Could not read "' + file.name + '": ' + err); }
}
});
document.getElementById('doc-qa-form').addEventListener('submit', async (e) => {
e.preventDefault();
const q = input().value.trim();
if (!q || !pyReady) return;
window.addMessage('user', q);
input().value = '';
enableChat(false);
try {
window.pyodide.globals.set('__q', q);
const answer = await window.pyodide.runPythonAsync('await process_user_query(__q)');
if (answer) window.addMessage('assistant', answer);
} catch (err) {
window.addMessage('assistant', 'Sorry, something went wrong: ' + err);
} finally {
enableChat(true);
input().focus();
}
});
// Pyodide is ready after initAgent(); the orchestrator fires this on document.
document.addEventListener('pyodide-ready', () => {
pyReady = true;
setStatus('Ready. Upload a document to begin.');
});
})();
import json
# How many document passages to retrieve per question (tunable at generation).
RAG_TOP_K = [[[RAG_TOP_K|4]]]
async def _ingest_document(source, text):
"""(JS-callable) Chunk + embed + index one document's text.
Underscore-prefixed so it is never exposed to the LLM as a tool.
Returns the number of chunks stored, as a string (for the UI).
"""
count = await agentop_rag.add_document(source, text)
return str(count)
def _extract_pdf_text(pdf_bytes):
"""(JS-callable) Extract plain text from a PDF's raw bytes via pypdf."""
import io
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(pdf_bytes))
return "\n\n".join((page.extract_text() or "") for page in reader.pages)
async def process_user_query(query):
"""RAG-first answering: retrieve the most relevant passages from the uploaded
document(s), then answer grounded ONLY in them.
Overrides the default router so retrieval is deterministic (small local
models are unreliable at deciding to call a search tool themselves).
"""
hits = await agentop_rag.search(query, RAG_TOP_K)
if not hits:
context = "(No document has been uploaded yet, or nothing relevant was found.)"
else:
context = "\n\n".join(
f"[{i + 1}] (from {h['source']}) {h['text']}" for i, h in enumerate(hits)
)
grounded_prompt = (
"Answer the QUESTION using ONLY the CONTEXT passages below. "
"If the answer is not in the context, say you could not find it in the "
"document. Cite passage numbers like [1] where relevant.\n\n"
f"CONTEXT:\n{context}\n\nQUESTION: {query}"
)
# TEMPLATE_SYSTEM_PROMPT only exists as a JS global; the query bridge falls
# back to it when custom_prompt is empty (same idiom as the base template).
return await process_user_query_wllama(
grounded_prompt, globals().get("TEMPLATE_SYSTEM_PROMPT", "")
)
Name your agent
Based on . You'll get your own private copy to try and customize.