Add a documents panel to view/remove RAG-indexed documents
Browse filesNew "Indexed documents" button in the sidebar opens a modal listing
every document currently indexed for the active conversation, with its
real chunk count, and a Remove button per document — works for both
online (server KV) and offline (IndexedDB) RAG, following whichever
one is currently active.
Always does a fresh fetch/query when opened rather than trusting any
client-side cache, so what's shown can never drift from what
retrieval actually reads. Removing a document is the same single
write as "the index being recalculated" — there's no separate index
structure to rebuild, since retrieval always scans whatever's
currently stored; the modal just re-fetches after removal to show
that state, rather than optimistically dropping the row locally.
Also removes ragDocumentNames, an in-memory tracking array made fully
redundant by this fresh-fetch approach — it was already write-only
dead code after an earlier change, and would have been actively wrong
here (stale after a reload) if left in place.
Backend: new listDocuments/removeDocument request types, tested
directly end-to-end (ingest two documents, list, remove one, list
again, remove the other, confirm empty) before shipping.
- index.html +133 -19
|
@@ -278,6 +278,10 @@
|
|
| 278 |
<button type="button" id="ragModeBtn" class="small-btn">RAG</button>
|
| 279 |
</div>
|
| 280 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
<div class="sidebar-footer-row" id="aiModeRow" style="display:none;">
|
| 282 |
<span id="aiModeLabel">AI mode</span>
|
| 283 |
<div class="btn-group">
|
|
@@ -317,6 +321,14 @@
|
|
| 317 |
<button type="button" class="modal-ok-btn" id="qrCopyLinkBtn">Copy link instead</button>
|
| 318 |
</div>
|
| 319 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
<div class="modal-overlay" id="capModalOverlay" style="display:none;" role="dialog" aria-modal="true">
|
| 321 |
<div class="modal-box">
|
| 322 |
<div class="modal-emoji" id="modalEmoji">🌙</div>
|
|
@@ -686,6 +698,11 @@ const aiModeServerBtn = document.getElementById('aiModeServerBtn');
|
|
| 686 |
const aiModeLocalBtn = document.getElementById('aiModeLocalBtn');
|
| 687 |
const chatModeBtn = document.getElementById('chatModeBtn');
|
| 688 |
const ragModeBtn = document.getElementById('ragModeBtn');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
const sidebarToggleBtn = document.getElementById('sidebarToggle');
|
| 690 |
const sidebarEl = document.getElementById('sidebar');
|
| 691 |
const sidebarOverlayEl = document.getElementById('sidebarOverlay');
|
|
@@ -723,20 +740,6 @@ let pendingImage = null; // { base64, name }
|
|
| 723 |
// mode is active when the file is picked decides which of these two gets
|
| 724 |
// used, not anything about the file itself.
|
| 725 |
let pendingOneShotDoc = null; // { text, name }
|
| 726 |
-
// Tracks document names indexed for RAG in the CURRENT conversation — not
|
| 727 |
-
// a queued attachment like pendingImage/pendingOneShotDoc. A document is
|
| 728 |
-
// sent to the server and indexed immediately on file select (see the
|
| 729 |
-
// fileInput handler below), not held until the next message is sent,
|
| 730 |
-
// since from here on every question in this conversation retrieves from
|
| 731 |
-
// it, not just the next one. Reset alongside every place activeConvoId
|
| 732 |
-
// changes, so switching conversations (New Chat, loading a saved chat,
|
| 733 |
-
// etc.) doesn't carry over a stale "documents indexed" state from a
|
| 734 |
-
// different conversation's documents.
|
| 735 |
-
let ragDocumentNames = [];
|
| 736 |
-
|
| 737 |
-
function resetRagDocuments() {
|
| 738 |
-
ragDocumentNames = [];
|
| 739 |
-
}
|
| 740 |
|
| 741 |
// Chat vs. RAG only ever governs what happens to a document attached
|
| 742 |
// FROM THIS POINT FORWARD — deliberately not retroactive. Switching mode
|
|
@@ -877,7 +880,6 @@ async function ingestDocumentForRag(text, name) {
|
|
| 877 |
});
|
| 878 |
const data = await res.json();
|
| 879 |
if (!res.ok || !data.ok) throw new Error(data.error || 'indexing failed');
|
| 880 |
-
ragDocumentNames.push(name);
|
| 881 |
attachPreview.innerHTML = `<span>📄 ${name} (indexed for this chat)</span><button type="button" id="clearAttach" aria-label="Dismiss">×</button>`;
|
| 882 |
document.getElementById('clearAttach').addEventListener('click', clearAttachment);
|
| 883 |
} catch (err) {
|
|
@@ -1315,7 +1317,6 @@ function loadConversationById(id) {
|
|
| 1315 |
if (!convo) return;
|
| 1316 |
if (typeof stopSpeaking === 'function') stopSpeaking();
|
| 1317 |
activeConvoId = id;
|
| 1318 |
-
resetRagDocuments();
|
| 1319 |
setSavedActiveConvoId(id);
|
| 1320 |
history = [{ role: 'system', content: buildSystemPrompt() }, ...convo.messages];
|
| 1321 |
renderThreadFromHistory();
|
|
@@ -1586,7 +1587,6 @@ if (continueSharedBtn) continueSharedBtn.addEventListener('click', () => {
|
|
| 1586 |
exitSharedView();
|
| 1587 |
history = [{ role: 'system', content: buildSystemPrompt() }, ...messages];
|
| 1588 |
activeConvoId = null;
|
| 1589 |
-
resetRagDocuments();
|
| 1590 |
persistActiveConversation();
|
| 1591 |
input.focus();
|
| 1592 |
});
|
|
@@ -1957,6 +1957,39 @@ async function deleteOfflineRagChunks(convoId) {
|
|
| 1957 |
} catch {}
|
| 1958 |
}
|
| 1959 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1960 |
// Mirrors ingestDocumentForRag (the server path) exactly in shape and UX,
|
| 1961 |
// just routed to IndexedDB + the local embedder instead of a fetch() —
|
| 1962 |
// this is what the fileInput handler below calls when RAG mode AND
|
|
@@ -1971,7 +2004,6 @@ async function ingestDocumentForOfflineRag(text, name) {
|
|
| 1971 |
const vectors = await embedLocalTexts(rawChunks);
|
| 1972 |
const entries = rawChunks.map((chunkText, i) => ({ text: chunkText, name, vector: vectors[i] }));
|
| 1973 |
await addOfflineRagChunks(activeConvoId, entries);
|
| 1974 |
-
ragDocumentNames.push(name);
|
| 1975 |
attachPreview.innerHTML = `<span>📄 ${name} (indexed offline)</span><button type="button" id="clearAttach" aria-label="Dismiss">×</button>`;
|
| 1976 |
document.getElementById('clearAttach').addEventListener('click', clearAttachment);
|
| 1977 |
} catch (err) {
|
|
@@ -2005,6 +2037,89 @@ async function retrieveOfflineRagContext(convoId, question) {
|
|
| 2005 |
}
|
| 2006 |
}
|
| 2007 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2008 |
// Which of the banner's three mutually-exclusive views is showing —
|
| 2009 |
// the outer .cap-banner container itself just handles positioning/padding.
|
| 2010 |
function showBannerView(view) {
|
|
@@ -2269,7 +2384,6 @@ function startNewChat() {
|
|
| 2269 |
if (typeof stopSpeaking === 'function') stopSpeaking();
|
| 2270 |
history = [{ role: "system", content: buildSystemPrompt() }];
|
| 2271 |
activeConvoId = null;
|
| 2272 |
-
resetRagDocuments();
|
| 2273 |
setSavedActiveConvoId(null);
|
| 2274 |
thread.innerHTML = '';
|
| 2275 |
showEmptyState();
|
|
|
|
| 278 |
<button type="button" id="ragModeBtn" class="small-btn">RAG</button>
|
| 279 |
</div>
|
| 280 |
</div>
|
| 281 |
+
<div class="sidebar-footer-row" id="ragDocsRow">
|
| 282 |
+
<span>Indexed documents</span>
|
| 283 |
+
<button type="button" id="ragDocsBtn" class="small-btn">📚 View</button>
|
| 284 |
+
</div>
|
| 285 |
<div class="sidebar-footer-row" id="aiModeRow" style="display:none;">
|
| 286 |
<span id="aiModeLabel">AI mode</span>
|
| 287 |
<div class="btn-group">
|
|
|
|
| 321 |
<button type="button" class="modal-ok-btn" id="qrCopyLinkBtn">Copy link instead</button>
|
| 322 |
</div>
|
| 323 |
</div>
|
| 324 |
+
<div class="modal-overlay" id="ragDocsModalOverlay" style="display:none;" role="dialog" aria-modal="true" aria-label="Indexed documents">
|
| 325 |
+
<div class="modal-box" style="max-width:420px;text-align:left;">
|
| 326 |
+
<button type="button" class="sidebar-close-btn" id="ragDocsModalClose" aria-label="Close" style="position:absolute;top:14px;right:14px;">✕</button>
|
| 327 |
+
<div class="modal-title" style="text-align:center;">Indexed documents</div>
|
| 328 |
+
<div class="modal-sub" id="ragDocsModalSub" style="margin-bottom:14px;text-align:center;">For this conversation</div>
|
| 329 |
+
<div id="ragDocsList" style="display:flex;flex-direction:column;gap:8px;max-height:300px;overflow-y:auto;"></div>
|
| 330 |
+
</div>
|
| 331 |
+
</div>
|
| 332 |
<div class="modal-overlay" id="capModalOverlay" style="display:none;" role="dialog" aria-modal="true">
|
| 333 |
<div class="modal-box">
|
| 334 |
<div class="modal-emoji" id="modalEmoji">🌙</div>
|
|
|
|
| 698 |
const aiModeLocalBtn = document.getElementById('aiModeLocalBtn');
|
| 699 |
const chatModeBtn = document.getElementById('chatModeBtn');
|
| 700 |
const ragModeBtn = document.getElementById('ragModeBtn');
|
| 701 |
+
const ragDocsBtn = document.getElementById('ragDocsBtn');
|
| 702 |
+
const ragDocsModalOverlay = document.getElementById('ragDocsModalOverlay');
|
| 703 |
+
const ragDocsModalClose = document.getElementById('ragDocsModalClose');
|
| 704 |
+
const ragDocsModalSub = document.getElementById('ragDocsModalSub');
|
| 705 |
+
const ragDocsListEl = document.getElementById('ragDocsList');
|
| 706 |
const sidebarToggleBtn = document.getElementById('sidebarToggle');
|
| 707 |
const sidebarEl = document.getElementById('sidebar');
|
| 708 |
const sidebarOverlayEl = document.getElementById('sidebarOverlay');
|
|
|
|
| 740 |
// mode is active when the file is picked decides which of these two gets
|
| 741 |
// used, not anything about the file itself.
|
| 742 |
let pendingOneShotDoc = null; // { text, name }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 743 |
|
| 744 |
// Chat vs. RAG only ever governs what happens to a document attached
|
| 745 |
// FROM THIS POINT FORWARD — deliberately not retroactive. Switching mode
|
|
|
|
| 880 |
});
|
| 881 |
const data = await res.json();
|
| 882 |
if (!res.ok || !data.ok) throw new Error(data.error || 'indexing failed');
|
|
|
|
| 883 |
attachPreview.innerHTML = `<span>📄 ${name} (indexed for this chat)</span><button type="button" id="clearAttach" aria-label="Dismiss">×</button>`;
|
| 884 |
document.getElementById('clearAttach').addEventListener('click', clearAttachment);
|
| 885 |
} catch (err) {
|
|
|
|
| 1317 |
if (!convo) return;
|
| 1318 |
if (typeof stopSpeaking === 'function') stopSpeaking();
|
| 1319 |
activeConvoId = id;
|
|
|
|
| 1320 |
setSavedActiveConvoId(id);
|
| 1321 |
history = [{ role: 'system', content: buildSystemPrompt() }, ...convo.messages];
|
| 1322 |
renderThreadFromHistory();
|
|
|
|
| 1587 |
exitSharedView();
|
| 1588 |
history = [{ role: 'system', content: buildSystemPrompt() }, ...messages];
|
| 1589 |
activeConvoId = null;
|
|
|
|
| 1590 |
persistActiveConversation();
|
| 1591 |
input.focus();
|
| 1592 |
});
|
|
|
|
| 1957 |
} catch {}
|
| 1958 |
}
|
| 1959 |
|
| 1960 |
+
// Groups stored chunks by document name — the offline mirror of the
|
| 1961 |
+
// server's listDocumentChunks, same reasoning: shows what's actually in
|
| 1962 |
+
// IndexedDB right now, not a client-side guess.
|
| 1963 |
+
async function listOfflineRagDocuments(convoId) {
|
| 1964 |
+
const stored = await getOfflineRagChunks(convoId);
|
| 1965 |
+
const byName = new Map();
|
| 1966 |
+
for (const entry of stored) byName.set(entry.name, (byName.get(entry.name) || 0) + 1);
|
| 1967 |
+
return Array.from(byName, ([name, chunks]) => ({ name, chunks }));
|
| 1968 |
+
}
|
| 1969 |
+
|
| 1970 |
+
// Removing one document offline — same idea as removeDocumentChunks on
|
| 1971 |
+
// the server (filter it out, re-save), but IndexedDB does this as a
|
| 1972 |
+
// cursor delete rather than a read-filter-rewrite of a whole JSON blob.
|
| 1973 |
+
// "The index recalculated" is really just "future retrieval scans see
|
| 1974 |
+
// one fewer row" — nothing to explicitly rebuild.
|
| 1975 |
+
async function removeOfflineRagDocument(convoId, name) {
|
| 1976 |
+
const db = await openOfflineRagDb();
|
| 1977 |
+
return new Promise((resolve, reject) => {
|
| 1978 |
+
const tx = db.transaction(OFFLINE_RAG_STORE, 'readwrite');
|
| 1979 |
+
const index = tx.objectStore(OFFLINE_RAG_STORE).index('convoId');
|
| 1980 |
+
const req = index.openCursor(convoId);
|
| 1981 |
+
req.onsuccess = () => {
|
| 1982 |
+
const cursor = req.result;
|
| 1983 |
+
if (cursor) {
|
| 1984 |
+
if (cursor.value.name === name) cursor.delete();
|
| 1985 |
+
cursor.continue();
|
| 1986 |
+
}
|
| 1987 |
+
};
|
| 1988 |
+
tx.oncomplete = () => resolve();
|
| 1989 |
+
tx.onerror = () => reject(tx.error);
|
| 1990 |
+
});
|
| 1991 |
+
}
|
| 1992 |
+
|
| 1993 |
// Mirrors ingestDocumentForRag (the server path) exactly in shape and UX,
|
| 1994 |
// just routed to IndexedDB + the local embedder instead of a fetch() —
|
| 1995 |
// this is what the fileInput handler below calls when RAG mode AND
|
|
|
|
| 2004 |
const vectors = await embedLocalTexts(rawChunks);
|
| 2005 |
const entries = rawChunks.map((chunkText, i) => ({ text: chunkText, name, vector: vectors[i] }));
|
| 2006 |
await addOfflineRagChunks(activeConvoId, entries);
|
|
|
|
| 2007 |
attachPreview.innerHTML = `<span>📄 ${name} (indexed offline)</span><button type="button" id="clearAttach" aria-label="Dismiss">×</button>`;
|
| 2008 |
document.getElementById('clearAttach').addEventListener('click', clearAttachment);
|
| 2009 |
} catch (err) {
|
|
|
|
| 2037 |
}
|
| 2038 |
}
|
| 2039 |
|
| 2040 |
+
// --- View/manage indexed documents (works for both online and offline
|
| 2041 |
+
// RAG) --------------------------------------------------------------
|
| 2042 |
+
// Always does a FRESH fetch/query when opened rather than trusting any
|
| 2043 |
+
// client-side cache — this is the real, current state (KV for online,
|
| 2044 |
+
// IndexedDB for offline), the same source of truth retrieval itself
|
| 2045 |
+
// reads from, so what's shown here can never drift from what's actually
|
| 2046 |
+
// searchable.
|
| 2047 |
+
async function fetchRagDocsList() {
|
| 2048 |
+
if (!activeConvoId) return [];
|
| 2049 |
+
if (localModeActive) {
|
| 2050 |
+
return listOfflineRagDocuments(activeConvoId);
|
| 2051 |
+
}
|
| 2052 |
+
try {
|
| 2053 |
+
const res = await fetch(WORKER_URL, {
|
| 2054 |
+
method: 'POST',
|
| 2055 |
+
headers: { 'Content-Type': 'application/json' },
|
| 2056 |
+
body: JSON.stringify({ type: 'listDocuments', convoId: activeConvoId })
|
| 2057 |
+
});
|
| 2058 |
+
const data = await res.json();
|
| 2059 |
+
return data.ok ? data.documents : [];
|
| 2060 |
+
} catch {
|
| 2061 |
+
return [];
|
| 2062 |
+
}
|
| 2063 |
+
}
|
| 2064 |
+
|
| 2065 |
+
async function removeRagDocByName(name) {
|
| 2066 |
+
if (!activeConvoId) return;
|
| 2067 |
+
if (localModeActive) {
|
| 2068 |
+
await removeOfflineRagDocument(activeConvoId, name);
|
| 2069 |
+
} else {
|
| 2070 |
+
try {
|
| 2071 |
+
await fetch(WORKER_URL, {
|
| 2072 |
+
method: 'POST',
|
| 2073 |
+
headers: { 'Content-Type': 'application/json' },
|
| 2074 |
+
body: JSON.stringify({ type: 'removeDocument', convoId: activeConvoId, name })
|
| 2075 |
+
});
|
| 2076 |
+
} catch {}
|
| 2077 |
+
}
|
| 2078 |
+
}
|
| 2079 |
+
|
| 2080 |
+
async function renderRagDocsList() {
|
| 2081 |
+
ragDocsListEl.innerHTML = '<div style="text-align:center;color:var(--muted);font-size:13px;padding:12px 0;">Loading…</div>';
|
| 2082 |
+
const documents = await fetchRagDocsList();
|
| 2083 |
+
if (!documents.length) {
|
| 2084 |
+
ragDocsListEl.innerHTML = '<div style="text-align:center;color:var(--muted);font-size:13px;padding:12px 0;">No documents indexed in this conversation yet.</div>';
|
| 2085 |
+
return;
|
| 2086 |
+
}
|
| 2087 |
+
ragDocsListEl.innerHTML = '';
|
| 2088 |
+
documents.forEach((doc) => {
|
| 2089 |
+
const row = document.createElement('div');
|
| 2090 |
+
row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:10px;';
|
| 2091 |
+
const label = document.createElement('span');
|
| 2092 |
+
label.style.cssText = 'font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
| 2093 |
+
label.textContent = `📄 ${doc.name} — ${doc.chunks} chunk${doc.chunks === 1 ? '' : 's'}`;
|
| 2094 |
+
const removeBtn = document.createElement('button');
|
| 2095 |
+
removeBtn.type = 'button';
|
| 2096 |
+
removeBtn.className = 'small-btn';
|
| 2097 |
+
removeBtn.textContent = 'Remove';
|
| 2098 |
+
removeBtn.addEventListener('click', async () => {
|
| 2099 |
+
removeBtn.disabled = true;
|
| 2100 |
+
removeBtn.textContent = 'Removing…';
|
| 2101 |
+
await removeRagDocByName(doc.name);
|
| 2102 |
+
// Re-fetch rather than just dropping this row locally — this is
|
| 2103 |
+
// the "index recalculated" step the removal itself already did
|
| 2104 |
+
// server/IndexedDB-side; re-rendering from a fresh fetch is what
|
| 2105 |
+
// actually shows that, rather than trusting the removal succeeded.
|
| 2106 |
+
renderRagDocsList();
|
| 2107 |
+
});
|
| 2108 |
+
row.append(label, removeBtn);
|
| 2109 |
+
ragDocsListEl.appendChild(row);
|
| 2110 |
+
});
|
| 2111 |
+
}
|
| 2112 |
+
|
| 2113 |
+
function openRagDocsModal() {
|
| 2114 |
+
ragDocsModalSub.textContent = localModeActive ? 'For this conversation (stored offline on this device)' : 'For this conversation';
|
| 2115 |
+
ragDocsModalOverlay.style.display = 'flex';
|
| 2116 |
+
renderRagDocsList();
|
| 2117 |
+
}
|
| 2118 |
+
|
| 2119 |
+
if (ragDocsBtn) ragDocsBtn.addEventListener('click', openRagDocsModal);
|
| 2120 |
+
if (ragDocsModalClose) ragDocsModalClose.addEventListener('click', () => { ragDocsModalOverlay.style.display = 'none'; });
|
| 2121 |
+
if (ragDocsModalOverlay) ragDocsModalOverlay.addEventListener('click', (e) => { if (e.target === ragDocsModalOverlay) ragDocsModalOverlay.style.display = 'none'; });
|
| 2122 |
+
|
| 2123 |
// Which of the banner's three mutually-exclusive views is showing —
|
| 2124 |
// the outer .cap-banner container itself just handles positioning/padding.
|
| 2125 |
function showBannerView(view) {
|
|
|
|
| 2384 |
if (typeof stopSpeaking === 'function') stopSpeaking();
|
| 2385 |
history = [{ role: "system", content: buildSystemPrompt() }];
|
| 2386 |
activeConvoId = null;
|
|
|
|
| 2387 |
setSavedActiveConvoId(null);
|
| 2388 |
thread.innerHTML = '';
|
| 2389 |
showEmptyState();
|