Chat: save before the AI writes, detect a page killed mid-answer, no 1 GB model on phones
Browse files- chat/chat.js +30 -12
chat/chat.js
CHANGED
|
@@ -28,6 +28,11 @@ function newChat() {
|
|
| 28 |
const AI_CONSENT = 'devseis.aiConsent';
|
| 29 |
// Set when the AI wrote unreadable text on this device (a faulty graphics chip or driver); holds the model version, so a new one is tried again.
|
| 30 |
const AI_FAULT = 'devseis.aiFault';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
const FIRST_WORDS_LIMIT = 60000, ANSWER_LIMIT = 180000;
|
| 32 |
let webllmPromise = null;
|
| 33 |
const loadWebllm = () => (webllmPromise ||= import(WEBLLM_URL));
|
|
@@ -43,9 +48,11 @@ function chatModel() {
|
|
| 43 |
}
|
| 44 |
// A broken-AI flag is kept per model build, so a new build gets a fresh try.
|
| 45 |
function faultKey() { const c = state.config?.chat || {}; return [c.version || 'base', c.quant].filter(Boolean).join('-'); }
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
|
| 51 |
async function modelChoice() {
|
|
@@ -231,8 +238,9 @@ async function ask(question) {
|
|
| 231 |
if (state.engine) {
|
| 232 |
// Typing dots until the AI's first words arrive; the knowledge-base answer is only the fallback if the AI fails.
|
| 233 |
reply.writing = true; reply.note = '✨ Writing answer…';
|
| 234 |
-
render();
|
| 235 |
-
|
|
|
|
| 236 |
} else {
|
| 237 |
reply.content = quickAnswer(passages);
|
| 238 |
reply.cited = true; reply.quick = true;
|
|
@@ -301,11 +309,11 @@ function fallback(reply, passages, note) {
|
|
| 301 |
}
|
| 302 |
|
| 303 |
// The AI wrote junk: free the graphics chip (a broken driver can hang the whole computer) and stay on the knowledge base here.
|
| 304 |
-
function turnOffBrokenAi() {
|
| 305 |
const engine = state.engine;
|
| 306 |
state.engine = null; state.enginePromise = null; state.loading = false; state.kbOnly = true; state.aiFault = true;
|
| 307 |
-
state.modelLabel =
|
| 308 |
-
try { localStorage.setItem(AI_FAULT, faultKey()); } catch { /* ignore */ }
|
| 309 |
if (engine && engine.unload) engine.unload().catch(() => {});
|
| 310 |
aiChip();
|
| 311 |
}
|
|
@@ -315,7 +323,8 @@ async function rewriteWithAi(index) {
|
|
| 315 |
if (!reply || !state.engine || state.busy) return;
|
| 316 |
state.busy = true; updateSend();
|
| 317 |
const messages = chatMessages(reply.question, reply.passages, auditBrief(chat.audit), reply.history || [], chat.audit ? null : deviceBrief(activeDevice()));
|
| 318 |
-
|
|
|
|
| 319 |
state.busy = false; saveChats(); updateSend(); render();
|
| 320 |
}
|
| 321 |
|
|
@@ -453,7 +462,7 @@ function renderDevicePanel() {
|
|
| 453 |
const d = activeDevice();
|
| 454 |
const row = (label, value, hint = '') => value ? `<div class="drow"><dt>${label}</dt><dd>${esc(value)}${hint ? `<small>${esc(hint)}</small>` : ''}</dd></div>` : '';
|
| 455 |
const unlock = real.platform_authenticator === true ? 'Available (fingerprint, face or PIN)' : real.platform_authenticator === false ? 'None found by the browser' : '';
|
| 456 |
-
const ai = state.aiFault ?
|
| 457 |
: 'gpu' in navigator && state.engine !== false ? 'Supported (WebGPU)' : 'Not supported here: answers come from the knowledge base';
|
| 458 |
let html;
|
| 459 |
if (audit) {
|
|
@@ -545,6 +554,9 @@ $('themeToggle').addEventListener('click', () => {
|
|
| 545 |
async function init() {
|
| 546 |
try { const t = localStorage.getItem('devseis.theme'); if (t) document.documentElement.dataset.theme = t; } catch { /* ignore */ }
|
| 547 |
state.chats = loadChats();
|
|
|
|
|
|
|
|
|
|
| 548 |
state.current = state.chats[0] || null;
|
| 549 |
const [kb, device, config] = await Promise.all([
|
| 550 |
getJson('library/knowledge.json'),
|
|
@@ -567,8 +579,14 @@ async function init() {
|
|
| 567 |
if ('gpu' in navigator) {
|
| 568 |
let consent = null; try { consent = localStorage.getItem(AI_CONSENT); } catch { /* ignore */ }
|
| 569 |
let fault = null; try { fault = localStorage.getItem(AI_FAULT); } catch { /* ignore */ }
|
| 570 |
-
|
| 571 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
if (cached || consent === 'yes') { state.cachedStart = cached; getEngine(); }
|
| 573 |
else if (consent === 'later') { state.kbOnly = true; aiChip(); }
|
| 574 |
else {
|
|
|
|
| 28 |
const AI_CONSENT = 'devseis.aiConsent';
|
| 29 |
// Set when the AI wrote unreadable text on this device (a faulty graphics chip or driver); holds the model version, so a new one is tried again.
|
| 30 |
const AI_FAULT = 'devseis.aiFault';
|
| 31 |
+
const AI_FAULT_REASON = 'devseis.aiFaultReason';
|
| 32 |
+
// Set while the AI loads or writes. Still set when the page starts again = the browser killed the page (out of memory:
|
| 33 |
+
// iOS reloads a tab that uses too much), so the AI is turned off on this device instead of crashing every question.
|
| 34 |
+
const AI_RUNNING = 'devseis.aiRunning';
|
| 35 |
+
function aiRunning(on) { try { if (on) localStorage.setItem(AI_RUNNING, faultKey()); else localStorage.removeItem(AI_RUNNING); } catch { /* ignore */ } }
|
| 36 |
const FIRST_WORDS_LIMIT = 60000, ANSWER_LIMIT = 180000;
|
| 37 |
let webllmPromise = null;
|
| 38 |
const loadWebllm = () => (webllmPromise ||= import(WEBLLM_URL));
|
|
|
|
| 48 |
}
|
| 49 |
// A broken-AI flag is kept per model build, so a new build gets a fresh try.
|
| 50 |
function faultKey() { const c = state.config?.chat || {}; return [c.version || 'base', c.quant].filter(Boolean).join('-'); }
|
| 51 |
+
// Why the AI cannot run here ('' = it can): the unrounded build is too big for a phone tab and needs 16-bit graphics.
|
| 52 |
+
async function aiUnsuitable() {
|
| 53 |
+
if (chatModel().quant !== 'q0f16') return '';
|
| 54 |
+
if (state.device && state.device.desktop === false) return 'too large for phones until the smaller version is ready';
|
| 55 |
+
try { const a = await navigator.gpu.requestAdapter(); return !a || !a.features.has('shader-f16') ? 'this graphics chip has no 16-bit support' : ''; } catch { return 'no graphics adapter'; }
|
| 56 |
}
|
| 57 |
|
| 58 |
async function modelChoice() {
|
|
|
|
| 238 |
if (state.engine) {
|
| 239 |
// Typing dots until the AI's first words arrive; the knowledge-base answer is only the fallback if the AI fails.
|
| 240 |
reply.writing = true; reply.note = '✨ Writing answer…';
|
| 241 |
+
render(); saveChats();
|
| 242 |
+
aiRunning(true);
|
| 243 |
+
try { await aiAnswer(chat, reply, messages, passages); } finally { aiRunning(false); }
|
| 244 |
} else {
|
| 245 |
reply.content = quickAnswer(passages);
|
| 246 |
reply.cited = true; reply.quick = true;
|
|
|
|
| 309 |
}
|
| 310 |
|
| 311 |
// The AI wrote junk: free the graphics chip (a broken driver can hang the whole computer) and stay on the knowledge base here.
|
| 312 |
+
function turnOffBrokenAi(reason = 'it wrote unreadable text on this device') {
|
| 313 |
const engine = state.engine;
|
| 314 |
state.engine = null; state.enginePromise = null; state.loading = false; state.kbOnly = true; state.aiFault = true;
|
| 315 |
+
state.modelLabel = `Turned off: ${reason}`; $('modelName').textContent = 'Knowledge base only';
|
| 316 |
+
try { localStorage.setItem(AI_FAULT, faultKey()); localStorage.setItem(AI_FAULT_REASON, reason); } catch { /* ignore */ }
|
| 317 |
if (engine && engine.unload) engine.unload().catch(() => {});
|
| 318 |
aiChip();
|
| 319 |
}
|
|
|
|
| 323 |
if (!reply || !state.engine || state.busy) return;
|
| 324 |
state.busy = true; updateSend();
|
| 325 |
const messages = chatMessages(reply.question, reply.passages, auditBrief(chat.audit), reply.history || [], chat.audit ? null : deviceBrief(activeDevice()));
|
| 326 |
+
saveChats(); aiRunning(true);
|
| 327 |
+
try { await aiAnswer(chat, reply, messages, reply.passages); } catch (error) { reply.note = `The AI could not answer: ${error.message}`; } finally { aiRunning(false); }
|
| 328 |
state.busy = false; saveChats(); updateSend(); render();
|
| 329 |
}
|
| 330 |
|
|
|
|
| 462 |
const d = activeDevice();
|
| 463 |
const row = (label, value, hint = '') => value ? `<div class="drow"><dt>${label}</dt><dd>${esc(value)}${hint ? `<small>${esc(hint)}</small>` : ''}</dd></div>` : '';
|
| 464 |
const unlock = real.platform_authenticator === true ? 'Available (fingerprint, face or PIN)' : real.platform_authenticator === false ? 'None found by the browser' : '';
|
| 465 |
+
const ai = state.aiFault ? state.modelLabel : state.engine ? `Ready · ${state.modelLabel}` : state.loading ? `Downloading · ${Math.round((state.progress || 0) * 100)}%`
|
| 466 |
: 'gpu' in navigator && state.engine !== false ? 'Supported (WebGPU)' : 'Not supported here: answers come from the knowledge base';
|
| 467 |
let html;
|
| 468 |
if (audit) {
|
|
|
|
| 554 |
async function init() {
|
| 555 |
try { const t = localStorage.getItem('devseis.theme'); if (t) document.documentElement.dataset.theme = t; } catch { /* ignore */ }
|
| 556 |
state.chats = loadChats();
|
| 557 |
+
// An answer still being written when the page was saved: the page was killed mid-answer. Show the knowledge-base one.
|
| 558 |
+
for (const c of state.chats) for (const m of c.messages) if (m.role === 'assistant' && (m.pending || m.writing))
|
| 559 |
+
fallback(m, m.passages || [], 'The page reloaded while the AI was writing this (the device likely ran out of memory), so this answer comes from the knowledge base.');
|
| 560 |
state.current = state.chats[0] || null;
|
| 561 |
const [kb, device, config] = await Promise.all([
|
| 562 |
getJson('library/knowledge.json'),
|
|
|
|
| 579 |
if ('gpu' in navigator) {
|
| 580 |
let consent = null; try { consent = localStorage.getItem(AI_CONSENT); } catch { /* ignore */ }
|
| 581 |
let fault = null; try { fault = localStorage.getItem(AI_FAULT); } catch { /* ignore */ }
|
| 582 |
+
let crashed = null; try { crashed = localStorage.getItem(AI_RUNNING); } catch { /* ignore */ }
|
| 583 |
+
if (crashed) {
|
| 584 |
+
aiRunning(false);
|
| 585 |
+
if (crashed === faultKey()) { turnOffBrokenAi('the page reloaded while the AI was running (this device ran out of memory)'); fault = faultKey(); }
|
| 586 |
+
}
|
| 587 |
+
let reason = 'it wrote unreadable text on this device'; try { reason = localStorage.getItem(AI_FAULT_REASON) || reason; } catch { /* ignore */ }
|
| 588 |
+
if (fault && fault === faultKey()) { state.kbOnly = true; state.aiFault = true; state.modelLabel = `Turned off: ${reason}`; $('modelName').textContent = 'Knowledge base only'; aiChip(); }
|
| 589 |
+
else aiUnsuitable().then(no => no ? (() => { state.engine = false; state.modelLabel = `Not available: ${no}`; $('modelName').textContent = 'Knowledge base only'; aiChip(); render(); updateSend(); })() : modelCached().then(cached => {
|
| 590 |
if (cached || consent === 'yes') { state.cachedStart = cached; getEngine(); }
|
| 591 |
else if (consent === 'later') { state.kbOnly = true; aiChip(); }
|
| 592 |
else {
|