Add fully offline RAG for local mode
Browse filesExtends RAG mode to work with zero network calls when combined with
local (in-browser WebLLM) mode β documents are chunked, embedded, and
retrieved entirely on-device, so a visitor can do private document
Q&A with no internet connection and nothing ever leaving the browser.
- Embeddings: WebLLM (already used for local chat) doesn't do
embeddings, so this pulls in a second, much smaller model via
Transformers.js (Xenova/all-MiniLM-L6-v2, ~23MB quantized) β lazy-
loaded the same way the local chat model already is.
- Storage: IndexedDB instead of the server's KV β persists
indefinitely (unlike the server's 24h TTL, since this is the
visitor's own device with no server cost to bound), cleaned up only
when a saved conversation is deleted outright.
- Retrieval: the exact same chunking/cosine-similarity logic as the
server path, ported as plain dependency-free JS, injected into the
local model's prompt for that turn only (never persisted into
history).
- Local mode's existing document decline now only applies to Chat
mode's one-shot analysis (still genuinely unsupported by the tiny
local model) β RAG-mode documents are fully supported offline.
- index.html +202 -14
|
@@ -346,7 +346,7 @@
|
|
| 346 |
<div class="cap-title">π₯οΈ Local AI mode β <span class="info-term" id="localLimitedTerm" tabindex="0" role="button" aria-expanded="false" aria-describedby="localLimitedTooltip">limited<div class="tooltip-bubble" id="localLimitedTooltip" role="tooltip">
|
| 347 |
<ol>
|
| 348 |
<li><strong>Smaller model</strong> β it's a 1B-parameter model, chosen specifically to run smoothly on phones and slower devices (WebGPU memory is often tight on mobile). A real capability gap versus the server's best ("Smart", 8B), but a much smaller, faster download than a bigger model would be.</li>
|
| 349 |
-
<li><strong>No images</strong> β it's text-only
|
| 350 |
<li><strong>No live data</strong> β the server injects real-time weather, news, currency rates, holidays, and general web facts into its answers. Local mode has none of that β it's just the raw model guessing from its own training, so it'll be wrong or evasive on anything time-sensitive ("what's the weather," "who won yesterday," etc.).</li>
|
| 351 |
<li><strong>No date awareness</strong> β the server also tells the model today's actual date; local mode doesn't get that either, so it may not know what day it is.</li>
|
| 352 |
<li><strong>Speed/quality depend on your device</strong> β no server-grade GPU behind it, just whatever's in your laptop/phone.</li>
|
|
@@ -826,7 +826,10 @@ fileInput.addEventListener('change', async () => {
|
|
| 826 |
} else if (file.type === 'application/pdf' || /\.pdf$/i.test(file.name)) {
|
| 827 |
const text = await extractPdfText(file);
|
| 828 |
if (attachmentMode === 'rag') {
|
| 829 |
-
await ingestDocumentForRag(text, file.name);
|
|
|
|
|
|
|
|
|
|
| 830 |
} else {
|
| 831 |
pendingOneShotDoc = { text: text.slice(0, MAX_ONE_SHOT_DOC_CHARS), name: file.name };
|
| 832 |
showAttachmentPreview(file.name);
|
|
@@ -834,7 +837,10 @@ fileInput.addEventListener('change', async () => {
|
|
| 834 |
} else if (file.type.startsWith('text/') || /\.(txt|md|csv|json)$/i.test(file.name)) {
|
| 835 |
const text = await fileToText(file);
|
| 836 |
if (attachmentMode === 'rag') {
|
| 837 |
-
await ingestDocumentForRag(text, file.name);
|
|
|
|
|
|
|
|
|
|
| 838 |
} else {
|
| 839 |
pendingOneShotDoc = { text: text.slice(0, MAX_ONE_SHOT_DOC_CHARS), name: file.name };
|
| 840 |
showAttachmentPreview(file.name);
|
|
@@ -1250,6 +1256,7 @@ function deleteConversation(id) {
|
|
| 1250 |
const convos = loadConversations();
|
| 1251 |
delete convos[id];
|
| 1252 |
saveConversations(convos);
|
|
|
|
| 1253 |
if (id === activeConvoId) {
|
| 1254 |
startNewChat();
|
| 1255 |
} else {
|
|
@@ -1826,6 +1833,178 @@ async function loadLocalEngine() {
|
|
| 1826 |
return localEngineLoadPromise;
|
| 1827 |
}
|
| 1828 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1829 |
// Which of the banner's three mutually-exclusive views is showing β
|
| 1830 |
// the outer .cap-banner container itself just handles positioning/padding.
|
| 1831 |
function showBannerView(view) {
|
|
@@ -2280,7 +2459,16 @@ async function requestLocalReply(loadingBubble, userMessageText) {
|
|
| 2280 |
}
|
| 2281 |
try {
|
| 2282 |
const engine = await loadLocalEngine();
|
| 2283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2284 |
let full = '';
|
| 2285 |
let started = false;
|
| 2286 |
for await (const chunk of chunks) {
|
|
@@ -2321,17 +2509,17 @@ async function requestReply(imageBase64) {
|
|
| 2321 |
const userMessageText = [...history].reverse().find((m) => m.role === 'user')?.content || '';
|
| 2322 |
|
| 2323 |
if (localModeActive) {
|
| 2324 |
-
// Local mode
|
| 2325 |
-
//
|
| 2326 |
-
//
|
| 2327 |
-
//
|
| 2328 |
-
//
|
| 2329 |
-
//
|
| 2330 |
-
//
|
| 2331 |
-
//
|
| 2332 |
-
if (imageBase64 ||
|
| 2333 |
loadingBubble.parentElement.classList.remove('loading');
|
| 2334 |
-
loadingBubble.textContent = "Local AI mode can't read images or documents β remove the attachment, or switch back to the server
|
| 2335 |
scrollToBottom();
|
| 2336 |
return;
|
| 2337 |
}
|
|
|
|
| 346 |
<div class="cap-title">π₯οΈ Local AI mode β <span class="info-term" id="localLimitedTerm" tabindex="0" role="button" aria-expanded="false" aria-describedby="localLimitedTooltip">limited<div class="tooltip-bubble" id="localLimitedTooltip" role="tooltip">
|
| 347 |
<ol>
|
| 348 |
<li><strong>Smaller model</strong> β it's a 1B-parameter model, chosen specifically to run smoothly on phones and slower devices (WebGPU memory is often tight on mobile). A real capability gap versus the server's best ("Smart", 8B), but a much smaller, faster download than a bigger model would be.</li>
|
| 349 |
+
<li><strong>No images, no one-off documents</strong> β it's text-only, and can't analyze a document in a single pass. Switch to RAG mode to work with a document offline instead β that's fully supported, indexed and searched entirely on your device.</li>
|
| 350 |
<li><strong>No live data</strong> β the server injects real-time weather, news, currency rates, holidays, and general web facts into its answers. Local mode has none of that β it's just the raw model guessing from its own training, so it'll be wrong or evasive on anything time-sensitive ("what's the weather," "who won yesterday," etc.).</li>
|
| 351 |
<li><strong>No date awareness</strong> β the server also tells the model today's actual date; local mode doesn't get that either, so it may not know what day it is.</li>
|
| 352 |
<li><strong>Speed/quality depend on your device</strong> β no server-grade GPU behind it, just whatever's in your laptop/phone.</li>
|
|
|
|
| 826 |
} else if (file.type === 'application/pdf' || /\.pdf$/i.test(file.name)) {
|
| 827 |
const text = await extractPdfText(file);
|
| 828 |
if (attachmentMode === 'rag') {
|
| 829 |
+
await (localModeActive ? ingestDocumentForOfflineRag(text, file.name) : ingestDocumentForRag(text, file.name));
|
| 830 |
+
} else if (localModeActive) {
|
| 831 |
+
alert("Local AI mode can't analyze a one-off document β switch to RAG mode to index it offline, or switch back to the server.");
|
| 832 |
+
clearAttachment();
|
| 833 |
} else {
|
| 834 |
pendingOneShotDoc = { text: text.slice(0, MAX_ONE_SHOT_DOC_CHARS), name: file.name };
|
| 835 |
showAttachmentPreview(file.name);
|
|
|
|
| 837 |
} else if (file.type.startsWith('text/') || /\.(txt|md|csv|json)$/i.test(file.name)) {
|
| 838 |
const text = await fileToText(file);
|
| 839 |
if (attachmentMode === 'rag') {
|
| 840 |
+
await (localModeActive ? ingestDocumentForOfflineRag(text, file.name) : ingestDocumentForRag(text, file.name));
|
| 841 |
+
} else if (localModeActive) {
|
| 842 |
+
alert("Local AI mode can't analyze a one-off document β switch to RAG mode to index it offline, or switch back to the server.");
|
| 843 |
+
clearAttachment();
|
| 844 |
} else {
|
| 845 |
pendingOneShotDoc = { text: text.slice(0, MAX_ONE_SHOT_DOC_CHARS), name: file.name };
|
| 846 |
showAttachmentPreview(file.name);
|
|
|
|
| 1256 |
const convos = loadConversations();
|
| 1257 |
delete convos[id];
|
| 1258 |
saveConversations(convos);
|
| 1259 |
+
deleteOfflineRagChunks(id);
|
| 1260 |
if (id === activeConvoId) {
|
| 1261 |
startNewChat();
|
| 1262 |
} else {
|
|
|
|
| 1833 |
return localEngineLoadPromise;
|
| 1834 |
}
|
| 1835 |
|
| 1836 |
+
// --- Offline RAG (local mode + RAG attachment mode together) -----------
|
| 1837 |
+
// Mirrors the server-side RAG feature (chunk -> embed -> store -> cosine-
|
| 1838 |
+
// similarity retrieve -> inject as context) entirely client-side, for
|
| 1839 |
+
// visitors who want document Q&A with zero network calls at all. WebLLM
|
| 1840 |
+
// (already used for local chat) doesn't do embeddings, so this pulls in
|
| 1841 |
+
// a second, much smaller model via Transformers.js β the same lazy,
|
| 1842 |
+
// load-once-then-cached pattern as loadLocalEngine() above. Storage uses
|
| 1843 |
+
// IndexedDB (not the server's KV) since this needs to work fully
|
| 1844 |
+
// offline and never leave the device.
|
| 1845 |
+
let localEmbedder = null;
|
| 1846 |
+
let localEmbedderLoadPromise = null;
|
| 1847 |
+
async function loadLocalEmbedder() {
|
| 1848 |
+
if (localEmbedder) return localEmbedder;
|
| 1849 |
+
if (localEmbedderLoadPromise) return localEmbedderLoadPromise;
|
| 1850 |
+
localEmbedderLoadPromise = (async () => {
|
| 1851 |
+
const { pipeline } = await import('https://esm.run/@huggingface/transformers');
|
| 1852 |
+
// ~23MB quantized β a much smaller one-time download than the chat
|
| 1853 |
+
// model, and WebGPU is already confirmed available by this point
|
| 1854 |
+
// (local mode can't be entered at all without it, see HAS_WEBGPU).
|
| 1855 |
+
const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { device: 'webgpu' });
|
| 1856 |
+
localEmbedder = extractor;
|
| 1857 |
+
return extractor;
|
| 1858 |
+
})();
|
| 1859 |
+
return localEmbedderLoadPromise;
|
| 1860 |
+
}
|
| 1861 |
+
async function embedLocalTexts(texts) {
|
| 1862 |
+
const extractor = await loadLocalEmbedder();
|
| 1863 |
+
const output = await extractor(texts, { pooling: 'mean', normalize: true });
|
| 1864 |
+
return output.tolist();
|
| 1865 |
+
}
|
| 1866 |
+
|
| 1867 |
+
// Same chunking/similarity logic as the server's chunkText/
|
| 1868 |
+
// cosineSimilarity β kept as plain, dependency-free JS specifically so
|
| 1869 |
+
// it's trivially portable between the two, not because of any DOM/
|
| 1870 |
+
// browser-specific need.
|
| 1871 |
+
const OFFLINE_RAG_CHUNK_SIZE = 800;
|
| 1872 |
+
const OFFLINE_RAG_CHUNK_OVERLAP = 100;
|
| 1873 |
+
const OFFLINE_RAG_TOP_K = 4;
|
| 1874 |
+
function chunkTextOffline(text, chunkSize, overlap) {
|
| 1875 |
+
const chunks = [];
|
| 1876 |
+
let start = 0;
|
| 1877 |
+
while (start < text.length) {
|
| 1878 |
+
let end = Math.min(start + chunkSize, text.length);
|
| 1879 |
+
if (end < text.length) {
|
| 1880 |
+
const lastSpace = text.lastIndexOf(' ', end);
|
| 1881 |
+
if (lastSpace > start + chunkSize * 0.5) end = lastSpace;
|
| 1882 |
+
}
|
| 1883 |
+
const piece = text.slice(start, end).trim();
|
| 1884 |
+
if (piece) chunks.push(piece);
|
| 1885 |
+
if (end >= text.length) break;
|
| 1886 |
+
start = Math.max(end - overlap, start + 1);
|
| 1887 |
+
}
|
| 1888 |
+
return chunks;
|
| 1889 |
+
}
|
| 1890 |
+
function cosineSimilarityOffline(a, b) {
|
| 1891 |
+
let dot = 0, magA = 0, magB = 0;
|
| 1892 |
+
for (let i = 0; i < a.length; i++) {
|
| 1893 |
+
dot += a[i] * b[i];
|
| 1894 |
+
magA += a[i] * a[i];
|
| 1895 |
+
magB += b[i] * b[i];
|
| 1896 |
+
}
|
| 1897 |
+
return dot / (Math.sqrt(magA) * Math.sqrt(magB) || 1);
|
| 1898 |
+
}
|
| 1899 |
+
|
| 1900 |
+
const OFFLINE_RAG_DB_NAME = 'noxOfflineRag';
|
| 1901 |
+
const OFFLINE_RAG_STORE = 'chunks';
|
| 1902 |
+
function openOfflineRagDb() {
|
| 1903 |
+
return new Promise((resolve, reject) => {
|
| 1904 |
+
const req = indexedDB.open(OFFLINE_RAG_DB_NAME, 1);
|
| 1905 |
+
req.onupgradeneeded = () => {
|
| 1906 |
+
const db = req.result;
|
| 1907 |
+
if (!db.objectStoreNames.contains(OFFLINE_RAG_STORE)) {
|
| 1908 |
+
const store = db.createObjectStore(OFFLINE_RAG_STORE, { keyPath: 'id', autoIncrement: true });
|
| 1909 |
+
store.createIndex('convoId', 'convoId', { unique: false });
|
| 1910 |
+
}
|
| 1911 |
+
};
|
| 1912 |
+
req.onsuccess = () => resolve(req.result);
|
| 1913 |
+
req.onerror = () => reject(req.error);
|
| 1914 |
+
});
|
| 1915 |
+
}
|
| 1916 |
+
async function addOfflineRagChunks(convoId, entries) {
|
| 1917 |
+
const db = await openOfflineRagDb();
|
| 1918 |
+
return new Promise((resolve, reject) => {
|
| 1919 |
+
const tx = db.transaction(OFFLINE_RAG_STORE, 'readwrite');
|
| 1920 |
+
const store = tx.objectStore(OFFLINE_RAG_STORE);
|
| 1921 |
+
for (const entry of entries) store.add({ convoId, ...entry });
|
| 1922 |
+
tx.oncomplete = () => resolve();
|
| 1923 |
+
tx.onerror = () => reject(tx.error);
|
| 1924 |
+
});
|
| 1925 |
+
}
|
| 1926 |
+
async function getOfflineRagChunks(convoId) {
|
| 1927 |
+
const db = await openOfflineRagDb();
|
| 1928 |
+
return new Promise((resolve, reject) => {
|
| 1929 |
+
const tx = db.transaction(OFFLINE_RAG_STORE, 'readonly');
|
| 1930 |
+
const index = tx.objectStore(OFFLINE_RAG_STORE).index('convoId');
|
| 1931 |
+
const req = index.getAll(convoId);
|
| 1932 |
+
req.onsuccess = () => resolve(req.result);
|
| 1933 |
+
req.onerror = () => reject(req.error);
|
| 1934 |
+
});
|
| 1935 |
+
}
|
| 1936 |
+
// Unlike the server's KV (24h TTL, a shared/costed resource), IndexedDB
|
| 1937 |
+
// is the visitor's own device with no server cost β so offline chunks
|
| 1938 |
+
// deliberately persist indefinitely, same as saved conversations
|
| 1939 |
+
// themselves do, rather than expiring. The one real cleanup needed is
|
| 1940 |
+
// when a saved conversation is deleted outright (see deleteConversation),
|
| 1941 |
+
// so its documents don't sit there orphaned forever with nothing that
|
| 1942 |
+
// can ever reference them again.
|
| 1943 |
+
async function deleteOfflineRagChunks(convoId) {
|
| 1944 |
+
try {
|
| 1945 |
+
const db = await openOfflineRagDb();
|
| 1946 |
+
await new Promise((resolve, reject) => {
|
| 1947 |
+
const tx = db.transaction(OFFLINE_RAG_STORE, 'readwrite');
|
| 1948 |
+
const index = tx.objectStore(OFFLINE_RAG_STORE).index('convoId');
|
| 1949 |
+
const req = index.openCursor(convoId);
|
| 1950 |
+
req.onsuccess = () => {
|
| 1951 |
+
const cursor = req.result;
|
| 1952 |
+
if (cursor) { cursor.delete(); cursor.continue(); }
|
| 1953 |
+
};
|
| 1954 |
+
tx.oncomplete = () => resolve();
|
| 1955 |
+
tx.onerror = () => reject(tx.error);
|
| 1956 |
+
});
|
| 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
|
| 1963 |
+
// local mode are both active at once.
|
| 1964 |
+
async function ingestDocumentForOfflineRag(text, name) {
|
| 1965 |
+
if (!activeConvoId) activeConvoId = makeConvoId();
|
| 1966 |
+
attachPreview.style.display = 'flex';
|
| 1967 |
+
attachPreview.innerHTML = `<span>π Indexing ${name} (offline)β¦</span>`;
|
| 1968 |
+
try {
|
| 1969 |
+
const rawChunks = chunkTextOffline(text, OFFLINE_RAG_CHUNK_SIZE, OFFLINE_RAG_CHUNK_OVERLAP);
|
| 1970 |
+
if (!rawChunks.length) throw new Error('empty document');
|
| 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) {
|
| 1978 |
+
console.error(err);
|
| 1979 |
+
alert("Couldn't index that document right now β please try again in a moment.");
|
| 1980 |
+
clearAttachment();
|
| 1981 |
+
}
|
| 1982 |
+
}
|
| 1983 |
+
|
| 1984 |
+
// Called from requestLocalReply below β same idea as the server's own
|
| 1985 |
+
// ragNote injection, just entirely local: nothing here ever touches the
|
| 1986 |
+
// network.
|
| 1987 |
+
async function retrieveOfflineRagContext(convoId, question) {
|
| 1988 |
+
if (!convoId) return '';
|
| 1989 |
+
let stored;
|
| 1990 |
+
try {
|
| 1991 |
+
stored = await getOfflineRagChunks(convoId);
|
| 1992 |
+
} catch {
|
| 1993 |
+
return '';
|
| 1994 |
+
}
|
| 1995 |
+
if (!stored || !stored.length) return '';
|
| 1996 |
+
try {
|
| 1997 |
+
const [queryVector] = await embedLocalTexts([question]);
|
| 1998 |
+
const scored = stored.map((entry) => ({ ...entry, score: cosineSimilarityOffline(queryVector, entry.vector) }));
|
| 1999 |
+
scored.sort((a, b) => b.score - a.score);
|
| 2000 |
+
const top = scored.slice(0, OFFLINE_RAG_TOP_K);
|
| 2001 |
+
const excerptText = top.map((c, i) => `[Excerpt ${i + 1}, from "${c.name}"]\n${c.text}`).join('\n\n');
|
| 2002 |
+
return `\n\nRELEVANT EXCERPTS FROM THE VISITOR'S UPLOADED DOCUMENT(S) (retrieved just now based on their question β use these to answer if they're actually relevant, and cite the document name; if these excerpts don't actually answer the question, say so honestly rather than guessing or falling back to unrelated training knowledge):\n${excerptText}`;
|
| 2003 |
+
} catch {
|
| 2004 |
+
return '';
|
| 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) {
|
|
|
|
| 2459 |
}
|
| 2460 |
try {
|
| 2461 |
const engine = await loadLocalEngine();
|
| 2462 |
+
// Injected only into THIS call's messages, never persisted into
|
| 2463 |
+
// `history` itself β same reasoning as the server's own ragNote:
|
| 2464 |
+
// the retrieved excerpts are only ever relevant to the turn that
|
| 2465 |
+
// triggered retrieval, not something to permanently bake into every
|
| 2466 |
+
// future message.
|
| 2467 |
+
const ragContext = await retrieveOfflineRagContext(activeConvoId, userMessageText);
|
| 2468 |
+
const messagesForEngine = ragContext
|
| 2469 |
+
? [{ ...history[0], content: history[0].content + ragContext }, ...history.slice(1)]
|
| 2470 |
+
: history;
|
| 2471 |
+
const chunks = await engine.chat.completions.create({ messages: messagesForEngine, stream: true });
|
| 2472 |
let full = '';
|
| 2473 |
let started = false;
|
| 2474 |
for await (const chunk of chunks) {
|
|
|
|
| 2509 |
const userMessageText = [...history].reverse().find((m) => m.role === 'user')?.content || '';
|
| 2510 |
|
| 2511 |
if (localModeActive) {
|
| 2512 |
+
// Local mode still can't handle images or a one-shot document dump
|
| 2513 |
+
// (a chat-mode attachment folded into the message as a "[Attached
|
| 2514 |
+
// file: ...]" block with the full extracted text) β the tiny local
|
| 2515 |
+
// model isn't remotely equipped for that (real report: it produced
|
| 2516 |
+
// total gibberish when handed a document this way). RAG-mode
|
| 2517 |
+
// documents are fine now, though: those get retrieved from the
|
| 2518 |
+
// offline (IndexedDB) index inside requestLocalReply itself, not
|
| 2519 |
+
// stuffed into the message here at all.
|
| 2520 |
+
if (imageBase64 || /\[Attached file:/.test(userMessageText)) {
|
| 2521 |
loadingBubble.parentElement.classList.remove('loading');
|
| 2522 |
+
loadingBubble.textContent = "Local AI mode can't read images or one-off documents β remove the attachment, switch to RAG mode to index it offline, or switch back to the server.";
|
| 2523 |
scrollToBottom();
|
| 2524 |
return;
|
| 2525 |
}
|