<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>docling-rag</title>
<style>
:root {
--bg: #f7f7f8; --card: #ffffff; --text: #1c1c1e; --muted: #6e6e73;
--accent: #0a66c2; --border: #e2e2e6; --ok: #1a7f37; --bad: #b42318;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #131316; --card: #1d1d21; --text: #ececf0; --muted: #9a9aa2;
--accent: #6cb2ff; --border: #313138; --ok: #4ade80; --bad: #f87171;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
main { max-width: 860px; margin: 0 auto; padding: 24px 16px 64px; }
h1 { font-size: 20px; margin: 0; }
header { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
#status { font-size: 13px; color: var(--muted); }
#status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: var(--muted); margin-right: 4px; }
#status.ok .dot { background: var(--ok); }
#status.bad .dot { background: var(--bad); }
.card { background: var(--card); border: 1px solid var(--border);
border-radius: 10px; padding: 14px 16px; margin-top: 14px; }
label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 2px; }
input, select { font: inherit; color: var(--text); background: var(--bg);
border: 1px solid var(--border); border-radius: 7px; padding: 7px 9px; }
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
.grow { flex: 1 1 260px; } .grow input { width: 100%; }
button { font: inherit; font-weight: 600; color: #fff; background: var(--accent);
border: 0; border-radius: 7px; padding: 8px 18px; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; }
.check { display: flex; align-items: center; gap: 6px; font-size: 13px;
color: var(--muted); padding-bottom: 8px; }
#stats { font-size: 13px; color: var(--muted); }
#error { color: var(--bad); font-size: 14px; margin-top: 12px; white-space: pre-wrap; }
.answer { border-left: 3px solid var(--accent); }
.answer h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em;
color: var(--muted); margin: 0 0 6px; }
.hit { margin-top: 10px; }
.hit .meta { display: flex; gap: 10px; font-size: 12px; color: var(--muted);
margin-bottom: 6px; flex-wrap: wrap; }
.hit .score { color: var(--accent); font-weight: 600; }
.hit pre { margin: 0; white-space: pre-wrap; word-break: break-word;
font: 13.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
details.token summary { cursor: pointer; font-size: 13px; color: var(--muted); }
details.docs summary { cursor: pointer; font-size: 13px; color: var(--muted); }
.doc { display: flex; align-items: center; gap: 10px; padding: 7px 0;
border-top: 1px solid var(--border); }
.doc:first-of-type { border-top: 0; }
.doc .title { flex: 1 1 auto; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.doc .src { font-size: 12px; color: var(--muted); overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
.doc button { background: transparent; color: var(--bad); font-weight: 500;
padding: 2px 8px; border: 1px solid var(--border); }
.doc a.md { font-size: 12px; color: var(--accent); text-decoration: none;
padding: 2px 8px; border: 1px solid var(--border); border-radius: 7px; }
.doc a.md:hover { text-decoration: underline; }
#upload-row { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
#upload-note { font-size: 13px; color: var(--muted); }
</style>
</head>
<body>
<main>
<header>
<h1>docling-rag</h1>
<span id="status"><span class="dot"></span><span id="status-text">checking…</span></span>
<span id="stats"></span>
</header>
<div class="card">
<details class="token" id="token-box">
<summary>API key</summary>
<div class="row" style="margin-top:8px">
<div class="grow">
<label for="token">Stored in this browser's localStorage; sent as <code>X-Api-Key</code></label>
<input id="token" type="password" placeholder="RAG_API_KEYS entry" autocomplete="off">
</div>
</div>
</details>
</div>
<div class="card">
<details class="docs" id="docs-box">
<summary>Documents</summary>
<div id="doc-list" style="margin-top:8px"></div>
<div class="row" id="upload-row">
<input id="file" type="file">
<div class="check" title="Classify pictures (chart / logo / …); needs models/picture_classifier.onnx">
<input id="enrich-pictures" type="checkbox"><label for="enrich-pictures" style="margin:0">Enrich pictures</label>
</div>
<div class="check" title="Transcribe code blocks with the CodeFormula model; needs the --enrich download">
<input id="enrich-code" type="checkbox"><label for="enrich-code" style="margin:0">Enrich code</label>
</div>
<div class="check" title="Transcribe formulas to LaTeX with the CodeFormula model; needs the --enrich download">
<input id="enrich-formulas" type="checkbox"><label for="enrich-formulas" style="margin:0">Enrich formulas</label>
</div>
<button type="button" id="upload">Upload</button>
<span id="upload-note"></span>
</div>
</details>
</div>
<form class="card" id="form">
<div class="row">
<div class="grow">
<label for="q">Query</label>
<input id="q" type="text" required placeholder="What are the payment terms?">
</div>
<div>
<label for="mode">Mode</label>
<select id="mode">
<option value="">server default</option>
<option>vector</option>
<option>bm25</option>
<option>hybrid</option>
<option>multi-query</option>
<option>hyde</option>
</select>
</div>
<div>
<label for="k">Top-k</label>
<input id="k" type="number" min="1" max="100" style="width:5.5em" placeholder="cfg">
</div>
<div class="check">
<input id="answer" type="checkbox"><label for="answer" style="margin:0">LLM answer</label>
</div>
<div class="check" title="Show each hit with one chunk before and one after it">
<input id="extend" type="checkbox"><label for="extend" style="margin:0">extend context</label>
</div>
<button id="go">Search</button>
</div>
</form>
<div id="error" hidden></div>
<div id="out"></div>
</main>
<script>
"use strict";
const $ = (id) => document.getElementById(id);
const TOKEN_KEY = "docling-rag-token";
$("token").value = localStorage.getItem(TOKEN_KEY) || "";
if (!$("token").value) $("token-box").open = true;
$("token").addEventListener("input", () => {
localStorage.setItem(TOKEN_KEY, $("token").value.trim());
refreshStats();
refreshDocs();
});
function headers() {
const h = { "Content-Type": "application/json" };
const t = localStorage.getItem(TOKEN_KEY);
if (t) h["X-Api-Key"] = t;
return h;
}
async function api(path, init) {
const res = await fetch(path, init);
if (res.status === 401) {
$("token-box").open = true;
throw new Error("unauthorized — set a valid API key (one of RAG_API_KEYS)");
}
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
return body;
}
async function refreshHealth() {
const el = $("status");
try {
await fetch("/health").then((r) => { if (!r.ok) throw 0; });
el.className = "ok"; $("status-text").textContent = "online";
} catch {
el.className = "bad"; $("status-text").textContent = "offline";
}
}
async function refreshStats() {
try {
const s = await api("/api/stats", { headers: headers() });
$("stats").textContent = `${s.documents} documents · ${s.chunks} chunks`;
} catch { $("stats").textContent = ""; }
}
async function refreshDocs() {
const list = $("doc-list");
try {
const d = await api("/api/documents", { headers: headers() });
list.innerHTML = (d.documents || []).map((doc) => {
const busy = String(doc.hash || "").startsWith("pending:");
const mdHref = `/api/documents/${encodeURIComponent(doc.id)}/markdown` +
`?api_key=${encodeURIComponent(localStorage.getItem(TOKEN_KEY) || "")}`;
const md = (doc.metadata && doc.metadata.has_markdown)
? `<a class="md" target="_blank" rel="noopener" href="${esc(mdHref)}">md</a>` : "";
return `<div class="doc" data-id="${esc(doc.id)}">` +
`<span class="title">${esc(doc.title)}${busy ? " ⏳ processing…" : ""}</span>` +
`<span class="src">${esc(doc.source_uri)}</span>` + md +
`<button type="button" class="del">delete</button></div>`;
}).join("") || `<span id="upload-note">No documents yet.</span>`;
} catch { list.innerHTML = ""; }
}
function phaseSummary(m) {
if (!m) return "";
const s = (p) => (m[p] && m[p].seconds != null) ? `${p} ${m[p].seconds.toFixed(1)}s` : null;
const parts = [s("parsing"), s("chunking"), s("embedding")].filter(Boolean);
return parts.length ? ` · ${parts.join(" · ")}` : "";
}
function startUploadProgress(name) {
const started = Date.now();
const uri = `upload:///${name}`;
const tick = async () => {
const secs = Math.round((Date.now() - started) / 1000);
try {
const d = await api("/api/documents", { headers: headers() });
const row = (d.documents || []).find((doc) => doc.source_uri === uri);
if (row && String(row.hash || "").startsWith("pending:")) {
const info = await api(`/api/documents/${encodeURIComponent(row.id)}`,
{ headers: headers() });
$("upload-note").textContent =
`converting + embedding… ${info.chunks} chunks · ${secs}s`;
return;
}
} catch { }
$("upload-note").textContent = `converting… ${secs}s`;
};
tick();
return setInterval(tick, 2000);
}
$("doc-list").addEventListener("click", async (ev) => {
const btn = ev.target.closest("button.del");
if (!btn) return;
const row = btn.closest(".doc");
const title = row.querySelector(".title").textContent;
if (!confirm(`Delete "${title}" and all its chunks?`)) return;
btn.disabled = true;
try {
await api(`/api/documents/${encodeURIComponent(row.dataset.id)}`, {
method: "DELETE", headers: headers(),
});
refreshDocs(); refreshStats();
} catch (e) {
btn.disabled = false;
$("error").textContent = String(e.message || e); $("error").hidden = false;
}
});
$("upload").addEventListener("click", async () => {
const file = $("file").files[0];
if (!file) { $("upload-note").textContent = "pick a file first"; return; }
$("upload").disabled = true;
$("error").hidden = true;
const progress = startUploadProgress(file.name);
try {
let qs = `name=${encodeURIComponent(file.name)}`;
if ($("enrich-pictures").checked) qs += "&enrich_pictures=true";
if ($("enrich-code").checked) qs += "&enrich_code=true";
if ($("enrich-formulas").checked) qs += "&enrich_formulas=true";
const r = await api(`/api/documents?${qs}`, {
method: "POST", headers: headers(), body: file,
});
$("upload-note").textContent = r.outcome === "skipped"
? "already stored (identical content)"
: `ingested — ${r.chunks} chunks${phaseSummary(r.metrics)}`;
$("file").value = "";
refreshDocs(); refreshStats();
} catch (e) {
$("upload-note").textContent = "";
$("error").textContent = String(e.message || e); $("error").hidden = false;
} finally { clearInterval(progress); $("upload").disabled = false; }
});
function esc(s) {
return String(s).replace(/[&<>"]/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
}
function render(data) {
const out = [];
if (data.answer) {
out.push(`<div class="card answer"><h2>Answer (${esc(data.mode)})</h2>` +
`<div>${esc(data.answer)}</div></div>`);
}
for (const hit of data.results || []) {
const c = hit.chunk || {};
const extended = hit.context != null && hit.context !== c.text;
out.push(`<div class="card hit"><div class="meta">` +
`<span class="score">${Number(hit.score).toFixed(4)}</span>` +
`<span>doc ${esc(c.doc_id ?? "?")}</span>` +
`<span>chunk #${esc(c.ordinal ?? "?")}${extended ? " ±1" : ""}</span></div>` +
`<pre>${esc(hit.context ?? c.text ?? "")}</pre></div>`);
}
if (!out.length) out.push(`<div class="card">No results.</div>`);
$("out").innerHTML = out.join("");
}
$("form").addEventListener("submit", async (ev) => {
ev.preventDefault();
$("error").hidden = true; $("out").innerHTML = ""; $("go").disabled = true;
const body = { query: $("q").value, answer: $("answer").checked,
extend: $("extend").checked };
if ($("mode").value) body.mode = $("mode").value;
if ($("k").value) body.top_k = Number($("k").value);
try {
render(await api("/api/search", {
method: "POST", headers: headers(), body: JSON.stringify(body),
}));
} catch (e) {
$("error").textContent = String(e.message || e); $("error").hidden = false;
} finally { $("go").disabled = false; }
});
refreshHealth();
refreshStats();
refreshDocs();
</script>
</body>
</html>