Umer359 commited on
Commit
0d8cfdf
·
1 Parent(s): 5da83ea

Add a documents panel to view/remove RAG-indexed documents

Browse files

New "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.

Files changed (1) hide show
  1. index.html +133 -19
index.html CHANGED
@@ -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();