Nox / index.html
Umer359's picture
Claude Sonnet 5
Bound local-mode history to fix real context-window crash, recover from device loss
925e634
Raw History Blame Contribute Delete
149 kB
<!doctype html>
<html lang="en">
<head>
<script type="text/javascript">
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "yf63tteriv");
</script>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Nox β€” by Devseis</title>
<meta name="description" content="Nox β€” a free AI assistant by Devseis. Ask anything, or attach an image or document." />
<meta property="og:type" content="website" />
<meta property="og:title" content="Nox β€” by Devseis" />
<meta property="og:description" content="A free AI assistant by Devseis. Ask anything, or attach an image or document." />
<meta property="og:image" content="https://devseis-nox.static.hf.space/devseis-logo.png" />
<meta property="og:url" content="https://devseis-nox.static.hf.space/" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Nox β€” by Devseis" />
<meta name="twitter:description" content="A free AI assistant by Devseis. Ask anything, or attach an image or document." />
<meta name="twitter:image" content="https://devseis-nox.static.hf.space/devseis-logo.png" />
<link rel="manifest" href="manifest.json" />
<meta name="theme-color" content="#0b0e14" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<link rel="icon" href="icon-192.png" id="favicon" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Code blocks are always rendered dark (see .bubble.markdown pre code below), so this
theme is loaded unconditionally rather than swapped with the page's own light/dark mode. -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/atom-one-dark.min.css" />
<style>
:root{
--bg:#0b0e14; --panel:#11151d; --border:#232a37; --text:#e8ecf4; --muted:#8b94a7;
--accent:#ff914d; --accent-text:#1a1006; --accent-soft:rgba(255,145,77,.16); --accent-border:rgba(255,145,77,.4); --bubble-bot:#161c27;
--input-bg:#2c323d;
--bg-rag:#12161f;
}
/* Default (no explicit choice) follows the OS β€” but only when the visitor
hasn't overridden it with the manual toggle, which stamps data-theme on
<html> and must win in both directions regardless of OS setting. */
@media (prefers-color-scheme: light){
:root:not([data-theme="dark"]){ --bg:#f4f6fb; --panel:#ffffff; --border:#e2e6ee; --text:#171a21; --muted:#666f80;
--accent:#004aae; --accent-text:#ffffff; --accent-soft:rgba(0,74,174,.08); --accent-border:rgba(0,74,174,.35); --bubble-bot:#f1f2f5;
--input-bg:#f4f6fb; --bg-rag:#eef1fb; }
}
:root[data-theme="light"]{
--bg:#f4f6fb; --panel:#ffffff; --border:#e2e6ee; --text:#171a21; --muted:#666f80;
--accent:#004aae; --accent-text:#ffffff; --accent-soft:rgba(0,74,174,.08); --accent-border:rgba(0,74,174,.35); --bubble-bot:#f1f2f5;
--input-bg:#f4f6fb;
--bg-rag:#eef1fb;
}
:root[data-theme="dark"]{
--bg:#0b0e14; --panel:#11151d; --border:#232a37; --text:#e8ecf4; --muted:#8b94a7;
--accent:#ff914d; --accent-text:#1a1006; --accent-soft:rgba(255,145,77,.16); --accent-border:rgba(255,145,77,.4); --bubble-bot:#161c27;
--input-bg:#2c323d;
--msg-font-size:15px;
--bg-rag:#12161f;
}
*{box-sizing:border-box;}
button:focus-visible, a:focus-visible, .chip:focus-visible, .convo-row:focus-visible{outline:2px solid var(--accent);outline-offset:2px;}
.input-pill:focus-within{box-shadow:0 0 0 2px var(--accent-border);}
html,body{height:100%;}
body{margin:0;background:var(--bg);color:var(--text);font-family:"Space Grotesk",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;height:100vh;height:100dvh;display:flex;flex-direction:column;overflow:hidden;transition:background .25s ease;}
/* Soft, deliberately subtle background shift while in RAG mode β€” the
visual cue is meant to be "something's different here", not a loud
re-theme, so this is a single small step off --bg per theme rather
than a distinct accent color. */
body.rag-mode-active{background:var(--bg-rag);}
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 20px;padding-top:calc(14px + env(safe-area-inset-top));border-bottom:1px solid var(--border);background:var(--panel);flex-shrink:0;}
.header-left{display:flex;align-items:center;gap:8px;}
.new-chat-btn{background:transparent;color:var(--text);border:1px solid var(--border);border-radius:8px;padding:0 14px;height:34px;font-size:13px;font-weight:600;cursor:pointer;}
.new-chat-btn:hover{background:var(--bg);}
.sidebar-toggle-btn{width:34px;height:34px;min-height:auto;border-radius:8px;padding:0;display:flex;align-items:center;justify-content:center;background:transparent;border:1px solid var(--border);color:var(--text);cursor:pointer;font-size:15px;}
.sidebar-toggle-btn:hover{background:var(--bg);}
.header-actions{display:flex;align-items:center;gap:8px;}
.theme-btn{width:28px;height:28px;min-height:auto;border-radius:50%;padding:0;font-size:14px;line-height:1;display:flex;align-items:center;justify-content:center;background:transparent;border:1.5px solid var(--border);cursor:pointer;color:var(--text);}
.theme-btn:hover{background:var(--bg);border-color:var(--accent);}
.lang-flags{display:flex;align-items:center;gap:3px;}
.flag-btn{width:28px;height:28px;min-height:auto;border-radius:50%;padding:0;font-size:15px;line-height:1;display:flex;align-items:center;justify-content:center;background:transparent;border:1.5px solid transparent;cursor:pointer;}
.flag-btn:hover{background:var(--bg);}
.flag-btn.active{border-color:var(--accent);background:var(--accent-soft);}
.status-bar{text-align:center;font-size:11.5px;color:var(--muted);padding:6px 10px;border-bottom:1px solid var(--border);background:var(--panel);flex-shrink:0;}
.shared-view-banner{display:flex;flex-direction:column;gap:8px;padding:12px 20px;background:var(--accent-soft);border-bottom:1px solid var(--accent-border);font-size:12.5px;flex-shrink:0;}
.shared-view-banner strong{font-weight:700;}
.shared-view-banner .btn-group{display:flex;gap:8px;}
.shared-view-banner .small-btn{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px 12px;font-size:12px;font-weight:600;cursor:pointer;min-height:auto;font-family:inherit;}
.shared-view-banner .small-btn:hover{border-color:var(--accent);}
main{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:20px;max-width:820px;width:100%;margin:0 auto;min-height:0;scrollbar-width:thin;scrollbar-color:var(--border) transparent;}
main::-webkit-scrollbar{width:8px;}
main::-webkit-scrollbar-track{background:transparent;}
main::-webkit-scrollbar-thumb{background:var(--border);border-radius:8px;}
main::-webkit-scrollbar-thumb:hover{background:var(--muted);}
.skeleton{display:inline-block;background:linear-gradient(90deg, var(--border) 25%, var(--panel) 37%, var(--border) 63%);background-size:400% 100%;animation:skeleton-shimmer 1.4s ease infinite;border-radius:6px;}
.skeleton-status{width:220px;max-width:70vw;height:11px;vertical-align:middle;}
@keyframes skeleton-shimmer{0%{background-position:100% 50%;}100%{background-position:0 50%;}}
.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center;gap:6px;padding:20px;}
.empty-state h1{font-size:28px;margin:0;font-weight:700;letter-spacing:-.02em;}
.empty-state .hero-sub{color:var(--muted);font-size:14px;margin:0 0 16px;font-weight:500;}
.chips{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;max-width:480px;}
.chip{background:var(--panel);border:1px solid var(--border);border-radius:999px;padding:9px 16px;font-size:13.5px;color:var(--text);cursor:pointer;font-weight:600;font-family:inherit;}
.chip:hover{border-color:var(--accent);}
.msg{display:flex;margin-bottom:14px;animation:msg-in .32s cubic-bezier(.22,1,.36,1);}
@keyframes msg-in{from{opacity:0;transform:translateY(10px) scale(.98);}to{opacity:1;transform:translateY(0) scale(1);}}
.msg.user{justify-content:flex-end;}
.msg.bot{gap:8px;align-items:flex-end;}
.avatar{width:26px;height:26px;border-radius:50%;background:var(--accent-soft);display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;}
.bubble{max-width:75%;padding:11px 16px;border-radius:20px;line-height:1.5;font-size:var(--msg-font-size);font-weight:500;white-space:pre-wrap;transition:background-color .15s;}
.msg.user .bubble{background:var(--accent-soft);border:1px solid var(--accent-border);border-bottom-right-radius:6px;}
.msg.bot .bubble{background:var(--bubble-bot);border-bottom-left-radius:6px;}
.typing-dots{display:inline-flex;gap:5px;align-items:center;padding:6px 2px;}
.typing-dots span{width:7px;height:7px;border-radius:50%;background:var(--accent);opacity:.4;animation:typing-bounce 1.1s infinite cubic-bezier(.45,0,.55,1);}
.typing-dots span:nth-child(2){animation-delay:.18s;}
.typing-dots span:nth-child(3){animation-delay:.36s;}
@keyframes typing-bounce{0%,80%,100%{transform:translateY(0);opacity:.35;}40%{transform:translateY(-5px);opacity:1;}}
/* Sits as its own row directly under a finished bot reply, indented to
line up under the bubble rather than the avatar (26px avatar + 8px gap
from .msg.bot above). Only added once a reply is complete β€” never shown
on the loading/typing bubble. */
.msg-actions{display:flex;gap:2px;margin:-6px 0 10px 34px;}
.msg-action-btn{background:transparent;border:none;color:var(--muted);font-size:13.5px;padding:5px 7px;border-radius:7px;cursor:pointer;min-height:auto;font-family:inherit;opacity:.6;transition:opacity .15s,background-color .15s;line-height:1;}
.msg-action-btn:hover{opacity:1;background:rgba(127,127,127,.14);}
.msg-action-btn.active{opacity:1;background:rgba(127,127,127,.14);}
.bubble.markdown{white-space:normal;}
.bubble.markdown p{margin:0 0 8px;}
.bubble.markdown p:last-child{margin-bottom:0;}
.bubble.markdown ul,.bubble.markdown ol{margin:4px 0 8px;padding-left:20px;}
.bubble.markdown pre{position:relative;background:transparent;padding:0;border-radius:10px;overflow:hidden;margin:0 0 8px;}
.bubble.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em;}
.bubble.markdown pre code{display:block;background:#282c34;color:#abb2bf;padding:14px 14px 12px;border-radius:10px;overflow-x:auto;font-size:13px;}
.bubble.markdown :not(pre)>code{background:rgba(127,127,127,.15);padding:1px 5px;border-radius:4px;}
.copy-btn{position:absolute;top:8px;right:8px;background:rgba(255,255,255,.12);color:#c9d1d9;border:none;border-radius:6px;padding:3px 9px;font-size:11px;font-weight:600;cursor:pointer;opacity:.7;font-family:inherit;min-height:auto;transition:opacity .15s,background-color .15s;}
.copy-btn:hover{opacity:1;background:rgba(255,255,255,.22);}
.bubble.markdown blockquote{margin:0 0 8px;padding-left:10px;border-left:3px solid var(--border);color:var(--muted);}
.bubble.markdown h1,.bubble.markdown h2,.bubble.markdown h3{margin:0 0 8px;font-size:1.1em;}
.bubble.markdown a{color:var(--accent);text-decoration:underline;}
.bubble.markdown table{border-collapse:collapse;width:100%;margin:0 0 8px;font-size:13.5px;display:block;overflow-x:auto;-webkit-overflow-scrolling:touch;}
.bubble.markdown th,.bubble.markdown td{border:1px solid var(--border);padding:6px 10px;text-align:left;}
.bubble.markdown th{background:rgba(127,127,127,.12);font-weight:700;}
.bubble.markdown tr:nth-child(even) td{background:rgba(127,127,127,.05);}
.composer{border-top:1px solid var(--border);background:var(--panel);flex-shrink:0;}
.attach-preview{display:flex;align-items:center;gap:8px;padding:10px 20px 0;max-width:820px;margin:0 auto;font-size:12.5px;color:var(--muted);}
.attach-preview span{background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:4px 10px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.attach-preview button{background:transparent;border:none;color:var(--muted);font-size:17px;line-height:1;cursor:pointer;padding:0 4px;min-height:auto;}
.attach-preview button:hover{color:var(--text);}
.cap-banner{padding:16px 20px;max-width:820px;margin:0 auto;background:var(--accent-soft);border-bottom:1px solid var(--accent-border);}
.cap-banner .cap-emoji{font-size:26px;flex-shrink:0;}
.cap-banner .cap-title{font-weight:700;font-size:13.5px;}
.cap-banner .cap-sub{color:var(--muted);font-size:12.5px;}
.cap-banner .cap-countdown{font-weight:700;color:var(--accent);font-variant-numeric:tabular-nums;}
.local-mode-link{background:none;border:none;color:var(--accent);font-size:12px;font-weight:700;cursor:pointer;padding:6px 0 0;font-family:inherit;text-decoration:underline;min-height:auto;display:block;}
.local-progress-track{background:rgba(127,127,127,.25);border-radius:999px;height:8px;overflow:hidden;margin-top:8px;}
.local-progress-fill{background:var(--accent);height:100%;width:0%;transition:width .25s;}
.info-term{position:relative;display:inline-block;border-bottom:1px dotted currentColor;cursor:help;}
/* Positioned via JS (left/top computed from the term's actual on-screen
rect, clamped to the viewport) rather than pure CSS anchoring β€” the
term sits at an unpredictable horizontal spot in the sentence (varies
by language, font size), so a fixed left:0 offset would overflow the
right edge on a narrow phone screen. See showLocalLimitedTooltip(). */
.tooltip-bubble{display:none;position:fixed;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:12px 14px;box-shadow:0 12px 32px rgba(0,0,0,.4);z-index:1200;font-size:12px;line-height:1.5;font-weight:500;color:var(--text);white-space:normal;max-height:60vh;overflow-y:auto;}
.tooltip-bubble ol{margin:0;padding-left:18px;}
.tooltip-bubble li{margin-bottom:8px;}
.tooltip-bubble li:last-child{margin-bottom:0;}
.tooltip-bubble strong{color:var(--accent);}
.local-mode-btn{background:transparent;border:1px solid var(--border);color:var(--text);border-radius:12px;padding:0 20px;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit;min-height:40px;width:100%;margin-top:10px;}
.local-mode-btn:hover{border-color:var(--accent);}
.sidebar-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:900;}
.sidebar{position:fixed;top:0;left:0;bottom:0;width:280px;max-width:82vw;background:var(--panel);border-right:1px solid var(--border);z-index:901;display:flex;flex-direction:column;box-shadow:8px 0 24px rgba(0,0,0,.25);animation:sidebar-in .2s ease-out;padding-top:env(safe-area-inset-top);padding-bottom:env(safe-area-inset-bottom);}
@keyframes sidebar-in{from{transform:translateX(-100%);}to{transform:translateX(0);}}
.sidebar-header{display:flex;align-items:center;justify-content:space-between;padding:16px 16px 10px;flex-shrink:0;}
.sidebar-header h2{margin:0;font-size:16px;font-weight:700;}
.sidebar-close-btn{background:transparent;border:none;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 8px;min-height:auto;line-height:1;}
.sidebar-close-btn:hover{color:var(--text);}
.sidebar-list{flex:1;overflow-y:auto;padding:4px 10px;min-height:0;}
.sidebar-empty{color:var(--muted);font-size:13px;padding:20px 12px;text-align:center;line-height:1.5;}
.convo-row{display:flex;align-items:center;gap:4px;padding:10px 10px;border-radius:10px;cursor:pointer;margin-bottom:2px;}
.convo-row:hover{background:var(--bg);}
.convo-row.active{background:var(--accent-soft);}
.convo-title{flex:1;font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.convo-delete{background:transparent;border:none;color:var(--muted);font-size:14px;cursor:pointer;padding:4px 7px;min-height:auto;border-radius:6px;opacity:.6;}
.convo-delete:hover{opacity:1;background:rgba(224,82,82,.15);color:#e05252;}
.sidebar-footer{border-top:1px solid var(--border);padding:12px 16px;display:flex;flex-direction:column;gap:10px;flex-shrink:0;}
.sidebar-footer-row{display:flex;align-items:center;justify-content:space-between;gap:8px;}
.sidebar-footer-row span{font-size:12px;color:var(--muted);font-weight:600;}
.sidebar-footer-row .btn-group{display:flex;gap:4px;}
.sidebar-footer .small-btn{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:5px 10px;font-size:12px;font-weight:600;cursor:pointer;min-height:auto;font-family:inherit;}
.sidebar-footer .small-btn:hover{border-color:var(--accent);}
.sidebar-footer .small-btn.active{background:var(--accent);color:var(--accent-text);border-color:var(--accent);}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:1000;padding:20px;padding:20px env(safe-area-inset-right) calc(20px + env(safe-area-inset-bottom)) env(safe-area-inset-left);}
.modal-box{position:relative;background:var(--panel);border:1px solid var(--border);border-radius:20px;padding:32px 28px;max-width:360px;width:100%;text-align:center;box-shadow:0 20px 60px rgba(0,0,0,.35);animation:modal-in .22s ease-out;}
@keyframes modal-in{from{opacity:0;transform:scale(.94) translateY(8px);}to{opacity:1;transform:scale(1) translateY(0);}}
.modal-emoji{font-size:42px;margin-bottom:10px;line-height:1;}
.modal-title{font-weight:700;font-size:18px;margin-bottom:8px;color:var(--text);}
.modal-sub{color:var(--muted);font-size:14px;line-height:1.5;margin-bottom:6px;}
.modal-countdown{font-weight:700;color:var(--accent);font-variant-numeric:tabular-nums;display:block;font-size:26px;margin:10px 0 22px;}
.modal-ok-btn{background:var(--accent);color:var(--accent-text);border:none;border-radius:12px;padding:0 36px;font-size:15px;font-weight:700;cursor:pointer;font-family:inherit;min-height:44px;width:100%;}
.modal-ok-btn:hover{opacity:.92;}
form{display:flex;align-items:flex-end;gap:8px;padding:16px 20px;max-width:820px;margin:0 auto;}
.input-pill{flex:1;display:flex;align-items:flex-end;background:var(--input-bg);border:1px solid var(--border);border-radius:24px;padding:9px 16px;}
textarea{flex:1;resize:none;border:none;background:transparent;color:var(--text);padding:0;font-size:16px;font-family:inherit;max-height:140px;}
textarea:focus{outline:none;}
button{background:var(--accent);color:var(--accent-text);border:none;border-radius:10px;padding:12px 18px;min-height:44px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;}
button:disabled{opacity:.5;cursor:not-allowed;}
.send-btn{width:44px;height:44px;min-height:44px;border-radius:50%;padding:0;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
.attach-btn{width:44px;height:44px;min-height:44px;border-radius:50%;padding:0;display:flex;align-items:center;justify-content:center;flex-shrink:0;background:transparent;border:1px solid var(--border);color:var(--muted);}
.attach-btn:hover{background:var(--bg);color:var(--text);}
.mic-btn{width:30px;height:30px;min-height:auto;border-radius:50%;padding:0;display:flex;align-items:center;justify-content:center;flex-shrink:0;background:transparent;border:none;color:var(--muted);margin-left:4px;}
.mic-btn:hover{color:var(--text);background:rgba(127,127,127,.12);}
.mic-btn.listening{color:#e05252;background:rgba(224,82,82,.15);animation:mic-pulse 1.2s infinite;}
@keyframes mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(224,82,82,.35);}50%{box-shadow:0 0 0 7px rgba(224,82,82,0);}}
footer{text-align:center;font-size:11px;color:var(--muted);padding:8px 12px calc(14px + env(safe-area-inset-bottom));flex-shrink:0;}
a{color:var(--accent);}
@media (max-width: 420px){
header{padding-left:14px;padding-right:14px;}
main{padding:14px;}
form{padding-left:14px;padding-right:14px;}
.empty-state h1{font-size:23px;}
.bubble{max-width:85%;}
}
@media (min-width: 1000px){
main, form{max-width:900px;}
}
</style>
</head>
<body>
<header>
<div class="header-left">
<button type="button" id="sidebarToggle" class="sidebar-toggle-btn" title="Saved chats" aria-label="Saved chats" aria-haspopup="dialog">☰</button>
<button type="button" id="newChat" class="new-chat-btn">New chat</button>
</div>
<div class="header-actions">
<button type="button" id="themeToggle" class="theme-btn" title="Toggle theme" aria-label="Toggle theme">πŸŒ™</button>
<div class="lang-flags" id="langFlags">
<button type="button" class="flag-btn" data-lang="en" title="English" aria-label="English">πŸ‡¬πŸ‡§</button>
<button type="button" class="flag-btn" data-lang="fr" title="FranΓ§ais" aria-label="FranΓ§ais">πŸ‡«πŸ‡·</button>
<button type="button" class="flag-btn" data-lang="de" title="Deutsch" aria-label="Deutsch">πŸ‡©πŸ‡ͺ</button>
<button type="button" class="flag-btn" data-lang="lb" title="LΓ«tzebuergesch" aria-label="LΓ«tzebuergesch">πŸ‡±πŸ‡Ί</button>
<button type="button" class="flag-btn" data-lang="pt" title="PortuguΓͺs" aria-label="PortuguΓͺs">πŸ‡΅πŸ‡Ή</button>
</div>
</div>
</header>
<div class="status-bar" id="statusBar"><span class="skeleton skeleton-status"></span></div>
<div class="shared-view-banner" id="sharedViewBanner" style="display:none;">
<div><strong>πŸ“‹ Viewing a shared conversation</strong> β€” read-only, nothing you type here gets sent.</div>
<div class="btn-group">
<button type="button" class="small-btn" id="continueSharedBtn">Continue this chat</button>
<button type="button" class="small-btn" id="startFreshBtn">Start new chat</button>
</div>
</div>
<main id="chatMain">
<div class="empty-state" id="emptyState">
<h1 id="heroTitle">πŸŒ™ Hi, I'm Nox.</h1>
<p class="hero-sub" id="heroSub">A free AI assistant by Devseis. Ask me anything, or attach an image or document.</p>
<div class="chips">
<button type="button" class="chip" data-prompt="What can you help me with?">πŸ’‘ What can you do?</button>
<button type="button" class="chip" data-prompt="Explain quantum computing in simple terms.">🧠 Explain something</button>
<button type="button" class="chip" data-prompt="Help me write a professional email.">✍️ Help me write</button>
<button type="button" class="chip" data-prompt="Catch me up on today's news.">πŸ“° Today's news</button>
</div>
</div>
<div id="thread" style="display:none;" aria-live="polite" role="log"></div>
</main>
<div class="sidebar-overlay" id="sidebarOverlay" style="display:none;"></div>
<aside class="sidebar" id="sidebar" role="dialog" aria-modal="true" aria-label="Saved chats" style="display:none;">
<div class="sidebar-header">
<h2 id="sidebarTitle">Chats</h2>
<button type="button" id="sidebarClose" class="sidebar-close-btn" aria-label="Close">βœ•</button>
</div>
<div class="sidebar-list" id="sidebarList"></div>
<div class="sidebar-footer">
<div class="sidebar-footer-row" id="chatRagModeRow">
<span id="chatRagModeLabel">Attachment mode</span>
<div class="btn-group">
<button type="button" id="chatModeBtn" class="small-btn active">Chat</button>
<button type="button" id="ragModeBtn" class="small-btn">RAG</button>
</div>
</div>
<div class="sidebar-footer-row" id="ragTopKRow" style="display:none;">
<span id="ragTopKLabel">Chunks per answer: <strong id="ragTopKValue">4</strong></span>
<div class="btn-group">
<button type="button" id="ragTopKDown" class="small-btn" aria-label="Fewer chunks">βˆ’</button>
<button type="button" id="ragTopKUp" class="small-btn" aria-label="More chunks">+</button>
</div>
</div>
<div class="sidebar-footer-row" id="ragDocsRow" style="display:none;">
<span>Indexed documents</span>
<button type="button" id="ragDocsBtn" class="small-btn">πŸ“š View</button>
</div>
<div class="sidebar-footer-row" id="aiModeRow" style="display:none;">
<span id="aiModeLabel">AI mode</span>
<div class="btn-group">
<button type="button" id="aiModeServerBtn" class="small-btn active">Server</button>
<button type="button" id="aiModeLocalBtn" class="small-btn">Local (beta)</button>
</div>
</div>
<div class="sidebar-footer-row">
<span id="fontSizeLabel">Text size</span>
<div class="btn-group">
<button type="button" id="fontSizeDown" class="small-btn" aria-label="Smaller text">Aβˆ’</button>
<button type="button" id="fontSizeUp" class="small-btn" aria-label="Larger text">A+</button>
</div>
</div>
<div class="sidebar-footer-row">
<span id="exportLabel">Export this chat</span>
<div class="btn-group">
<button type="button" id="exportTextBtn" class="small-btn">Text</button>
<button type="button" id="exportPdfBtn" class="small-btn">PDF</button>
</div>
</div>
<div class="sidebar-footer-row">
<span id="shareLabel">Share this chat</span>
<div class="btn-group">
<button type="button" id="shareLinkBtn" class="small-btn">Copy link</button>
<button type="button" id="shareQrBtn" class="small-btn">QR code</button>
</div>
</div>
</div>
</aside>
<div class="modal-overlay" id="qrModalOverlay" style="display:none;" role="dialog" aria-modal="true" aria-label="Share via QR code">
<div class="modal-box">
<button type="button" class="sidebar-close-btn" id="qrModalClose" aria-label="Close" style="position:absolute;top:14px;right:14px;">βœ•</button>
<div class="modal-title" id="qrModalTitle">Scan to open this chat</div>
<div class="modal-sub" style="margin-bottom:14px;">Anyone who scans this sees a read-only copy β€” they can't send messages as you.</div>
<div id="qrCodeContainer" style="display:flex;justify-content:center;background:#fff;border-radius:12px;padding:16px;margin-bottom:14px;"></div>
<button type="button" class="modal-ok-btn" id="qrCopyLinkBtn">Copy link instead</button>
</div>
</div>
<div class="modal-overlay" id="ragDocsModalOverlay" style="display:none;" role="dialog" aria-modal="true" aria-label="Indexed documents">
<div class="modal-box" style="max-width:420px;text-align:left;">
<button type="button" class="sidebar-close-btn" id="ragDocsModalClose" aria-label="Close" style="position:absolute;top:14px;right:14px;">βœ•</button>
<div class="modal-title" style="text-align:center;">Indexed documents</div>
<div class="modal-sub" id="ragDocsModalSub" style="margin-bottom:4px;text-align:center;">For this conversation</div>
<div id="ragDocsSummary" style="margin-bottom:14px;text-align:center;font-size:12px;color:var(--muted);"></div>
<div id="ragDocsList" style="display:flex;flex-direction:column;gap:8px;max-height:300px;overflow-y:auto;"></div>
</div>
</div>
<div class="modal-overlay" id="ragSourcesModalOverlay" style="display:none;" role="dialog" aria-modal="true" aria-label="Sources used for this reply">
<div class="modal-box" style="max-width:440px;text-align:left;">
<button type="button" class="sidebar-close-btn" id="ragSourcesModalClose" aria-label="Close" style="position:absolute;top:14px;right:14px;">βœ•</button>
<div class="modal-title" style="text-align:center;">Sources used for this reply</div>
<div class="modal-sub" style="margin-bottom:14px;text-align:center;">Ranked by closeness to your question</div>
<div id="ragSourcesList" style="display:flex;flex-direction:column;gap:8px;max-height:360px;overflow-y:auto;"></div>
</div>
</div>
<div class="modal-overlay" id="capModalOverlay" style="display:none;" role="dialog" aria-modal="true">
<div class="modal-box">
<div class="modal-emoji" id="modalEmoji">πŸŒ™</div>
<div class="modal-title" id="modalTitle">Nox is resting for today</div>
<div class="modal-sub" id="modalSub">Free daily limit reached</div>
<span class="modal-countdown" id="modalCountdown">--h --m --s</span>
<button type="button" class="modal-ok-btn" id="modalOkBtn">OK</button>
<button type="button" class="local-mode-btn" id="localModeBtn" style="display:none;">πŸ–₯️ Try local AI (beta) β€” runs on your device</button>
</div>
</div>
<div class="composer">
<div class="cap-banner" id="capBanner" style="display:none;">
<div class="cap-banner-normal" id="capBannerNormal" style="display:flex;align-items:center;gap:14px;">
<div class="cap-emoji">πŸŒ™</div>
<div style="flex:1;min-width:0;">
<div class="cap-title" id="capTitle">Nox is resting for today</div>
<div class="cap-sub" id="capSub">Free daily limit reached β€” back in <span class="cap-countdown" id="capCountdown">--h --m --s</span></div>
<button type="button" class="local-mode-link" id="localModeLink" style="display:none;">πŸ–₯️ Try local AI (beta) β€” runs on your device</button>
</div>
</div>
<div class="cap-banner-progress" id="capBannerProgress" style="display:none;">
<div class="cap-title">πŸ–₯️ Downloading local AI…</div>
<div class="cap-sub" id="localProgressText">Starting…</div>
<div class="local-progress-track"><div class="local-progress-fill" id="localProgressFill"></div></div>
</div>
<div class="cap-banner-active" id="capBannerActive" style="display:none;align-items:center;justify-content:space-between;gap:10px;">
<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">
<ol>
<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>
<li><strong>No images</strong> β€” it's text-only. One-off documents in Chat mode are read, but truncated much shorter than on the server (this tiny model's usable context is far smaller) β€” for longer documents, RAG mode works better offline: fully supported, indexed and searched entirely on your device.</li>
<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>
<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>
<li><strong>Speed/quality depend on your device</strong> β€” no server-grade GPU behind it, just whatever's in your laptop/phone.</li>
</ol>
</div></span>, runs on your device</div>
<button type="button" class="local-mode-link" id="exitLocalModeBtn">Use server again</button>
</div>
</div>
<div class="attach-preview" id="attachPreview" style="display:none;"></div>
<form id="form">
<button type="button" id="attachBtn" class="attach-btn" title="Attach an image or document" aria-label="Attach an image or document">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a5.5 5.5 0 01-7.78-7.78l9.2-9.19a3.5 3.5 0 014.95 4.95l-9.2 9.19a1.5 1.5 0 01-2.12-2.12l8.49-8.48"/></svg>
</button>
<input type="file" id="fileInput" accept="image/*,.pdf,.txt,.md,.csv,.json,text/plain" multiple style="display:none" />
<div class="input-pill">
<textarea id="input" rows="1" placeholder="Message Nox, or attach a file…" autofocus></textarea>
<button type="button" id="micBtn" class="mic-btn" style="display:none" title="Voice input" aria-label="Voice input">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</button>
</div>
<button type="submit" id="send" class="send-btn" title="Send" aria-label="Send message">
<svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor"><path d="M2 21l21-9L2 3v7l15 2-15 2z"/></svg>
</button>
</form>
</div>
<footer><span id="footerPoweredBy">Powered by</span> <a href="https://www.devseis.com/" target="_blank" rel="noopener">Devseis</a> &middot; <a href="privacy.html" id="footerPrivacyLink">Privacy &amp; Terms</a></footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/18.0.12/lib/marked.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.15/purify.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<!--
pdf.js was previously loaded as an ES module (`<script type="module">` +
dynamic import()), which loads in the background β€” on a slow mobile
connection a visitor could tap "attach" and pick a PDF before that import
resolved, so window.pdfjsLib was still undefined and the PDF path threw
immediately ("Couldn't read that file"). Making pdfjsLibReady a promise
fixed the ordering, but the failure persisted on mobile, pointing at ES
module support itself being the weaker link (some mobile browsers and
especially in-app webviews β€” links opened inside Instagram/WhatsApp/etc.
β€” have inconsistent `type="module"` support). Recent pdf.js (4.x/6.x) only
ships ES module builds, so this pins an older version (2.16.105) that
still ships a classic global script β€” same universally-supported pattern
already used for marked.js/DOMPurify above. Loaded as a plain blocking
script in document order, `pdfjsLib` is guaranteed to exist as a global
before the script below (which defines extractPdfText and attaches the
file-input listener) ever runs β€” no async race is even possible.
-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js";
const WORKER_URL = "https://devseis-chat-proxy.umer-wasim.workers.dev";
const TYPING_DOTS_HTML = '<span class="typing-dots"><span></span><span></span><span></span></span>';
// Flashes the tab title + favicon when a reply finishes arriving while the
// tab is backgrounded (another tab/app focused) β€” the same "you have
// something waiting" pattern as Slack/Gmail's favicon badge. Only starts if
// the tab is ACTUALLY hidden right when the reply lands (no point flashing
// at someone already looking at it), and always clears itself the moment
// the visitor switches back, via the one shared visibilitychange listener
// rather than a timeout guess.
const ORIGINAL_TITLE = document.title;
const faviconLinkEl = document.getElementById('favicon');
const NORMAL_FAVICON_HREF = 'icon-192.png';
const ALERT_FAVICON_HREF = 'icon-192-alert.png';
let replyFlashInterval = null;
function startReplyFlash() {
if (!document.hidden || replyFlashInterval) return;
let toggled = false;
replyFlashInterval = setInterval(() => {
toggled = !toggled;
document.title = toggled ? '\u{1F4AC} New reply β€” Nox' : ORIGINAL_TITLE;
if (faviconLinkEl) faviconLinkEl.href = toggled ? ALERT_FAVICON_HREF : NORMAL_FAVICON_HREF;
}, 1000);
}
function stopReplyFlash() {
if (replyFlashInterval) { clearInterval(replyFlashInterval); replyFlashInterval = null; }
document.title = ORIGINAL_TITLE;
if (faviconLinkEl) faviconLinkEl.href = NORMAL_FAVICON_HREF;
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) stopReplyFlash();
});
marked.setOptions({ breaks: true });
const linkRenderer = new marked.Renderer();
linkRenderer.link = ({ href, title, text }) =>
`<a href="${href}" target="_blank" rel="noopener noreferrer"${title ? ` title="${title}"` : ''}>${text}</a>`;
function renderMarkdown(text) {
return DOMPurify.sanitize(marked.parse(text, { renderer: linkRenderer }), { ADD_ATTR: ['target'] });
}
// Runs once per bubble, only after its content is final (mid-stream deltas
// replace the whole innerHTML each time, so highlighting during that would
// just be thrown away by the next chunk) β€” syntax-highlights every code
// block and adds a one-click copy button. Code blocks are always rendered
// in a fixed dark theme regardless of the page's own light/dark mode, same
// as most sites do, since a code block re-tinted to a light page looks worse
// than a small deliberate contrast with the surrounding bubble.
function enhanceCodeBlocks(bubble) {
if (typeof hljs === 'undefined') return;
bubble.querySelectorAll('pre code').forEach((block) => {
if (block.dataset.enhanced) return;
block.dataset.enhanced = '1';
try { hljs.highlightElement(block); } catch {}
const pre = block.parentElement;
if (!pre || pre.querySelector('.copy-btn')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'copy-btn';
btn.textContent = 'Copy';
btn.addEventListener('click', () => {
navigator.clipboard.writeText(block.textContent).then(() => {
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
}).catch(() => {});
});
pre.appendChild(btn);
});
}
// Copy + πŸ‘/πŸ‘Ž row shown under a finished bot reply. Feedback is a plain KV
// write on the backend (see worker.js recordFeedback) β€” it never touches
// Workers AI, so voting costs zero Neurons and is safe to leave always-on.
// replyText/userMessageText are captured as plain strings at call time
// (once streaming/continuation has fully finished), not read live off the
// DOM, so a later re-render of the bubble can't desync what gets copied or
// what feedback is actually about.
// Only one reply can be read aloud at a time β€” the browser only speaks one
// utterance anyway, but tracking which button is "currently speaking"
// separately from the utterance's own onend/onerror lets a click on button
// A reliably reset button A's icon even when it was interrupted by a click
// on button B calling speechSynthesis.cancel() (cancelling doesn't fire a
// consistent event across browsers to hang a reset off of).
let activeSpeakBtn = null;
let activeSpeakReset = null;
function stopSpeaking() {
if (activeSpeakReset) activeSpeakReset();
if ('speechSynthesis' in window) speechSynthesis.cancel();
activeSpeakBtn = null;
activeSpeakReset = null;
}
// Speaking the raw markdown source would read literal "asterisk asterisk"
// style noise aloud β€” strip the common syntax down to plain words first.
function stripMarkdownForSpeech(text) {
return text
.replace(/```[\s\S]*?```/g, ' code block. ')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[*_#>~]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
// Left unset, SpeechSynthesisUtterance picks whatever the OS/browser calls
// its "default" voice for a language β€” on plenty of systems (and Chrome
// especially) that default is a low-quality offline/robotic engine, not the
// natural-sounding ones the same browser actually has installed. Explicitly
// choosing a better one from the full voice list fixes that. getVoices()
// is famously asynchronous on first call in Chrome (returns empty until the
// 'voiceschanged' event fires) β€” this warms it up once at load so a voice
// is actually available by the time someone clicks πŸ”Š, and callers still
// tolerate an empty list gracefully (undefined = browser's own default).
if ('speechSynthesis' in window) {
speechSynthesis.getVoices();
speechSynthesis.addEventListener('voiceschanged', () => speechSynthesis.getVoices());
}
function pickBestVoice(langCode) {
if (!('speechSynthesis' in window)) return null;
const voices = speechSynthesis.getVoices();
if (!voices.length) return null;
const prefix = langCode.split('-')[0].toLowerCase();
const inLang = voices.filter((v) => v.lang && v.lang.toLowerCase().startsWith(prefix));
const pool = inLang.length ? inLang : voices;
// Network-backed voices (Google's Chrome voices, "Natural"/"Neural"/
// "Enhanced"/"Premium" on Apple/Microsoft) sound dramatically better than
// the compact offline synthesizers most systems fall back to.
const great = pool.find((v) => /Google|Natural|Neural|Enhanced|Premium/i.test(v.name));
if (great) return great;
const decent = pool.find((v) => !/compact|eSpeak/i.test(v.name));
return decent || pool[0];
}
function createFeedbackRow(replyText, userMessageText, sources) {
const wrap = document.createElement('div');
wrap.className = 'msg-actions';
if (sources && sources.length) {
const sourcesBtn = document.createElement('button');
sourcesBtn.type = 'button';
sourcesBtn.className = 'msg-action-btn';
sourcesBtn.title = 'View sources used for this reply';
sourcesBtn.setAttribute('aria-label', 'View sources used for this reply');
sourcesBtn.textContent = `πŸ“Ž ${sources.length}`;
sourcesBtn.addEventListener('click', () => openRagSourcesModal(sources));
wrap.appendChild(sourcesBtn);
}
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'msg-action-btn';
copyBtn.title = 'Copy reply';
copyBtn.setAttribute('aria-label', 'Copy reply');
copyBtn.textContent = 'πŸ“‹';
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(replyText).then(() => {
copyBtn.textContent = 'βœ…';
setTimeout(() => { copyBtn.textContent = 'πŸ“‹'; }, 1200);
}).catch(() => {});
});
const speakBtn = document.createElement('button');
speakBtn.type = 'button';
speakBtn.className = 'msg-action-btn';
speakBtn.title = 'Read aloud';
speakBtn.setAttribute('aria-label', 'Read aloud');
speakBtn.textContent = 'πŸ”Š';
if (!('speechSynthesis' in window)) {
speakBtn.style.display = 'none';
} else {
speakBtn.addEventListener('click', () => {
const wasActive = activeSpeakBtn === speakBtn;
stopSpeaking();
if (wasActive) return; // click again to stop β€” already reset above
const utter = new SpeechSynthesisUtterance(stripMarkdownForSpeech(replyText));
const lang = SPEECH_LANG_MAP[currentLang] || 'en-US';
utter.lang = lang;
const voice = pickBestVoice(lang);
if (voice) utter.voice = voice;
utter.rate = 1;
utter.pitch = 1;
const reset = () => {
speakBtn.textContent = 'πŸ”Š';
speakBtn.classList.remove('active');
if (activeSpeakBtn === speakBtn) { activeSpeakBtn = null; activeSpeakReset = null; }
};
utter.onend = reset;
utter.onerror = reset;
activeSpeakBtn = speakBtn;
activeSpeakReset = reset;
speakBtn.textContent = '⏹';
speakBtn.classList.add('active');
speechSynthesis.speak(utter);
});
}
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.className = 'msg-action-btn';
upBtn.title = 'Good reply';
upBtn.setAttribute('aria-label', 'Good reply');
upBtn.textContent = 'πŸ‘';
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.className = 'msg-action-btn';
downBtn.title = 'Bad reply';
downBtn.setAttribute('aria-label', 'Bad reply');
downBtn.textContent = 'πŸ‘Ž';
function vote(rating) {
upBtn.classList.toggle('active', rating === 'up');
downBtn.classList.toggle('active', rating === 'down');
fetch(WORKER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'feedback',
rating,
reply: replyText,
userMessage: userMessageText,
visitorId: getVisitorId(),
}),
}).catch(() => {});
}
upBtn.addEventListener('click', () => vote('up'));
downBtn.addEventListener('click', () => vote('down'));
wrap.append(copyBtn, speakBtn, upBtn, downBtn);
return wrap;
}
const thread = document.getElementById('thread');
const chatMain = document.getElementById('chatMain');
function scrollToBottom() {
chatMain.scrollTop = chatMain.scrollHeight;
}
// On mobile, opening or closing the on-screen keyboard resizes the visible
// viewport without firing a normal window "resize" event in every browser β€”
// visualViewport is the reliable signal for that. Without re-scrolling after
// it fires, the thread stays at whatever scroll position it had before the
// keyboard appeared, so the latest message (and sometimes the composer
// itself) can end up hidden behind the keyboard instead of staying anchored
// just above it. requestAnimationFrame catches the resize as it happens; the
// follow-up timeout re-corrects once the keyboard's own slide animation
// (typically ~250-300ms) has fully settled.
function scheduleScrollToBottom() {
requestAnimationFrame(scrollToBottom);
setTimeout(scrollToBottom, 300);
}
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', scheduleScrollToBottom);
} else {
window.addEventListener('resize', scheduleScrollToBottom);
}
const emptyState = document.getElementById('emptyState');
const form = document.getElementById('form');
const input = document.getElementById('input');
const sendBtn = document.getElementById('send');
const newChatBtn = document.getElementById('newChat');
const themeToggleBtn = document.getElementById('themeToggle');
const statusBar = document.getElementById('statusBar');
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
const attachPreview = document.getElementById('attachPreview');
const capBanner = document.getElementById('capBanner');
const capTitleEl = document.getElementById('capTitle');
const capSubEl = document.getElementById('capSub');
const capCountdownEl = document.getElementById('capCountdown');
const capModalOverlay = document.getElementById('capModalOverlay');
const modalTitleEl = document.getElementById('modalTitle');
const modalSubEl = document.getElementById('modalSub');
const modalCountdownEl = document.getElementById('modalCountdown');
const modalOkBtn = document.getElementById('modalOkBtn');
const capBannerNormalEl = document.getElementById('capBannerNormal');
const capBannerProgressEl = document.getElementById('capBannerProgress');
const capBannerActiveEl = document.getElementById('capBannerActive');
const localModeLinkEl = document.getElementById('localModeLink');
const localModeBtnEl = document.getElementById('localModeBtn');
const exitLocalModeBtnEl = document.getElementById('exitLocalModeBtn');
const localProgressTextEl = document.getElementById('localProgressText');
const localProgressFillEl = document.getElementById('localProgressFill');
const aiModeRowEl = document.getElementById('aiModeRow');
const aiModeServerBtn = document.getElementById('aiModeServerBtn');
const aiModeLocalBtn = document.getElementById('aiModeLocalBtn');
const chatModeBtn = document.getElementById('chatModeBtn');
const ragModeBtn = document.getElementById('ragModeBtn');
const ragDocsBtn = document.getElementById('ragDocsBtn');
const ragDocsModalOverlay = document.getElementById('ragDocsModalOverlay');
const ragDocsModalClose = document.getElementById('ragDocsModalClose');
const ragDocsModalSub = document.getElementById('ragDocsModalSub');
const ragDocsListEl = document.getElementById('ragDocsList');
const ragDocsSummaryEl = document.getElementById('ragDocsSummary');
const ragDocsRowEl = document.getElementById('ragDocsRow');
const ragTopKRowEl = document.getElementById('ragTopKRow');
const ragTopKDownBtn = document.getElementById('ragTopKDown');
const ragTopKUpBtn = document.getElementById('ragTopKUp');
const ragTopKValueEl = document.getElementById('ragTopKValue');
const ragSourcesModalOverlay = document.getElementById('ragSourcesModalOverlay');
const ragSourcesModalClose = document.getElementById('ragSourcesModalClose');
const ragSourcesListEl = document.getElementById('ragSourcesList');
const sidebarToggleBtn = document.getElementById('sidebarToggle');
const sidebarEl = document.getElementById('sidebar');
const sidebarOverlayEl = document.getElementById('sidebarOverlay');
const sidebarCloseBtn = document.getElementById('sidebarClose');
const sidebarListEl = document.getElementById('sidebarList');
const sidebarTitleEl = document.getElementById('sidebarTitle');
const fontSizeLabelEl = document.getElementById('fontSizeLabel');
const fontSizeDownBtn = document.getElementById('fontSizeDown');
const fontSizeUpBtn = document.getElementById('fontSizeUp');
const exportLabelEl = document.getElementById('exportLabel');
const exportTextBtn = document.getElementById('exportTextBtn');
const exportPdfBtn = document.getElementById('exportPdfBtn');
const shareLabelEl = document.getElementById('shareLabel');
const shareLinkBtn = document.getElementById('shareLinkBtn');
const shareQrBtn = document.getElementById('shareQrBtn');
const qrModalOverlay = document.getElementById('qrModalOverlay');
const qrModalClose = document.getElementById('qrModalClose');
const qrCodeContainer = document.getElementById('qrCodeContainer');
const qrCopyLinkBtn = document.getElementById('qrCopyLinkBtn');
const sharedViewBanner = document.getElementById('sharedViewBanner');
const continueSharedBtn = document.getElementById('continueSharedBtn');
const startFreshBtn = document.getElementById('startFreshBtn');
const MAX_ATTACHMENT_BYTES = 4 * 1024 * 1024; // 4MB
// Only applies in "Chat" mode's one-shot path β€” the whole point of RAG
// mode is to not need a single-prompt size ceiling at all, so this stays
// scoped to pendingOneShotDoc specifically, not a general document limit.
const MAX_ONE_SHOT_DOC_CHARS = 12000;
// The local engine (Llama-3.2-1B) has a real, hard context window of
// only 4096 tokens (confirmed via a real ContextWindowSizeExceededError
// in testing) β€” nowhere near the server model's budget. This caps a
// single one-shot attachment's own size; trimHistoryForLocalEngine below
// separately caps how much of the ongoing conversation (which can
// include EARLIER attachments) gets sent on top of it each turn.
const MAX_ONE_SHOT_DOC_CHARS_LOCAL = 2500;
let pendingImage = null; // { base64, name }
// A document attached while in "Chat" mode β€” a one-shot analysis, folded
// into just the NEXT message and then discarded, same as Nox's original
// document-attachment behavior. Never indexed, never used again after
// that one message. Mutually exclusive with RAG indexing below β€” which
// mode is active when the file is picked decides which of these two gets
// used, not anything about the file itself.
let pendingOneShotDoc = null; // { text, name }
// Chat vs. RAG only ever governs what happens to a document attached
// FROM THIS POINT FORWARD β€” deliberately not retroactive. Switching mode
// mid-conversation never re-indexes (or un-indexes) anything already
// uploaded under the other mode; a real product choice, made for
// simplicity, not an oversight. Persisted across reloads like the
// language/theme choices, defaulting to "chat" β€” the more conservative,
// original behavior β€” rather than opting every visitor into indexing
// by default.
let attachmentMode = 'chat';
function applyAttachmentMode() {
const isRag = attachmentMode === 'rag';
document.body.classList.toggle('rag-mode-active', isRag);
if (chatModeBtn) chatModeBtn.classList.toggle('active', !isRag);
if (ragModeBtn) ragModeBtn.classList.toggle('active', isRag);
// "Chunks per answer" only means anything in RAG mode β€” Chat mode's
// one-shot attachment never retrieves excerpts at all.
if (ragTopKRowEl) ragTopKRowEl.style.display = isRag ? 'flex' : 'none';
refreshRagDocsRowVisibility();
}
function setAttachmentMode(mode) {
attachmentMode = mode;
try { localStorage.setItem('noxAttachmentMode', mode); } catch {}
applyAttachmentMode();
}
function clearAttachment() {
pendingImage = null;
pendingOneShotDoc = null;
fileInput.value = '';
attachPreview.style.display = 'none';
attachPreview.innerHTML = '';
}
function showAttachmentPreview(name) {
attachPreview.style.display = 'flex';
attachPreview.innerHTML = `<span>πŸ“Ž ${name}</span><button type="button" id="clearAttach" aria-label="Remove attachment">Γ—</button>`;
document.getElementById('clearAttach').addEventListener('click', clearAttachment);
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = () => resolve(reader.result.split(',')[1]);
reader.readAsDataURL(file);
});
}
function fileToText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = () => resolve(reader.result);
reader.readAsText(file);
});
}
async function extractPdfText(file) {
const buf = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: buf }).promise;
let text = '';
const maxPages = Math.min(pdf.numPages, 30);
for (let i = 1; i <= maxPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
text += content.items.map((it) => it.str).join(' ') + '\n\n';
}
return text.trim();
}
function isPdfFile(file) {
return file.type === 'application/pdf' || /\.pdf$/i.test(file.name);
}
function isTextDocFile(file) {
return file.type.startsWith('text/') || /\.(txt|md|csv|json)$/i.test(file.name);
}
function extractFileText(file) {
return isPdfFile(file) ? extractPdfText(file) : fileToText(file);
}
attachBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async () => {
const allFiles = Array.from(fileInput.files || []);
if (!allFiles.length) return;
clearAttachment();
const oversized = allFiles.filter((f) => f.size > MAX_ATTACHMENT_BYTES);
const files = allFiles.filter((f) => f.size <= MAX_ATTACHMENT_BYTES);
if (oversized.length) {
alert(`${oversized.length === allFiles.length ? '' : oversized.length + ' file(s) skipped β€” '}Please attach files under 4MB: ${oversized.map((f) => f.name).join(', ')}`);
}
if (!files.length) { fileInput.value = ''; return; }
const images = files.filter((f) => f.type.startsWith('image/'));
const docs = files.filter((f) => !f.type.startsWith('image/') && (isPdfFile(f) || isTextDocFile(f)));
const unsupported = files.filter((f) => !images.includes(f) && !docs.includes(f));
if (unsupported.length) {
alert(`These file types aren't supported yet and were skipped: ${unsupported.map((f) => f.name).join(', ')}`);
}
// The vision endpoint takes exactly one image per message β€” a real
// backend constraint, not a client-side choice β€” so only the first
// selected image is used even when several are attached at once.
if (images.length) {
if (images.length > 1) {
alert('Only one image can be attached per message β€” using the first; the rest were skipped.');
}
try {
const base64 = await fileToBase64(images[0]);
pendingImage = { base64, name: images[0].name };
if (!docs.length) showAttachmentPreview(images[0].name);
} catch (err) {
console.error(err);
alert("Couldn't read that image. Please try another.");
pendingImage = null;
}
}
if (!docs.length) { fileInput.value = ''; return; }
if (attachmentMode === 'rag') {
// Works the same whether the engine is server or local β€” RAG mode
// never folds full document text into a message at all, it retrieves
// small, relevant excerpts per question instead, so it isn't subject
// to the tiny local model's context-window limits the way a one-shot
// dump is.
await ingestDocumentsForRag(docs);
} else {
try {
// The single-file case leaves text unwrapped here β€” the send
// handler below adds the one "[Attached file: name]" header itself.
// Only the multi-file case pre-labels each file, since then there's
// no single name for the send handler to label the whole thing
// with; `multi` tells it not to add its own header on top of these
// and end up with the file doubly (and, for 2+, confusingly
// nested-ly) labeled.
const isMulti = docs.length > 1;
const texts = await Promise.all(docs.map(async (f) => {
const t = await extractFileText(f);
return isMulti ? `[Attached file: ${f.name}]\n\n${t}` : t;
}));
const cap = localModeActive ? MAX_ONE_SHOT_DOC_CHARS_LOCAL : MAX_ONE_SHOT_DOC_CHARS;
const combined = texts.join('\n\n').trim().slice(0, cap);
pendingOneShotDoc = { text: combined, name: isMulti ? `${docs.length} files` : docs[0].name, multi: isMulti };
showAttachmentPreview(pendingOneShotDoc.name);
} catch (err) {
console.error(err);
alert("Couldn't read one of those files. Please try again.");
if (!images.length) clearAttachment();
}
}
});
// Sends one PDF/text attachment straight to the server to be chunked,
// embedded, and stored for RAG β€” indexed immediately on file select, not
// held until the next message is sent, since every question for the rest
// of THIS conversation retrieves from it, not just the one right after
// upload. Unlike the old design (the whole extracted text folded into a
// single message, capped at 12,000 characters), there's no practical
// client-side size limit here β€” the server chunks whatever comes through,
// bounded generously server-side against abuse rather than truncated
// here against a single-prompt budget that no longer applies.
async function indexOneDocumentOnServer(name, text) {
const res = await fetch(WORKER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'ingestDocument', convoId: activeConvoId, name, text })
});
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error || 'indexing failed');
}
async function indexOneDocumentOffline(name, text) {
const rawChunks = chunkTextOffline(text, OFFLINE_RAG_CHUNK_SIZE, OFFLINE_RAG_CHUNK_OVERLAP);
if (!rawChunks.length) throw new Error('empty document');
const vectors = await embedLocalTexts(rawChunks);
const entries = rawChunks.map((chunkText, i) => ({ text: chunkText, name, vector: vectors[i] }));
await addOfflineRagChunks(activeConvoId, entries);
}
// Indexes one or more files, one at a time (each is its own chunk/embed
// call β€” running them concurrently would just contend for the same rate
// limits and offline embedder), updating a single shared preview as it
// goes and reporting a combined result at the end rather than the status
// of whichever file happened to finish last.
async function ingestDocumentsForRag(files) {
if (!activeConvoId) activeConvoId = makeConvoId();
attachPreview.style.display = 'flex';
const indexOne = localModeActive ? indexOneDocumentOffline : indexOneDocumentOnServer;
const suffix = localModeActive ? ' (offline)' : '';
const succeeded = [];
const failed = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
attachPreview.innerHTML = `<span>πŸ“„ Indexing ${files.length > 1 ? `${i + 1}/${files.length}: ` : ''}${file.name}${suffix}…</span>`;
try {
const text = await extractFileText(file);
await indexOne(file.name, text);
succeeded.push(file.name);
} catch (err) {
console.error(err);
failed.push(file.name);
}
}
refreshRagDocsRowVisibility();
if (!succeeded.length) {
alert(`Couldn't index ${failed.length === 1 ? 'that document' : 'those documents'} right now β€” please try again in a moment.`);
clearAttachment();
return;
}
const summary = succeeded.length === 1
? `πŸ“„ ${succeeded[0]} (indexed${suffix})`
: `πŸ“„ ${succeeded.length} files indexed${suffix}${failed.length ? ` β€” ${failed.length} failed` : ''}`;
attachPreview.innerHTML = `<span>${summary}</span><button type="button" id="clearAttach" aria-label="Dismiss">Γ—</button>`;
document.getElementById('clearAttach').addEventListener('click', clearAttachment);
if (failed.length) alert(`These files couldn't be indexed and were skipped: ${failed.join(', ')}`);
}
const SYSTEM_PROMPT = "You are a helpful, knowledgeable AI assistant. Answer questions directly and accurately using your training knowledge, and use markdown formatting (lists, bold, code blocks) when it helps readability. " +
"When the user attaches an image, its content is included directly in their message β€” read it carefully and answer based on it. A document attached as \"[Attached file: ...]\" with its full text inline is a one-time analysis request for THIS message only β€” answer using that text, and don't assume it applies to later messages unless it's repeated. Separately, when the user has uploaded a PDF or text document earlier in this conversation under RAG mode, relevant excerpts from it are retrieved and included below as context whenever they're relevant to the current question β€” treat those excerpts as the authoritative source for anything they cover, and say so honestly if the retrieved excerpts don't actually answer what was asked, rather than guessing.";
const LANG_NAMES = { en: 'English', fr: 'French', de: 'German', lb: 'Luxembourgish', pt: 'Portuguese' };
const SUPPORTED_LANGS = ['en', 'fr', 'de', 'lb', 'pt'];
// BCP-47 codes for the Web Speech API (both recognition and synthesis).
// Luxembourgish has no browser speech-engine support anywhere, so it maps
// to English rather than silently failing.
const SPEECH_LANG_MAP = { en: 'en-US', fr: 'fr-FR', de: 'de-DE', pt: 'pt-PT', lb: 'en-US' };
const TRANSLATIONS = {
en: {
heroTitle: "πŸŒ™ Hi, I'm Nox.",
heroSub: "A free AI assistant by Devseis. Ask me anything, or attach an image or document.",
chips: [
{ emoji: "πŸ’‘", label: "What can you do?", prompt: "What can you help me with?" },
{ emoji: "🧠", label: "Explain something", prompt: "Explain quantum computing in simple terms." },
{ emoji: "✍️", label: "Help me write", prompt: "Help me write a professional email." },
{ emoji: "πŸ“°", label: "Today's news", prompt: "Catch me up on today's news." },
],
placeholder: "Message Nox, or attach a file…",
newChat: "New chat",
attachTitle: "Attach an image or document",
sendTitle: "Send",
themeToggleTitle: "Toggle theme",
privacyLink: "Privacy & Terms",
poweredBy: "Powered by",
statusDemo: "Free public demo",
statusUsedToday: "messages used today",
statusResetsIn: "resets in",
capTitle: "Nox is resting for today",
capSub: "Free daily limit reached β€” back in",
capTitleNeurons: "Nox is at capacity right now",
capSubNeurons: "Should free up in approximately",
capPlaceholder: "Nox is resting β€” come back after the reset πŸŒ™",
sidebarToggleTitle: "Saved chats",
sidebarTitle: "Chats",
sidebarEmpty: "No saved chats yet β€” start typing to save one here.",
fontSizeLabel: "Text size",
exportLabel: "Export this chat",
shareLabel: "Share this chat",
deleteChatTitle: "Delete chat",
},
fr: {
heroTitle: "πŸŒ™ Salut, je suis Nox.",
heroSub: "Un assistant IA gratuit par Devseis. Demande-moi n'importe quoi, ou joins une image ou un document.",
chips: [
{ emoji: "πŸ’‘", label: "Que sais-tu faire ?", prompt: "Qu'est-ce que tu peux m'aider Γ  faire ?" },
{ emoji: "🧠", label: "Explique quelque chose", prompt: "Explique l'informatique quantique en termes simples." },
{ emoji: "✍️", label: "Aide-moi à écrire", prompt: "Aide-moi à rédiger un e-mail professionnel." },
{ emoji: "πŸ“°", label: "ActualitΓ©s du jour", prompt: "Donne-moi les actualitΓ©s du jour." },
],
placeholder: "Γ‰cris Γ  Nox, ou joins un fichier…",
newChat: "Nouvelle discussion",
attachTitle: "Joindre une image ou un document",
sendTitle: "Envoyer",
themeToggleTitle: "Changer de thème",
privacyLink: "ConfidentialitΓ© et conditions",
poweredBy: "PropulsΓ© par",
statusDemo: "DΓ©mo publique gratuite",
statusUsedToday: "messages utilisΓ©s aujourd'hui",
statusResetsIn: "rΓ©initialisation dans",
capTitle: "Nox se repose pour aujourd'hui",
capSub: "Limite quotidienne gratuite atteinte β€” retour dans",
capTitleNeurons: "Nox est Γ  pleine capacitΓ© pour le moment",
capSubNeurons: "Devrait se libΓ©rer dans environ",
capPlaceholder: "Nox se repose β€” reviens aprΓ¨s la rΓ©initialisation πŸŒ™",
sidebarToggleTitle: "Discussions enregistrΓ©es",
sidebarTitle: "Discussions",
sidebarEmpty: "Aucune discussion enregistrΓ©e pour l'instant β€” Γ©cris un message pour en crΓ©er une.",
fontSizeLabel: "Taille du texte",
exportLabel: "Exporter cette discussion",
shareLabel: "Partager cette discussion",
deleteChatTitle: "Supprimer la discussion",
},
de: {
heroTitle: "πŸŒ™ Hallo, ich bin Nox.",
heroSub: "Ein kostenloser KI-Assistent von Devseis. Frag mich alles, oder fΓΌge ein Bild oder Dokument an.",
chips: [
{ emoji: "πŸ’‘", label: "Was kannst du?", prompt: "Wobei kannst du mir helfen?" },
{ emoji: "🧠", label: "ErklÀre etwas", prompt: "ErklÀre Quantencomputing einfach." },
{ emoji: "✍️", label: "Hilf mir beim Schreiben", prompt: "Hilf mir, eine professionelle E-Mail zu schreiben." },
{ emoji: "πŸ“°", label: "Nachrichten heute", prompt: "Bring mich auf den neuesten Stand der heutigen Nachrichten." },
],
placeholder: "Schreib Nox, oder fΓΌge eine Datei an…",
newChat: "Neuer Chat",
attachTitle: "Bild oder Dokument anhΓ€ngen",
sendTitle: "Senden",
themeToggleTitle: "Design umschalten",
privacyLink: "Datenschutz & AGB",
poweredBy: "Bereitgestellt von",
statusDemo: "Kostenlose ΓΆffentliche Demo",
statusUsedToday: "Nachrichten heute genutzt",
statusResetsIn: "ZurΓΌcksetzung in",
capTitle: "Nox macht heute Pause",
capSub: "Kostenloses Tageslimit erreicht β€” zurΓΌck in",
capTitleNeurons: "Nox ist gerade ausgelastet",
capSubNeurons: "Sollte in etwa wieder frei sein in",
capPlaceholder: "Nox macht Pause β€” komm nach dem Reset wieder πŸŒ™",
sidebarToggleTitle: "Gespeicherte Chats",
sidebarTitle: "Chats",
sidebarEmpty: "Noch keine gespeicherten Chats β€” schreib etwas, um hier einen zu speichern.",
fontSizeLabel: "Textgrâße",
exportLabel: "Diesen Chat exportieren",
shareLabel: "Diesen Chat teilen",
deleteChatTitle: "Chat lΓΆschen",
},
lb: {
heroTitle: "πŸŒ™ Moien, ech sinn den Nox.",
heroSub: "E gratis KI-Assistent vun Devseis. Frot mech alles, oder hΓ€nkt e Bild oder Dokument un.",
chips: [
{ emoji: "πŸ’‘", label: "Wat kanns du maachen?", prompt: "Womat kanns du mir hΓ«llefen?" },
{ emoji: "🧠", label: "ErklÀer eppes", prompt: "ErklÀer Quantecomputing einfach." },
{ emoji: "✍️", label: "Hëllef mir schreiwen", prompt: "Hëllef mir en professionellen E-Mail ze schreiwen." },
{ emoji: "πŸ“°", label: "Haut Neiegkeeten", prompt: "Halt mech um Lafenden iwwer d'Neiegkeeten haut." },
],
placeholder: "Schreif un den Nox, oder hΓ€nkt eng Datei un…",
newChat: "Neie Chat",
attachTitle: "Bild oder Dokument uhΓ€nken",
sendTitle: "SchΓ©cken",
themeToggleTitle: "Design wiesselen",
privacyLink: "Dateschutz & BedΓ©ngungen",
poweredBy: "Ugedriwwe vun",
statusDemo: "Gratis Γ«ffentlech Demo",
statusUsedToday: "Messagen haut benotzt",
statusResetsIn: "Reset an",
capTitle: "Nox rout haut",
capSub: "Gratis Dageslimit erreecht β€” zrΓ©ck an",
capTitleNeurons: "Den Nox ass am Moment ausgelaascht",
capSubNeurons: "Sollt an ongefΓ©ier erΓ«m frΓ€i sinn an",
capPlaceholder: "Nox rout β€” kommt nom Reset zrΓ©ck πŸŒ™",
sidebarToggleTitle: "GespΓ€ichert Chats",
sidebarTitle: "Chats",
sidebarEmpty: "Nach keng gespΓ€ichert Chats β€” schreif eppes fir ee hei ze spΓ€icheren.",
fontSizeLabel: "TextgrΓ©isst",
exportLabel: "DΓ«se Chat exportΓ©ieren",
shareLabel: "DΓ«se Chat deelen",
deleteChatTitle: "Chat lΓ€schen",
},
pt: {
heroTitle: "πŸŒ™ OlΓ‘, sou o Nox.",
heroSub: "Um assistente de IA gratuito da Devseis. Pergunta-me qualquer coisa, ou anexa uma imagem ou documento.",
chips: [
{ emoji: "πŸ’‘", label: "O que sabes fazer?", prompt: "Com o que podes me ajudar?" },
{ emoji: "🧠", label: "Explica algo", prompt: "Explica computação quÒntica de forma simples." },
{ emoji: "✍️", label: "Ajuda-me a escrever", prompt: "Ajuda-me a escrever um e-mail profissional." },
{ emoji: "πŸ“°", label: "NotΓ­cias de hoje", prompt: "Atualiza-me sobre as notΓ­cias de hoje." },
],
placeholder: "Escreve para o Nox, ou anexa um ficheiro…",
newChat: "Nova conversa",
attachTitle: "Anexar uma imagem ou documento",
sendTitle: "Enviar",
themeToggleTitle: "Alternar tema",
privacyLink: "Privacidade e Termos",
poweredBy: "Desenvolvido por",
statusDemo: "DemonstraΓ§Γ£o pΓΊblica gratuita",
statusUsedToday: "mensagens usadas hoje",
statusResetsIn: "reinicia em",
capTitle: "O Nox estΓ‘ a descansar hoje",
capSub: "Limite diΓ‘rio gratuito atingido β€” volta em",
capTitleNeurons: "O Nox estΓ‘ no limite de capacidade agora",
capSubNeurons: "Deve ficar disponΓ­vel novamente em aproximadamente",
capPlaceholder: "O Nox estΓ‘ a descansar β€” volta depois do reset πŸŒ™",
sidebarToggleTitle: "Conversas guardadas",
sidebarTitle: "Conversas",
sidebarEmpty: "Ainda nΓ£o hΓ‘ conversas guardadas β€” escreve algo para guardar uma aqui.",
fontSizeLabel: "Tamanho do texto",
exportLabel: "Exportar esta conversa",
shareLabel: "Partilhar esta conversa",
deleteChatTitle: "Eliminar conversa",
},
};
function getSavedLang() {
try {
const stored = localStorage.getItem('noxLang');
if (stored && SUPPORTED_LANGS.includes(stored)) return stored;
} catch {}
const nav = (navigator.language || 'en').slice(0, 2).toLowerCase();
return SUPPORTED_LANGS.includes(nav) ? nav : 'en';
}
let currentLang = getSavedLang();
// Three-state theme: no localStorage entry means "follow the OS" (the
// prefers-color-scheme media query alone decides); an explicit 'light' or
// 'dark' stamps data-theme on <html>, which the CSS above is written to
// always win over the media query, in both directions.
function getSavedTheme() {
try {
const stored = localStorage.getItem('noxTheme');
return stored === 'light' || stored === 'dark' ? stored : null;
} catch { return null; }
}
function effectiveIsDark() {
const explicit = document.documentElement.getAttribute('data-theme');
if (explicit) return explicit === 'dark';
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
}
function updateThemeToggleIcon() {
if (!themeToggleBtn) return;
// Icon shows what tapping it switches TO, not the current state.
themeToggleBtn.textContent = effectiveIsDark() ? 'β˜€οΈ' : 'πŸŒ™';
}
function applyTheme(theme) {
if (theme === 'light' || theme === 'dark') {
document.documentElement.setAttribute('data-theme', theme);
} else {
document.documentElement.removeAttribute('data-theme');
}
updateThemeToggleIcon();
}
if (themeToggleBtn) {
themeToggleBtn.addEventListener('click', () => {
const next = effectiveIsDark() ? 'light' : 'dark';
try { localStorage.setItem('noxTheme', next); } catch {}
applyTheme(next);
});
}
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (!document.documentElement.getAttribute('data-theme')) updateThemeToggleIcon();
});
}
applyTheme(getSavedTheme());
function buildSystemPrompt() {
const langName = LANG_NAMES[currentLang] || 'English';
return SYSTEM_PROMPT + `\n\nLANGUAGE OVERRIDE: The user has explicitly chosen to use Nox in ${langName}. Always reply in ${langName}, no matter what language they write in.`;
}
// Drives every piece of translatable page text plus the pinned reply
// language from one flag click β€” the hero copy, chips (including the
// prompt text actually sent when a chip is clicked), placeholder, button
// labels, footer link, and status bar all switch together, and the choice
// persists via the same localStorage key the privacy page reads.
function applyLanguage(lang) {
currentLang = TRANSLATIONS[lang] ? lang : 'en';
const t = TRANSLATIONS[currentLang];
document.documentElement.lang = currentLang;
document.getElementById('heroTitle').textContent = t.heroTitle;
document.getElementById('heroSub').textContent = t.heroSub;
document.querySelectorAll('.chip[data-prompt]').forEach((el, i) => {
const c = t.chips[i];
if (!c) return;
el.textContent = `${c.emoji} ${c.label}`;
el.dataset.prompt = c.prompt;
});
input.placeholder = t.placeholder;
newChatBtn.textContent = t.newChat;
attachBtn.title = t.attachTitle;
attachBtn.setAttribute('aria-label', t.attachTitle);
sendBtn.title = t.sendTitle;
sendBtn.setAttribute('aria-label', t.sendTitle);
if (themeToggleBtn) {
themeToggleBtn.title = t.themeToggleTitle;
themeToggleBtn.setAttribute('aria-label', t.themeToggleTitle);
}
if (sidebarToggleBtn) {
sidebarToggleBtn.title = t.sidebarToggleTitle;
sidebarToggleBtn.setAttribute('aria-label', t.sidebarToggleTitle);
}
if (sidebarTitleEl) sidebarTitleEl.textContent = t.sidebarTitle;
if (fontSizeLabelEl) fontSizeLabelEl.textContent = t.fontSizeLabel;
if (exportLabelEl) exportLabelEl.textContent = t.exportLabel;
if (shareLabelEl) shareLabelEl.textContent = t.shareLabel;
const privacyLink = document.getElementById('footerPrivacyLink');
if (privacyLink) privacyLink.textContent = t.privacyLink;
const poweredByEl = document.getElementById('footerPoweredBy');
if (poweredByEl) poweredByEl.textContent = t.poweredBy;
document.querySelectorAll('.flag-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.lang === currentLang);
});
try { localStorage.setItem('noxLang', currentLang); } catch {}
renderStatusBar();
if (typeof renderSidebarList === 'function') renderSidebarList();
if (capBanner.style.display !== 'none' && currentResetKind) showResetBanner(currentResetKind);
if (history.length) history[0] = { role: 'system', content: buildSystemPrompt() };
}
document.querySelectorAll('.flag-btn').forEach((btn) => {
btn.addEventListener('click', () => applyLanguage(btn.dataset.lang));
});
let history = [{ role: "system", content: buildSystemPrompt() }];
// Every real conversation (one that has at least one message) is kept as
// its own entry in one localStorage object, keyed by a generated id β€” not
// serialized as one giant blob per save, so switching chats is just picking
// a different entry rather than re-parsing everything. A brand new "New
// chat" is NOT written here until its first message; an empty conversation
// nobody typed into isn't worth cluttering the sidebar with.
const CONVOS_KEY = 'noxConversations';
const ACTIVE_CONVO_KEY = 'noxActiveConvoId';
let activeConvoId = null;
// How many retrieved document excerpts get handed to the model per
// question β€” shared by both server (sent as ragTopK) and offline/local
// RAG (used directly as the slice size), so one setting controls both
// instead of two separate, silently-diverging knobs.
const RAG_TOP_K_MIN = 1;
const RAG_TOP_K_MAX = 8;
const RAG_TOP_K_KEY = 'noxRagTopK';
function loadRagTopK() {
const stored = parseInt(localStorage.getItem(RAG_TOP_K_KEY), 10);
if (!Number.isFinite(stored)) return 4;
return Math.min(RAG_TOP_K_MAX, Math.max(RAG_TOP_K_MIN, stored));
}
let ragTopK = loadRagTopK();
function setRagTopK(value) {
ragTopK = Math.min(RAG_TOP_K_MAX, Math.max(RAG_TOP_K_MIN, value));
try { localStorage.setItem(RAG_TOP_K_KEY, String(ragTopK)); } catch {}
if (ragTopKValueEl) ragTopKValueEl.textContent = String(ragTopK);
if (ragTopKDownBtn) ragTopKDownBtn.disabled = ragTopK <= RAG_TOP_K_MIN;
if (ragTopKUpBtn) ragTopKUpBtn.disabled = ragTopK >= RAG_TOP_K_MAX;
}
function loadConversations() {
try {
const raw = localStorage.getItem(CONVOS_KEY);
const parsed = raw ? JSON.parse(raw) : {};
return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
} catch { return {}; }
}
function saveConversations(convos) {
try { localStorage.setItem(CONVOS_KEY, JSON.stringify(convos)); } catch {}
}
function getSavedActiveConvoId() {
try { return localStorage.getItem(ACTIVE_CONVO_KEY); } catch { return null; }
}
function setSavedActiveConvoId(id) {
try {
if (id) localStorage.setItem(ACTIVE_CONVO_KEY, id);
else localStorage.removeItem(ACTIVE_CONVO_KEY);
} catch {}
}
function makeConvoId() {
return 'c-' + (crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2));
}
function deriveConvoTitle(messages) {
const firstUser = messages.find((m) => m.role === 'user');
const raw = (firstUser?.content || '').replace(/\s+/g, ' ').trim();
if (!raw) return 'New chat';
return raw.length > 42 ? raw.slice(0, 42) + '…' : raw;
}
// Called after every user/assistant message lands in `history` β€” a single
// conversation's worth of JSON, not the whole store, so this stays cheap
// even with many saved chats. This is what makes a reload/crash/accidental
// tab close non-destructive: the exact `messages` array (system prompt
// excluded β€” that's rebuilt fresh from buildSystemPrompt() on load, so a
// later language or persona change applies to old chats too) is what gets
// restored.
function persistActiveConversation() {
const messages = history.filter((m) => m.role !== 'system');
if (!messages.length) return;
if (!activeConvoId) activeConvoId = makeConvoId();
const convos = loadConversations();
const existing = convos[activeConvoId];
convos[activeConvoId] = {
id: activeConvoId,
title: deriveConvoTitle(messages),
messages,
createdAt: existing?.createdAt || Date.now(),
updatedAt: Date.now(),
};
saveConversations(convos);
setSavedActiveConvoId(activeConvoId);
renderSidebarList();
}
function deleteConversation(id) {
const convos = loadConversations();
delete convos[id];
saveConversations(convos);
deleteOfflineRagChunks(id);
if (id === activeConvoId) {
startNewChat();
} else {
renderSidebarList();
}
}
function renderSidebarList() {
if (!sidebarListEl) return;
const t = TRANSLATIONS[currentLang] || TRANSLATIONS.en;
const convos = loadConversations();
const list = Object.values(convos).sort((a, b) => b.updatedAt - a.updatedAt);
sidebarListEl.innerHTML = '';
if (!list.length) {
const empty = document.createElement('div');
empty.className = 'sidebar-empty';
empty.textContent = t.sidebarEmpty;
sidebarListEl.appendChild(empty);
return;
}
list.forEach((c) => {
const row = document.createElement('div');
row.className = 'convo-row' + (c.id === activeConvoId ? ' active' : '');
row.tabIndex = 0;
row.setAttribute('role', 'button');
row.setAttribute('aria-label', c.title);
const title = document.createElement('div');
title.className = 'convo-title';
title.textContent = c.title;
const del = document.createElement('button');
del.type = 'button';
del.className = 'convo-delete';
del.title = t.deleteChatTitle;
del.setAttribute('aria-label', t.deleteChatTitle);
del.textContent = 'πŸ—‘';
del.addEventListener('click', (e) => {
e.stopPropagation();
deleteConversation(c.id);
});
row.addEventListener('click', () => loadConversationById(c.id));
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); loadConversationById(c.id); }
});
row.append(title, del);
sidebarListEl.appendChild(row);
});
}
function loadConversationById(id) {
const convos = loadConversations();
const convo = convos[id];
if (!convo) return;
if (typeof stopSpeaking === 'function') stopSpeaking();
activeConvoId = id;
setSavedActiveConvoId(id);
history = [{ role: 'system', content: buildSystemPrompt() }, ...convo.messages];
renderThreadFromHistory();
refreshRagDocsRowVisibility();
closeSidebar();
}
// Rebuilds the visible thread DOM from `history` β€” used both when
// restoring the last-open conversation on page load and when switching to
// a different saved one from the sidebar. Bot replies get their code
// blocks re-highlighted and a fresh copy/feedback row (a new vote from
// this render doesn't carry over any previous vote's highlight state β€”
// votes aren't persisted, only the messages are, which is an acceptable
// simplification since the KV record of the original vote still exists
// server-side).
function renderThreadFromHistory() {
thread.innerHTML = '';
const messages = history.filter((m) => m.role !== 'system');
if (!messages.length) {
showEmptyState();
return;
}
hideEmptyState();
let lastUserText = '';
messages.forEach((m) => {
if (m.role === 'user') {
lastUserText = m.content;
addMessage('user', m.content);
} else if (m.role === 'assistant') {
const bubble = addMessage('bot', '');
bubble.classList.add('markdown');
bubble.innerHTML = renderMarkdown(m.content);
enhanceCodeBlocks(bubble);
bubble.parentElement.after(createFeedbackRow(m.content, lastUserText));
}
});
scrollToBottom();
}
function openSidebar() {
renderSidebarList();
sidebarEl.style.display = 'flex';
sidebarOverlayEl.style.display = 'block';
sidebarCloseBtn.focus();
}
function closeSidebar() {
if (sidebarEl.style.display === 'none') return;
sidebarEl.style.display = 'none';
sidebarOverlayEl.style.display = 'none';
sidebarToggleBtn.focus();
}
sidebarToggleBtn.addEventListener('click', openSidebar);
sidebarCloseBtn.addEventListener('click', closeSidebar);
sidebarOverlayEl.addEventListener('click', closeSidebar);
// Persisted per-visitor text-size preference β€” steps rather than a free
// slider, simple to reason about and to keep in sync with a disabled state
// at each end.
const FONT_SIZES = [13, 15, 17, 19];
function getSavedFontSizeIndex() {
try {
const raw = localStorage.getItem('noxFontSizeIdx');
const idx = raw !== null ? parseInt(raw, 10) : 1;
return FONT_SIZES[idx] !== undefined ? idx : 1;
} catch { return 1; }
}
let fontSizeIdx = getSavedFontSizeIndex();
function applyFontSize() {
document.documentElement.style.setProperty('--msg-font-size', FONT_SIZES[fontSizeIdx] + 'px');
if (fontSizeDownBtn) fontSizeDownBtn.disabled = fontSizeIdx === 0;
if (fontSizeUpBtn) fontSizeUpBtn.disabled = fontSizeIdx === FONT_SIZES.length - 1;
}
if (fontSizeDownBtn) fontSizeDownBtn.addEventListener('click', () => {
if (fontSizeIdx === 0) return;
fontSizeIdx--;
applyFontSize();
try { localStorage.setItem('noxFontSizeIdx', String(fontSizeIdx)); } catch {}
});
if (fontSizeUpBtn) fontSizeUpBtn.addEventListener('click', () => {
if (fontSizeIdx === FONT_SIZES.length - 1) return;
fontSizeIdx++;
applyFontSize();
try { localStorage.setItem('noxFontSizeIdx', String(fontSizeIdx)); } catch {}
});
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
if (exportTextBtn) exportTextBtn.addEventListener('click', () => {
const messages = history.filter((m) => m.role !== 'system');
if (!messages.length) return;
const text = messages.map((m) => `${m.role === 'user' ? 'You' : 'Nox'}: ${m.content}`).join('\n\n');
downloadBlob(new Blob([text], { type: 'text/plain;charset=utf-8' }), 'nox-chat.txt');
});
if (exportPdfBtn) exportPdfBtn.addEventListener('click', () => {
const messages = history.filter((m) => m.role !== 'system');
if (!messages.length || typeof window.jspdf === 'undefined') return;
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ unit: 'pt', format: 'a4' });
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 40;
const maxWidth = pageWidth - margin * 2;
let y = margin;
doc.setFont('helvetica', 'bold');
doc.setFontSize(16);
doc.text('Nox β€” chat export', margin, y);
y += 26;
doc.setFont('helvetica', 'normal');
doc.setFontSize(11);
messages.forEach((m) => {
const label = m.role === 'user' ? 'You' : 'Nox';
const lines = doc.splitTextToSize(`${label}: ${m.content}`, maxWidth);
lines.forEach((line) => {
if (y > pageHeight - margin) {
doc.addPage();
y = margin;
}
doc.text(line, margin, y);
y += 15;
});
y += 8;
});
doc.save('nox-chat.pdf');
});
// --- Shareable read-only chat link ---------------------------------------
// The entire conversation is compressed and packed into the URL fragment
// (#shared=...) β€” never sent to any server (fragments never leave the
// browser on a normal navigation), so a shared link needs no backend
// storage at all. gzip via the native CompressionStream API when available
// (dramatically shrinks typical chat text); falls back to plain base64
// for older browsers that lack it, flagged by a leading 'g'/'r' byte so a
// link generated on one browser still opens correctly on another.
function bytesToBase64Url(bytes) {
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function base64UrlToBytes(b64url) {
let b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
async function compressToBase64Url(str) {
const cs = new CompressionStream('gzip');
const writer = cs.writable.getWriter();
writer.write(new TextEncoder().encode(str));
writer.close();
const buf = await new Response(cs.readable).arrayBuffer();
return bytesToBase64Url(new Uint8Array(buf));
}
async function decompressFromBase64Url(b64url) {
const ds = new DecompressionStream('gzip');
const writer = ds.writable.getWriter();
writer.write(base64UrlToBytes(b64url));
writer.close();
const buf = await new Response(ds.readable).arrayBuffer();
return new TextDecoder().decode(buf);
}
async function buildShareLink() {
const messages = history.filter((m) => m.role !== 'system');
if (!messages.length) return null;
// Short keys (r/c instead of role/content) β€” every byte here rides in
// the URL, so trimming the JSON itself is worth it on top of gzip.
const payload = JSON.stringify(messages.map((m) => ({ r: m.role, c: m.content })));
let flag = 'r';
let data = null;
if (typeof CompressionStream !== 'undefined') {
try {
data = await compressToBase64Url(payload);
flag = 'g';
} catch (err) {
console.error('Compression failed, falling back to raw', err);
}
}
if (!data) {
data = bytesToBase64Url(new TextEncoder().encode(payload));
flag = 'r';
}
return `${location.origin}${location.pathname}#shared=${flag}${data}`;
}
async function loadSharedFromHash() {
const prefix = '#shared=';
if (!location.hash.startsWith(prefix)) return null;
const rest = location.hash.slice(prefix.length);
const flag = rest[0];
const data = rest.slice(1);
try {
let jsonStr;
if (flag === 'g') {
if (typeof DecompressionStream === 'undefined') {
throw new Error('This link needs a newer browser to open (missing DecompressionStream).');
}
jsonStr = await decompressFromBase64Url(data);
} else {
jsonStr = new TextDecoder().decode(base64UrlToBytes(data));
}
const parsed = JSON.parse(jsonStr);
if (!Array.isArray(parsed)) throw new Error('Malformed shared conversation payload');
return parsed.map((m) => ({ role: m.r, content: m.c })).filter((m) => m.role && typeof m.content === 'string');
} catch (err) {
console.error('Failed to load shared conversation', err);
return null;
}
}
let sharedMessagesPending = null;
// Renders a shared link's conversation read-only: no feedback row (rating
// someone else's already-sent reply doesn't mean anything), composer
// disabled, and a banner offering to either fork it into an editable chat
// of the visitor's own or discard it and start fresh.
async function initSharedViewIfPresent() {
const messages = await loadSharedFromHash();
if (!messages || !messages.length) return false;
sharedMessagesPending = messages;
sharedViewBanner.style.display = 'flex';
input.disabled = true;
sendBtn.disabled = true;
attachBtn.disabled = true;
if (micBtn) micBtn.disabled = true;
hideEmptyState();
thread.innerHTML = '';
messages.forEach((m) => {
if (m.role === 'user') {
addMessage('user', m.content);
} else if (m.role === 'assistant') {
const bubble = addMessage('bot', '');
bubble.classList.add('markdown');
bubble.innerHTML = renderMarkdown(m.content);
enhanceCodeBlocks(bubble);
}
});
scrollToBottom();
return true;
}
function exitSharedView() {
sharedViewBanner.style.display = 'none';
input.disabled = false;
sendBtn.disabled = false;
attachBtn.disabled = false;
if (micBtn) micBtn.disabled = false;
window.history.replaceState(null, '', location.pathname + location.search);
}
if (continueSharedBtn) continueSharedBtn.addEventListener('click', () => {
const messages = sharedMessagesPending || [];
exitSharedView();
history = [{ role: 'system', content: buildSystemPrompt() }, ...messages];
activeConvoId = null;
persistActiveConversation();
input.focus();
});
if (startFreshBtn) startFreshBtn.addEventListener('click', () => {
exitSharedView();
startNewChat();
});
if (shareLinkBtn) shareLinkBtn.addEventListener('click', async () => {
const original = shareLinkBtn.textContent;
shareLinkBtn.disabled = true;
try {
const link = await buildShareLink();
if (!link) {
shareLinkBtn.textContent = 'Nothing to share yet';
} else {
await navigator.clipboard.writeText(link);
shareLinkBtn.textContent = 'Copied!';
}
} catch (err) {
console.error('Share link failed', err);
shareLinkBtn.textContent = 'Failed';
}
setTimeout(() => { shareLinkBtn.textContent = original; shareLinkBtn.disabled = false; }, 1500);
});
let lastQrLink = null;
if (shareQrBtn) shareQrBtn.addEventListener('click', async () => {
const link = await buildShareLink();
if (!link) return;
lastQrLink = link;
if (qrCodeContainer) {
qrCodeContainer.innerHTML = '';
if (typeof QRCode !== 'undefined') {
new QRCode(qrCodeContainer, { text: link, width: 220, height: 220, correctLevel: QRCode.CorrectLevel.M });
}
}
qrModalOverlay.style.display = 'flex';
});
if (qrModalClose) qrModalClose.addEventListener('click', () => { qrModalOverlay.style.display = 'none'; });
if (qrModalOverlay) qrModalOverlay.addEventListener('click', (e) => { if (e.target === qrModalOverlay) qrModalOverlay.style.display = 'none'; });
if (qrCopyLinkBtn) qrCopyLinkBtn.addEventListener('click', async () => {
if (!lastQrLink) return;
const original = qrCopyLinkBtn.textContent;
try {
await navigator.clipboard.writeText(lastQrLink);
qrCopyLinkBtn.textContent = 'Copied!';
} catch {
qrCopyLinkBtn.textContent = 'Failed';
}
setTimeout(() => { qrCopyLinkBtn.textContent = original; }, 1500);
});
// --- PWA share target ------------------------------------------------------
// manifest.json's share_target sends Android straight here with
// ?share_title=&share_text=&share_url= when a visitor shares a link/text
// into the installed app from any other app. Pre-fills the composer with a
// summarize-style prompt rather than auto-sending β€” a visitor should see
// and approve what's about to be sent, not have a message fire before
// they've even looked at the screen.
function handleShareTargetParams() {
const params = new URLSearchParams(location.search);
const sharedTitle = params.get('share_title');
const sharedText = params.get('share_text');
const sharedUrl = params.get('share_url');
if (!sharedTitle && !sharedText && !sharedUrl) return false;
const parts = [sharedTitle, sharedText, sharedUrl].filter(Boolean);
input.value = `Summarize this:\n\n${parts.join('\n')}`;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
window.history.replaceState(null, '', location.pathname + location.hash);
return true;
}
// Cmd/Ctrl+K jumps straight to the composer from anywhere on the page; Esc
// backs out of whatever's currently "in front" β€” the sidebar first if it's
// open, otherwise just clears a non-empty draft. Deliberately does NOT
// auto-dismiss the at-capacity modal on Esc β€” that state means every
// request would fail anyway, so hiding the explanation without the visitor
// reading it first isn't helpful.
document.addEventListener('keydown', (e) => {
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k';
if (isCmdK) {
e.preventDefault();
input.focus();
return;
}
if (e.key === 'Escape') {
if (sidebarEl.style.display !== 'none') {
closeSidebar();
return;
}
if (document.activeElement === input && input.value) {
input.value = '';
input.style.height = 'auto';
}
}
});
function showEmptyState() {
emptyState.style.display = 'flex';
thread.style.display = 'none';
}
function hideEmptyState() {
emptyState.style.display = 'none';
thread.style.display = 'block';
}
document.querySelectorAll('.chip[data-prompt]').forEach((chip) => {
chip.addEventListener('click', () => {
input.value = chip.dataset.prompt;
form.requestSubmit();
});
});
let lastUsage = null;
function msUntilUtcMidnight() {
const now = new Date();
const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1, 0, 0, 0));
return next - now;
}
function formatCountdown(ms) {
const totalMinutes = Math.max(0, Math.floor(ms / 60000));
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
return `${h}h ${m}m`;
}
function renderStatusBar() {
if (!lastUsage) return;
const t = TRANSLATIONS[currentLang] || TRANSLATIONS.en;
const { used, cap, neuronsExhausted } = lastUsage;
// neuronsExhausted reflects the REAL Cloudflare account-wide Neuron
// budget (shared with Lex) β€” distinct from used/cap, this app's own
// self-imposed counter, which can show plenty of remaining room while
// the account is actually fully out and every request is failing. Takes
// over the whole line rather than appending a caveat to the (now
// meaningless) used/cap numbers.
if (neuronsExhausted) {
statusBar.textContent = `\u{1F534} ${t.statusDemo} Β· ${t.capTitleNeurons}`;
return;
}
const dot = used >= cap ? 'πŸ”΄' : '🟒';
const countdown = formatCountdown(msUntilUtcMidnight());
statusBar.textContent = `${dot} ${t.statusDemo} Β· ${used}/${cap} ${t.statusUsedToday} Β· ${t.statusResetsIn} ${countdown}`;
}
// A visitor hitting the daily cap used to just get a flat sentence buried in
// a chat bubble, with no live sense of when they could come back. This
// banner shows proactively β€” even on a fresh page load, if the shared daily
// cap is already exhausted from other visitors β€” with a live per-second
// countdown, and disables the composer so no one wastes a typed message on
// a request that's guaranteed to fail.
let capInterval = null;
let capRecheckInterval = null;
// Which reset state the modal was last dismissed for ('cap' | 'neurons' |
// null) β€” lets the modal pop up again if the underlying cause CHANGES
// (e.g. the message cap resets but Neurons are still exhausted) without
// re-interrupting the visitor every ~30s poll for the SAME already-seen
// state.
let capModalDismissedFor = null;
let currentResetKind = null;
function formatCountdownFull(ms) {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
return `${h}h ${m}m ${s}s`;
}
function tickCapCountdown() {
const ms = msUntilUtcMidnight();
if (ms <= 0) {
hideResetBanner();
fetchStats();
return;
}
const formatted = formatCountdownFull(ms);
capCountdownEl.textContent = formatted;
modalCountdownEl.textContent = formatted;
}
// --- Local AI fallback mode --------------------------------------------
// When the REAL Cloudflare Neuron budget (not just this app's own daily
// message cap) is exhausted, a visitor can opt into running a small model
// entirely in their own browser via WebGPU (@mlc-ai/web-llm) instead of
// waiting it out. This is a real capability downgrade versus the server's
// best ("Smart", 8B). Went through two model choices before landing here:
// the original 360M model was too weak (noticeably less coherent), then
// a 3B model (same class as the server's "Standard" tier) fixed that but
// real report β€” the 3B model made things noticeably slow, especially on
// mobile, where WebGPU memory budgets are often under 1-1.5GB. Settled on
// Llama-3.2-1B β€” same Meta family/generation the 3B was, and specifically
// one of the models Meta built for on-device/edge use, at roughly 1/2.6
// the VRAM (verified directly against WebLLM's own prebuilt config
// before shipping: 879MB vs. 2,264MB) β€” real, if smaller, capability gap
// versus 3B, but nowhere near as weak as the original 360M was. Opt-in,
// clearly labeled everywhere it's active, and only ever offered when the
// browser actually supports it: WebGPU has no CPU/WASM fallback in this
// library, so there's no point offering a button that would just fail.
// esm.run's ES-module-only build is fine here (unlike the earlier pdf.js
// mobile-webview issue) because any browser new enough to have WebGPU
// support is already far newer than the module-support gap that caused
// that bug.
const HAS_WEBGPU = typeof navigator !== 'undefined' && !!navigator.gpu;
const LOCAL_MODEL_ID = 'Llama-3.2-1B-Instruct-q4f16_1-MLC';
// Mirrors worker.js's own CRISIS_RE / CRISIS_RESPONSE exactly. Local mode
// bypasses the server entirely, so without this a crisis message would go
// straight to an unsupervised local model with zero safety net β€” this
// check must never be skipped, even though it duplicates server logic.
const LOCAL_CRISIS_RE = /\b(kill myself|suicid\w*|end my life|want(ed)? to die|don'?t want to (live|be alive)|self.?harm|hurt(ing)? myself|cutting myself|no reason to live|better off dead)\b/i;
const LOCAL_CRISIS_RESPONSE = "Hey β€” I'm really glad you told me, but I'm just an AI and I'm not equipped to help with something this serious. Please reach out to people who can: SOS DΓ©tresse (Luxembourg) at 116 123, free and confidential, or 112 for emergencies. Please also talk to someone you trust β€” a parent, teacher, or doctor. You deserve real support. \u{1F499}";
let localModeActive = false;
let localEngine = null;
let localEngineLoadPromise = null;
function setLocalProgress(fraction, text) {
if (localProgressFillEl) localProgressFillEl.style.width = Math.round(Math.max(0, Math.min(1, fraction)) * 100) + '%';
if (localProgressTextEl) localProgressTextEl.textContent = text || '';
}
// Cached in a module-level promise (not just the resolved engine) so a
// double-click or a second message sent mid-download reuses the SAME
// in-flight load instead of kicking off a second multi-hundred-MB fetch.
async function loadLocalEngine() {
if (localEngine) return localEngine;
if (localEngineLoadPromise) return localEngineLoadPromise;
localEngineLoadPromise = (async () => {
const webllm = await import('https://esm.run/@mlc-ai/web-llm');
const engine = await webllm.CreateMLCEngine(LOCAL_MODEL_ID, {
initProgressCallback: (report) => {
setLocalProgress(report.progress || 0, report.text || 'Loading…');
},
});
localEngine = engine;
return engine;
})();
return localEngineLoadPromise;
}
// --- Offline RAG (local mode + RAG attachment mode together) -----------
// Mirrors the server-side RAG feature (chunk -> embed -> store -> cosine-
// similarity retrieve -> inject as context) entirely client-side, for
// visitors who want document Q&A with zero network calls at all. WebLLM
// (already used for local chat) doesn't do embeddings, so this pulls in
// a second, much smaller model via Transformers.js β€” the same lazy,
// load-once-then-cached pattern as loadLocalEngine() above. Storage uses
// IndexedDB (not the server's KV) since this needs to work fully
// offline and never leave the device.
let localEmbedder = null;
let localEmbedderLoadPromise = null;
async function loadLocalEmbedder() {
if (localEmbedder) return localEmbedder;
if (localEmbedderLoadPromise) return localEmbedderLoadPromise;
localEmbedderLoadPromise = (async () => {
const { pipeline } = await import('https://esm.run/@huggingface/transformers');
// ~23MB quantized β€” a much smaller one-time download than the chat
// model, and WebGPU is already confirmed available by this point
// (local mode can't be entered at all without it, see HAS_WEBGPU).
const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { device: 'webgpu' });
localEmbedder = extractor;
return extractor;
})();
return localEmbedderLoadPromise;
}
async function embedLocalTexts(texts) {
const extractor = await loadLocalEmbedder();
const output = await extractor(texts, { pooling: 'mean', normalize: true });
return output.tolist();
}
// Same chunking/similarity logic as the server's chunkText/
// cosineSimilarity β€” kept as plain, dependency-free JS specifically so
// it's trivially portable between the two, not because of any DOM/
// browser-specific need.
const OFFLINE_RAG_CHUNK_SIZE = 800;
const OFFLINE_RAG_CHUNK_OVERLAP = 100;
function chunkTextOffline(text, chunkSize, overlap) {
const chunks = [];
let start = 0;
while (start < text.length) {
let end = Math.min(start + chunkSize, text.length);
if (end < text.length) {
const lastSpace = text.lastIndexOf(' ', end);
if (lastSpace > start + chunkSize * 0.5) end = lastSpace;
}
const piece = text.slice(start, end).trim();
if (piece) chunks.push(piece);
if (end >= text.length) break;
start = Math.max(end - overlap, start + 1);
}
return chunks;
}
function cosineSimilarityOffline(a, b) {
let dot = 0, magA = 0, magB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
magA += a[i] * a[i];
magB += b[i] * b[i];
}
return dot / (Math.sqrt(magA) * Math.sqrt(magB) || 1);
}
const OFFLINE_RAG_DB_NAME = 'noxOfflineRag';
const OFFLINE_RAG_STORE = 'chunks';
function openOfflineRagDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(OFFLINE_RAG_DB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(OFFLINE_RAG_STORE)) {
const store = db.createObjectStore(OFFLINE_RAG_STORE, { keyPath: 'id', autoIncrement: true });
store.createIndex('convoId', 'convoId', { unique: false });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function addOfflineRagChunks(convoId, entries) {
const db = await openOfflineRagDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(OFFLINE_RAG_STORE, 'readwrite');
const store = tx.objectStore(OFFLINE_RAG_STORE);
for (const entry of entries) store.add({ convoId, ...entry });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function getOfflineRagChunks(convoId) {
const db = await openOfflineRagDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(OFFLINE_RAG_STORE, 'readonly');
const index = tx.objectStore(OFFLINE_RAG_STORE).index('convoId');
const req = index.getAll(convoId);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// Unlike the server's KV (24h TTL, a shared/costed resource), IndexedDB
// is the visitor's own device with no server cost β€” so offline chunks
// deliberately persist indefinitely, same as saved conversations
// themselves do, rather than expiring. The one real cleanup needed is
// when a saved conversation is deleted outright (see deleteConversation),
// so its documents don't sit there orphaned forever with nothing that
// can ever reference them again.
async function deleteOfflineRagChunks(convoId) {
try {
const db = await openOfflineRagDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(OFFLINE_RAG_STORE, 'readwrite');
const index = tx.objectStore(OFFLINE_RAG_STORE).index('convoId');
const req = index.openCursor(convoId);
req.onsuccess = () => {
const cursor = req.result;
if (cursor) { cursor.delete(); cursor.continue(); }
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {}
}
// Groups stored chunks by document name β€” the offline mirror of the
// server's listDocumentChunks, same reasoning: shows what's actually in
// IndexedDB right now, not a client-side guess.
async function listOfflineRagDocuments(convoId) {
const stored = await getOfflineRagChunks(convoId);
const byName = new Map();
for (const entry of stored) byName.set(entry.name, (byName.get(entry.name) || 0) + 1);
return Array.from(byName, ([name, chunks]) => ({ name, chunks }));
}
// Removing one document offline β€” same idea as removeDocumentChunks on
// the server (filter it out, re-save), but IndexedDB does this as a
// cursor delete rather than a read-filter-rewrite of a whole JSON blob.
// "The index recalculated" is really just "future retrieval scans see
// one fewer row" β€” nothing to explicitly rebuild.
async function removeOfflineRagDocument(convoId, name) {
const db = await openOfflineRagDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(OFFLINE_RAG_STORE, 'readwrite');
const index = tx.objectStore(OFFLINE_RAG_STORE).index('convoId');
const req = index.openCursor(convoId);
req.onsuccess = () => {
const cursor = req.result;
if (cursor) {
if (cursor.value.name === name) cursor.delete();
cursor.continue();
}
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// Called from requestLocalReply below β€” same idea as the server's own
// ragNote injection, just entirely local: nothing here ever touches the
// network.
async function retrieveOfflineRagContext(convoId, question) {
if (!convoId) return { context: '', chunks: [] };
let stored;
try {
stored = await getOfflineRagChunks(convoId);
} catch {
return { context: '', chunks: [] };
}
if (!stored || !stored.length) return { context: '', chunks: [] };
try {
const [queryVector] = await embedLocalTexts([question]);
const scored = stored.map((entry) => ({ ...entry, score: cosineSimilarityOffline(queryVector, entry.vector) }));
scored.sort((a, b) => b.score - a.score);
const top = scored.slice(0, ragTopK);
const excerptText = top.map((c, i) => `[Excerpt ${i + 1}, from "${c.name}"]\n${c.text}`).join('\n\n');
const context = `\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}`;
const chunks = top.map((c) => ({ name: c.name, score: Math.round(c.score * 1000) / 1000, text: c.text }));
return { context, chunks };
} catch {
return { context: '', chunks: [] };
}
}
// --- View/manage indexed documents (works for both online and offline
// RAG) --------------------------------------------------------------
// Always does a FRESH fetch/query when opened rather than trusting any
// client-side cache β€” this is the real, current state (KV for online,
// IndexedDB for offline), the same source of truth retrieval itself
// reads from, so what's shown here can never drift from what's actually
// searchable.
async function fetchRagDocsList() {
if (!activeConvoId) return [];
if (localModeActive) {
return listOfflineRagDocuments(activeConvoId);
}
try {
const res = await fetch(WORKER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'listDocuments', convoId: activeConvoId })
});
const data = await res.json();
return data.ok ? data.documents : [];
} catch {
return [];
}
}
// The "Indexed documents" row is only worth showing once there's
// something to view or manage β€” re-checked on every mode switch,
// conversation switch, upload, and removal, always against the real
// current list (never a locally-tracked count that could drift).
let ragDocsRowVisibilityToken = 0;
async function refreshRagDocsRowVisibility() {
if (!ragDocsRowEl) return;
if (attachmentMode !== 'rag' || !activeConvoId) {
ragDocsRowEl.style.display = 'none';
return;
}
const token = ++ragDocsRowVisibilityToken;
const documents = await fetchRagDocsList();
if (token !== ragDocsRowVisibilityToken) return; // a newer check superseded this one
ragDocsRowEl.style.display = documents.length ? 'flex' : 'none';
}
async function removeRagDocByName(name) {
if (!activeConvoId) return;
if (localModeActive) {
await removeOfflineRagDocument(activeConvoId, name);
} else {
try {
await fetch(WORKER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'removeDocument', convoId: activeConvoId, name })
});
} catch {}
}
}
async function renderRagDocsList() {
ragDocsListEl.innerHTML = '<div style="text-align:center;color:var(--muted);font-size:13px;padding:12px 0;">Loading…</div>';
ragDocsSummaryEl.textContent = '';
const documents = await fetchRagDocsList();
if (!documents.length) {
ragDocsListEl.innerHTML = '<div style="text-align:center;color:var(--muted);font-size:13px;padding:12px 0;">No documents indexed in this conversation yet.</div>';
return;
}
// One embedding vector per chunk, always β€” there's no separate
// per-document embedding count to report, so this is really one
// number shown two ways for anyone expecting them to differ.
const totalChunks = documents.reduce((sum, doc) => sum + (doc.chunks || 0), 0);
ragDocsSummaryEl.textContent = `${documents.length} document${documents.length === 1 ? '' : 's'} β€” ${totalChunks} chunk${totalChunks === 1 ? '' : 's'} / ${totalChunks} embedding${totalChunks === 1 ? '' : 's'}`;
ragDocsListEl.innerHTML = '';
documents.forEach((doc) => {
const row = document.createElement('div');
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;';
const label = document.createElement('span');
label.style.cssText = 'font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
label.textContent = `πŸ“„ ${doc.name} β€” ${doc.chunks} chunk${doc.chunks === 1 ? '' : 's'}`;
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'small-btn';
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', async () => {
removeBtn.disabled = true;
removeBtn.textContent = 'Removing…';
await removeRagDocByName(doc.name);
// Re-fetch rather than just dropping this row locally β€” this is
// the "index recalculated" step the removal itself already did
// server/IndexedDB-side; re-rendering from a fresh fetch is what
// actually shows that, rather than trusting the removal succeeded.
renderRagDocsList();
refreshRagDocsRowVisibility();
});
row.append(label, removeBtn);
ragDocsListEl.appendChild(row);
});
}
function openRagDocsModal() {
ragDocsModalSub.textContent = localModeActive ? 'For this conversation (stored offline on this device)' : 'For this conversation';
ragDocsModalOverlay.style.display = 'flex';
renderRagDocsList();
}
if (ragDocsBtn) ragDocsBtn.addEventListener('click', openRagDocsModal);
if (ragDocsModalClose) ragDocsModalClose.addEventListener('click', () => { ragDocsModalOverlay.style.display = 'none'; });
if (ragDocsModalOverlay) ragDocsModalOverlay.addEventListener('click', (e) => { if (e.target === ragDocsModalOverlay) ragDocsModalOverlay.style.display = 'none'; });
// Shows exactly what was retrieved and handed to the model for one reply β€”
// built fresh from the metadata that reply's own response carried (a
// response header for streamed replies, a JSON field for the buffered
// fallback path), never re-queried, since re-querying now could return a
// different top-K than what the model actually saw at the time.
function openRagSourcesModal(sources) {
ragSourcesListEl.innerHTML = '';
sources.forEach((chunk, i) => {
const row = document.createElement('div');
row.style.cssText = 'padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;';
const head = document.createElement('div');
head.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:4px;';
const label = document.createElement('span');
label.style.cssText = 'font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
label.textContent = `${i + 1}. πŸ“„ ${chunk.name}`;
const score = document.createElement('span');
score.style.cssText = 'font-size:12px;color:var(--muted);white-space:nowrap;';
score.textContent = `${Math.round((chunk.score || 0) * 100)}% match`;
head.append(label, score);
const excerpt = document.createElement('div');
excerpt.style.cssText = 'font-size:12.5px;color:var(--muted);white-space:pre-wrap;';
excerpt.textContent = chunk.text || '';
row.append(head, excerpt);
ragSourcesListEl.appendChild(row);
});
ragSourcesModalOverlay.style.display = 'flex';
}
if (ragSourcesModalClose) ragSourcesModalClose.addEventListener('click', () => { ragSourcesModalOverlay.style.display = 'none'; });
if (ragSourcesModalOverlay) ragSourcesModalOverlay.addEventListener('click', (e) => { if (e.target === ragSourcesModalOverlay) ragSourcesModalOverlay.style.display = 'none'; });
// Lets a visitor trade retrieval breadth for prompt focus β€” more chunks
// means more chance the right excerpt is included, but also more
// unrelated text for the model to sift through per answer.
setRagTopK(ragTopK);
if (ragTopKDownBtn) ragTopKDownBtn.addEventListener('click', () => setRagTopK(ragTopK - 1));
if (ragTopKUpBtn) ragTopKUpBtn.addEventListener('click', () => setRagTopK(ragTopK + 1));
// Which of the banner's three mutually-exclusive views is showing β€”
// the outer .cap-banner container itself just handles positioning/padding.
function showBannerView(view) {
if (capBannerNormalEl) capBannerNormalEl.style.display = view === 'normal' ? 'flex' : 'none';
if (capBannerProgressEl) capBannerProgressEl.style.display = view === 'progress' ? 'block' : 'none';
if (capBannerActiveEl) capBannerActiveEl.style.display = view === 'active' ? 'flex' : 'none';
}
// Reflects current/loading state on the always-available sidebar toggle β€”
// kept in sync from every place localModeActive changes, not just the
// exhaustion-triggered flow, since this same pair of functions now also
// backs an on-demand switch a visitor can use anytime, unrelated to the
// server actually being out of capacity.
function updateAiModeButtons(loading) {
if (!aiModeServerBtn || !aiModeLocalBtn) return;
aiModeServerBtn.classList.toggle('active', !localModeActive);
aiModeLocalBtn.classList.toggle('active', localModeActive);
aiModeLocalBtn.disabled = !!loading;
aiModeServerBtn.disabled = !!loading;
aiModeLocalBtn.textContent = loading ? 'Loading…' : 'Local (beta)';
}
async function enableLocalMode() {
if (!HAS_WEBGPU || localModeActive) return;
capModalOverlay.style.display = 'none';
capModalDismissedFor = currentResetKind;
// Local mode owns the banner/composer state from here β€” stop the normal
// cap countdown/recheck timers so they can't fight with it (see the
// localModeActive guards in showResetBanner/hideResetBanner below).
if (capInterval) { clearInterval(capInterval); capInterval = null; }
if (capRecheckInterval) { clearInterval(capRecheckInterval); capRecheckInterval = null; }
capBanner.style.display = 'block';
showBannerView('progress');
setLocalProgress(0, 'Starting…');
updateAiModeButtons(true);
try {
await loadLocalEngine();
localModeActive = true;
showBannerView('active');
input.disabled = false;
sendBtn.disabled = false;
attachBtn.disabled = false;
input.placeholder = (TRANSLATIONS[currentLang] || TRANSLATIONS.en).placeholder;
updateAiModeButtons(false);
refreshRagDocsRowVisibility(); // switches from KV-backed to IndexedDB-backed documents
} catch (err) {
console.error('Local AI load failed', err);
localEngine = null;
localEngineLoadPromise = null;
showBannerView('normal');
updateAiModeButtons(false);
fetchStats(); // fall back to whatever the real server-side state actually is
}
}
function exitLocalMode() {
localModeActive = false;
localEngine = null;
localEngineLoadPromise = null;
if (typeof stopSpeaking === 'function') stopSpeaking();
updateAiModeButtons(false);
fetchStats();
refreshRagDocsRowVisibility(); // back to KV-backed documents
}
if (localModeBtnEl) localModeBtnEl.addEventListener('click', enableLocalMode);
if (localModeLinkEl) localModeLinkEl.addEventListener('click', enableLocalMode);
if (exitLocalModeBtnEl) exitLocalModeBtnEl.addEventListener('click', exitLocalMode);
// The always-available manual switch, independent of whether the server is
// actually out of capacity β€” "Server" only does anything when local mode
// is currently the active one (clicking it while already on the server is
// a harmless no-op).
if (aiModeLocalBtn) aiModeLocalBtn.addEventListener('click', enableLocalMode);
if (aiModeServerBtn) aiModeServerBtn.addEventListener('click', () => { if (localModeActive) exitLocalMode(); });
if (chatModeBtn) chatModeBtn.addEventListener('click', () => setAttachmentMode('chat'));
if (ragModeBtn) ragModeBtn.addEventListener('click', () => setAttachmentMode('rag'));
try {
const savedMode = localStorage.getItem('noxAttachmentMode');
if (savedMode === 'rag' || savedMode === 'chat') attachmentMode = savedMode;
} catch {}
applyAttachmentMode();
if (aiModeRowEl && HAS_WEBGPU) aiModeRowEl.style.display = 'flex';
// The ":hover"/":focus" CSS alone covers desktop and most keyboard nav,
// but a tap on mobile doesn't reliably keep an element "hovered" long
// enough to read a multi-paragraph tooltip β€” an explicit toggle (open on
// tap, close on any tap elsewhere) makes it usable there too.
const localLimitedTerm = document.getElementById('localLimitedTerm');
const localLimitedTooltip = document.getElementById('localLimitedTooltip');
// True hover only on devices that actually have a pointer capable of it β€”
// on touch, "hover" fires on tap in a way that fights with the click
// toggle below (tap would open-then-immediately-close), so touch relies
// purely on the click handler instead.
const supportsHover = !!(window.matchMedia && window.matchMedia('(hover: hover)').matches);
function showLocalLimitedTooltip() {
if (!localLimitedTerm || !localLimitedTooltip) return;
const margin = 12;
const termRect = localLimitedTerm.getBoundingClientRect();
const width = Math.min(300, window.innerWidth - margin * 2);
localLimitedTooltip.style.width = width + 'px';
localLimitedTooltip.style.display = 'block';
const height = localLimitedTooltip.offsetHeight;
let left = termRect.left;
if (left + width > window.innerWidth - margin) left = window.innerWidth - margin - width;
if (left < margin) left = margin;
let top = termRect.top - height - 9;
if (top < margin) top = termRect.bottom + 9; // flip below if there's no room above
localLimitedTooltip.style.left = left + 'px';
localLimitedTooltip.style.top = top + 'px';
}
function hideLocalLimitedTooltip() {
if (localLimitedTooltip) localLimitedTooltip.style.display = 'none';
if (localLimitedTerm) localLimitedTerm.setAttribute('aria-expanded', 'false');
}
// Tracked separately from the tooltip's own display state, not inferred
// from it β€” the term has tabindex="0", so a real click/tap ALSO fires a
// native `focus` event first (browsers focus a focusable element before
// dispatching its click), which independently shows the tooltip. Reading
// "is it currently visible" inside the click handler was therefore always
// true by the time click ran, making every tap open-then-immediately-close
// it. A dedicated flag that only the click handler itself flips sidesteps
// that ordering entirely β€” confirmed with a real Playwright touch tap
// after this was still broken with the naive "toggle by reading display" approach.
let localTooltipPinnedOpen = false;
if (localLimitedTerm) {
if (supportsHover) {
localLimitedTerm.addEventListener('mouseenter', showLocalLimitedTooltip);
localLimitedTerm.addEventListener('mouseleave', () => { if (!localTooltipPinnedOpen) hideLocalLimitedTooltip(); });
}
localLimitedTerm.addEventListener('focus', showLocalLimitedTooltip);
localLimitedTerm.addEventListener('blur', () => { if (!localTooltipPinnedOpen) hideLocalLimitedTooltip(); });
localLimitedTerm.addEventListener('click', (e) => {
e.stopPropagation();
localTooltipPinnedOpen = !localTooltipPinnedOpen;
if (localTooltipPinnedOpen) {
showLocalLimitedTooltip();
localLimitedTerm.setAttribute('aria-expanded', 'true');
} else {
hideLocalLimitedTooltip();
}
});
document.addEventListener('click', () => {
localTooltipPinnedOpen = false;
hideLocalLimitedTooltip();
});
window.addEventListener('resize', () => {
if (localLimitedTooltip && localLimitedTooltip.style.display === 'block') showLocalLimitedTooltip();
});
}
// Single source of truth for both the inline composer banner and the
// centered modal popup, for either reset cause. Both show the SAME
// UTC-midnight countdown β€” for the message cap (this app's own counter)
// that's an exact, guaranteed reset time; for the real Neuron-budget
// exhaustion it's a best estimate, which is why that state's copy
// ("Should free up in approximately...") is phrased as an estimate rather
// than a promise, instead of just omitting the number a visitor
// reasonably expects to see.
function showResetBanner(kind) {
// Local mode already covers the 'neurons' case with its own banner view
// (and doesn't touch the server at all, so the message cap is moot too
// while it's active) β€” this function's job is entirely superseded.
if (localModeActive) return;
const t = TRANSLATIONS[currentLang] || TRANSLATIONS.en;
const title = kind === 'neurons' ? t.capTitleNeurons : t.capTitle;
const sub = kind === 'neurons' ? t.capSubNeurons : t.capSub;
currentResetKind = kind;
capTitleEl.textContent = title;
capSubEl.childNodes[0].textContent = sub + ' ';
showBannerView('normal');
capBanner.style.display = 'block';
const offerLocal = HAS_WEBGPU && kind === 'neurons';
if (localModeLinkEl) localModeLinkEl.style.display = offerLocal ? 'block' : 'none';
if (capModalDismissedFor !== kind) {
modalTitleEl.textContent = title;
modalSubEl.textContent = sub;
if (localModeBtnEl) localModeBtnEl.style.display = offerLocal ? 'block' : 'none';
capModalOverlay.style.display = 'flex';
}
input.disabled = true;
sendBtn.disabled = true;
attachBtn.disabled = true;
input.placeholder = t.capPlaceholder;
tickCapCountdown();
if (!capInterval) capInterval = setInterval(tickCapCountdown, 1000);
// While the composer is disabled, sending a message (the normal way this
// app would discover the condition cleared) isn't possible β€” without
// this, a visitor would be stuck staring at a ticking countdown for the
// full estimated duration even if the real Neuron budget actually
// recovers sooner. Only relevant for 'neurons' (an estimate); the
// message cap's own countdown is already exact.
if (kind === 'neurons' && !capRecheckInterval) {
capRecheckInterval = setInterval(fetchStats, 60000);
}
}
function hideResetBanner() {
if (localModeActive) return;
capModalDismissedFor = null;
currentResetKind = null;
capModalOverlay.style.display = 'none';
if (capRecheckInterval) { clearInterval(capRecheckInterval); capRecheckInterval = null; }
if (capBanner.style.display === 'none') return;
capBanner.style.display = 'none';
showBannerView('normal');
input.disabled = false;
sendBtn.disabled = false;
attachBtn.disabled = false;
input.placeholder = (TRANSLATIONS[currentLang] || TRANSLATIONS.en).placeholder;
if (capInterval) { clearInterval(capInterval); capInterval = null; }
}
modalOkBtn.addEventListener('click', () => {
capModalOverlay.style.display = 'none';
capModalDismissedFor = currentResetKind;
});
function updateGlobalUsage(usage) {
if (!usage) return;
lastUsage = usage;
renderStatusBar();
// neuronsExhausted takes priority β€” it's the real, account-wide
// constraint, checked before the message-count cap since the two are
// independent and either alone should block the composer.
if (usage.neuronsExhausted) showResetBanner('neurons');
else if (usage.used >= usage.cap) showResetBanner('cap');
else hideResetBanner();
}
setInterval(renderStatusBar, 30000);
function getVisitorId() {
try {
let id = localStorage.getItem('noxVisitorId');
if (!id) {
id = crypto.randomUUID ? crypto.randomUUID() : 'v-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
localStorage.setItem('noxVisitorId', id);
}
return id;
} catch {
return null;
}
}
async function fetchStats() {
try {
const res = await fetch(WORKER_URL);
if (!res.ok) throw new Error('stats fetch failed');
updateGlobalUsage(await res.json());
} catch {
statusBar.textContent = (TRANSLATIONS[currentLang] || TRANSLATIONS.en).statusDemo;
}
}
function startNewChat() {
if (typeof stopSpeaking === 'function') stopSpeaking();
history = [{ role: "system", content: buildSystemPrompt() }];
activeConvoId = null;
setSavedActiveConvoId(null);
thread.innerHTML = '';
showEmptyState();
input.value = '';
input.style.height = 'auto';
input.focus();
renderSidebarList();
refreshRagDocsRowVisibility();
}
newChatBtn.addEventListener('click', startNewChat);
function addMessage(role, text, opts={}) {
const row = document.createElement('div');
row.className = 'msg ' + (role === 'user' ? 'user' : 'bot') + (opts.loading ? ' loading' : '');
if (role !== 'user') {
const avatar = document.createElement('div');
avatar.className = 'avatar';
avatar.textContent = 'πŸŒ™';
row.appendChild(avatar);
}
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.textContent = text;
row.appendChild(bubble);
thread.appendChild(row);
scrollToBottom();
return bubble;
}
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
// Tapping the composer is what actually triggers the mobile keyboard to
// open (and dismissing it triggers close) β€” re-anchor to the latest message
// on both, on top of the visualViewport listener above, since a couple of
// mobile browsers only reliably resize the viewport after focus/blur settle
// rather than emitting their own timely resize event.
input.addEventListener('focus', scheduleScrollToBottom);
input.addEventListener('blur', scheduleScrollToBottom);
// Voice input β€” mic button only appears at all when the browser actually
// supports SpeechRecognition (Firefox doesn't, as of this writing), so
// there's no dead button to explain on unsupported browsers. Interim
// results stream into the textarea live so a visitor sees it's really
// listening; final transcript replaces them once recognition settles.
// Whatever text was already typed is kept and the transcript appended
// after it, rather than overwritten, so tapping the mic mid-sentence works
// as "add to this" rather than "start over."
const SpeechRecognitionCtor = window.SpeechRecognition || window.webkitSpeechRecognition;
const micBtn = document.getElementById('micBtn');
let recognizer = null;
let isListening = false;
let micBaseValue = '';
function stopListening() {
isListening = false;
micBtn.classList.remove('listening');
if (recognizer) {
const r = recognizer;
recognizer = null;
try { r.stop(); } catch {}
}
}
if (SpeechRecognitionCtor) {
micBtn.style.display = 'flex';
micBtn.addEventListener('click', () => {
if (isListening) {
stopListening();
return;
}
micBaseValue = input.value ? input.value + ' ' : '';
const rec = new SpeechRecognitionCtor();
recognizer = rec;
rec.lang = SPEECH_LANG_MAP[currentLang] || 'en-US';
rec.interimResults = true;
rec.maxAlternatives = 1;
rec.onresult = (e) => {
let transcript = '';
for (let i = 0; i < e.results.length; i++) transcript += e.results[i][0].transcript;
input.value = micBaseValue + transcript;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
};
rec.onerror = () => stopListening();
rec.onend = () => stopListening();
try {
rec.start();
isListening = true;
micBtn.classList.add('listening');
} catch {
stopListening();
}
});
}
async function streamTextReply(res, loadingBubble, existingText) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let full = existingText || '';
let buffer = '';
let started = full.length > 0;
let finishReason = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const dataStr = trimmed.slice(5).trim();
if (!dataStr || dataStr === '[DONE]') continue;
try {
const parsed = JSON.parse(dataStr);
const choice = parsed.choices?.[0];
if (choice?.finish_reason) finishReason = choice.finish_reason;
const delta = choice?.delta?.content;
if (delta) {
if (!started) {
loadingBubble.parentElement.classList.remove('loading');
loadingBubble.classList.add('markdown');
started = true;
}
full += delta;
loadingBubble.innerHTML = renderMarkdown(full);
scrollToBottom();
}
} catch {
// Ignore any non-JSON keepalive lines.
}
}
}
if (!full) full = '(no response)';
if (!started) {
loadingBubble.parentElement.classList.remove('loading');
loadingBubble.classList.add('markdown');
loadingBubble.innerHTML = renderMarkdown(full);
}
enhanceCodeBlocks(loadingBubble);
return { text: full, finishReason };
}
// A tight max_tokens cap was silently truncating long replies (document
// summaries especially) mid-sentence, with no indication anything was cut
// off. Raising the cap helps but can't guarantee completeness for an
// arbitrarily long document β€” the stream's finish_reason tells us for
// certain when a reply stopped because it hit the token limit ("length")
// rather than because the model was actually done ("stop"). When that
// happens, automatically ask the model to continue from exactly where it
// left off and append the result into the same bubble, so the user always
// sees one complete reply. Capped to avoid a runaway loop of continuations.
const MAX_AUTO_CONTINUATIONS = 3;
// The local engine's context window is a hard 4096 tokens β€” unlike the
// server (which caps outgoing messages at the last 20 server-side),
// nothing bounded how much of `history` got sent to the local engine
// each turn, so a conversation with even one document attachment would
// keep growing every turn until it blew the budget outright (a real
// ContextWindowSizeExceededError, hit in testing). No tokenizer is
// loaded client-side for this (its own multi-MB download) β€” chars/3 is
// a deliberately conservative stand-in (over-counts tokens, so it
// under-counts how much actually fits) that's good enough to stay well
// clear of the real limit. The system message is always kept in full;
// the most recent messages are kept until the budget runs out, and at
// least the latest user message is always included even if it alone
// would exceed the remaining budget (better to try and possibly still
// fail than to silently drop the very message just sent).
const LOCAL_CONTEXT_TOKEN_BUDGET = 2200; // of 4096 β€” leaves generous room for the reply itself
function estimateLocalTokens(text) {
// chars/2.5 rather than the more typical chars/4 for plain English β€”
// structured text (attachment markers, numbers, repeated short words)
// tokenizes denser than prose, and the real failure this guards
// against came from underestimating that.
return Math.ceil((text || '').length / 2.5);
}
function trimHistoryForLocalEngine(fullHistory) {
if (!fullHistory.length) return fullHistory;
const system = fullHistory[0];
const rest = fullHistory.slice(1);
const kept = [];
let budget = LOCAL_CONTEXT_TOKEN_BUDGET - estimateLocalTokens(system.content);
for (let i = rest.length - 1; i >= 0; i--) {
const cost = estimateLocalTokens(rest[i].content);
if (kept.length && budget - cost < 0) break;
budget -= cost;
kept.unshift(rest[i]);
}
return [system, ...kept];
}
// Answers entirely from the in-browser WebLLM engine instead of the
// Cloudflare Worker β€” see the "Local AI fallback mode" block above for
// enableLocalMode()/loadLocalEngine(). Mirrors requestReply's server-path
// bubble handling (loading state, markdown rendering, code highlighting,
// persistence, feedback row) so the rest of the app can't tell the
// difference, EXCEPT: no fetchStats()/updateGlobalUsage() calls, since
// nothing here touches the server's usage counters at all.
async function requestLocalReply(loadingBubble, userMessageText) {
if (LOCAL_CRISIS_RE.test(userMessageText)) {
loadingBubble.parentElement.classList.remove('loading');
loadingBubble.classList.add('markdown');
loadingBubble.innerHTML = renderMarkdown(LOCAL_CRISIS_RESPONSE);
history.push({ role: 'assistant', content: LOCAL_CRISIS_RESPONSE });
persistActiveConversation();
loadingBubble.parentElement.after(createFeedbackRow(LOCAL_CRISIS_RESPONSE, userMessageText));
scrollToBottom();
return;
}
try {
const engine = await loadLocalEngine();
// Injected only into THIS call's messages, never persisted into
// `history` itself β€” same reasoning as the server's own ragNote:
// the retrieved excerpts are only ever relevant to the turn that
// triggered retrieval, not something to permanently bake into every
// future message.
const { context: ragContext, chunks: ragSources } = await retrieveOfflineRagContext(activeConvoId, userMessageText);
const trimmedHistory = trimHistoryForLocalEngine(history);
const messagesForEngine = ragContext
? [{ ...trimmedHistory[0], content: trimmedHistory[0].content + ragContext }, ...trimmedHistory.slice(1)]
: trimmedHistory;
const chunks = await engine.chat.completions.create({ messages: messagesForEngine, stream: true });
let full = '';
let started = false;
for await (const chunk of chunks) {
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) {
if (!started) {
loadingBubble.parentElement.classList.remove('loading');
loadingBubble.classList.add('markdown');
started = true;
}
full += delta;
loadingBubble.innerHTML = renderMarkdown(full);
scrollToBottom();
}
}
if (!full) full = '(no response)';
if (!started) {
loadingBubble.parentElement.classList.remove('loading');
loadingBubble.classList.add('markdown');
loadingBubble.innerHTML = renderMarkdown(full);
}
enhanceCodeBlocks(loadingBubble);
history.push({ role: 'assistant', content: full });
persistActiveConversation();
loadingBubble.parentElement.after(createFeedbackRow(full, userMessageText, ragSources));
scrollToBottom();
} catch (err) {
console.error('Local reply failed', err);
loadingBubble.parentElement.classList.remove('loading');
const errMsg = String((err && err.message) || err);
if (/context window|ContextWindowSizeExceeded/i.test(errMsg)) {
// trimHistoryForLocalEngine's estimate is conservative but not
// exact β€” this is the backstop for when it still wasn't enough
// (e.g. a single message denser in tokens than the chars/3 guess).
loadingBubble.textContent = "That conversation is too long for local mode's limited memory, even after trimming older messages β€” try starting a new chat, or switch to RAG mode (which only sends small retrieved excerpts, not whole messages) or the server.";
} else if (/device was lost|external instance reference/i.test(errMsg)) {
// A crashed WebGPU device otherwise stays dead β€” every later local
// message would keep failing the same way until a full page
// reload. Clearing the cached engine here makes the NEXT attempt
// transparently reload a fresh one instead.
localEngine = null;
localEngineLoadPromise = null;
loadingBubble.textContent = 'Local AI mode lost its connection to the GPU and needs to reload β€” please try again.';
} else {
loadingBubble.textContent = 'Local AI mode hit an error. You can try again, or switch back to the server.';
}
scrollToBottom();
}
}
async function requestReply(imageBase64) {
const loadingBubble = addMessage('bot', '', { loading: true });
loadingBubble.innerHTML = TYPING_DOTS_HTML;
const userMessageText = [...history].reverse().find((m) => m.role === 'user')?.content || '';
if (localModeActive) {
// Local mode still can't read images β€” the engine loaded here is
// text-only, not a technical choice that could be relaxed by capping
// input size the way the one-shot document case below was. A
// one-shot document dump WAS blocked here too, on an earlier real
// test report of unusable, garbled replies from the tiny 1B model β€”
// now allowed again, but capped much tighter for local mode
// specifically (MAX_ONE_SHOT_DOC_CHARS_LOCAL) than the server path's
// budget, to keep a document from crowding out the system prompt and
// conversation history entirely.
if (imageBase64) {
loadingBubble.parentElement.classList.remove('loading');
loadingBubble.textContent = "Local AI mode can't read images β€” remove the attachment, or switch back to the server.";
scrollToBottom();
return;
}
return requestLocalReply(loadingBubble, userMessageText);
}
if (history.length) history[0] = { role: 'system', content: buildSystemPrompt() };
try {
const payload = { messages: history, visitorId: getVisitorId() };
if (imageBase64) payload.image = imageBase64;
if (activeConvoId) {
payload.convoId = activeConvoId;
payload.ragTopK = ragTopK;
}
const res = await fetch(WORKER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Request failed: ' + res.status);
const contentType = res.headers.get('Content-Type') || '';
// Only the FIRST response of a (possibly multi-part, auto-continued)
// reply carries retrieval metadata β€” it's tied to the question that
// triggered retrieval, not to whichever part the model happened to
// still be generating when a continuation was needed.
let ragSources = [];
const ragHeader = res.headers.get('X-Rag-Chunks');
if (ragHeader) {
try { ragSources = JSON.parse(decodeURIComponent(ragHeader)); } catch { ragSources = []; }
}
if (contentType.includes('text/event-stream')) {
let { text: reply, finishReason } = await streamTextReply(res, loadingBubble);
let continuations = 0;
while (finishReason === 'length' && continuations < MAX_AUTO_CONTINUATIONS) {
continuations++;
const contRes = await fetch(WORKER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [
...history,
{ role: 'assistant', content: reply },
{ role: 'user', content: 'Continue exactly where you left off. Do not repeat any earlier text, and do not add any preamble β€” just continue the response.' },
],
visitorId: getVisitorId(),
})
});
if (!contRes.ok) break;
const contType = contRes.headers.get('Content-Type') || '';
if (!contType.includes('text/event-stream')) break;
const result = await streamTextReply(contRes, loadingBubble, reply);
reply = result.text;
finishReason = result.finishReason;
}
history.push({ role: 'assistant', content: reply });
persistActiveConversation();
startReplyFlash();
loadingBubble.parentElement.after(createFeedbackRow(reply, userMessageText, ragSources));
scrollToBottom();
fetchStats();
} else {
const data = await res.json();
loadingBubble.parentElement.classList.remove('loading');
loadingBubble.classList.add('markdown');
if (data.limitReached) {
// The banner below the composer (triggered by updateGlobalUsage)
// disables input and explains further β€” this bubble just needs a
// short, friendly pointer to it. Two distinct causes share
// limitReached: this app's own daily message cap (which reliably
// resets at UTC midnight, safe to reference) vs. the real
// account-wide Neuron budget (unknown reset time β€” don't imply one).
const bubbleText = data.usage?.neuronsExhausted
? 'πŸŒ™ Nox is at capacity right now β€” see below, and please try again shortly.'
: 'πŸŒ™ Looks like Nox has used up today\'s free messages β€” see below for when it resets.';
loadingBubble.innerHTML = renderMarkdown(bubbleText);
updateGlobalUsage(data.usage);
scrollToBottom();
return;
}
const reply = data.reply || '(no response)';
loadingBubble.innerHTML = renderMarkdown(reply);
enhanceCodeBlocks(loadingBubble);
history.push({ role: 'assistant', content: reply });
persistActiveConversation();
startReplyFlash();
loadingBubble.parentElement.after(createFeedbackRow(reply, userMessageText, data.ragChunks || []));
updateGlobalUsage(data.usage);
scrollToBottom();
}
} catch (err) {
loadingBubble.parentElement.classList.remove('loading');
loadingBubble.textContent = 'Sorry, something went wrong. Please try again in a moment.';
console.error(err);
scrollToBottom();
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (input.disabled) return; // capped β€” banner below explains why
if (isListening) stopListening();
stopSpeaking();
const text = input.value.trim();
if (!text && !pendingImage && !pendingOneShotDoc) return;
hideEmptyState();
input.value = '';
input.style.height = 'auto';
sendBtn.disabled = true;
attachBtn.disabled = true;
let messageContent = text;
let displayText = text;
if (pendingOneShotDoc) {
// Chat mode's one-shot path β€” folded into just this one message and
// never stored anywhere after it's sent, unlike RAG mode's documents.
// The multi-file case already labeled each file individually at
// select time (see fileInput's change handler) β€” adding another
// header here on top of that would double- (and, nested, confusingly)
// label it.
const doc = pendingOneShotDoc.multi
? pendingOneShotDoc.text
: `[Attached file: ${pendingOneShotDoc.name}]\n\n${pendingOneShotDoc.text}`;
messageContent = (text ? text + '\n\n' : '') + doc;
displayText = text || `πŸ“Ž ${pendingOneShotDoc.name}`;
} else if (pendingImage && !text) {
messageContent = 'Describe this image in detail.';
displayText = `πŸ“Ž ${pendingImage.name}`;
}
const imageToSend = pendingImage ? pendingImage.base64 : null;
clearAttachment();
addMessage('user', displayText);
history.push({ role: 'user', content: messageContent });
persistActiveConversation();
await requestReply(imageToSend);
if (!lastUsage || lastUsage.used < lastUsage.cap) {
sendBtn.disabled = false;
attachBtn.disabled = false;
input.focus();
}
});
// Restores whatever conversation was open when the page was last closed,
// instead of always starting blank β€” the single biggest source of "lost my
// chat on refresh" frustration this was built to fix. Falls back to a
// normal blank new chat if there's nothing saved, or the saved id no longer
// exists (e.g. it was deleted from another tab).
(async () => {
// A shared read-only link takes priority over restoring the visitor's
// own last conversation β€” it's what they explicitly navigated here for.
const showedSharedView = await initSharedViewIfPresent();
if (!showedSharedView) {
const restoreId = getSavedActiveConvoId();
const restoreConvos = loadConversations();
if (restoreId && restoreConvos[restoreId]) {
activeConvoId = restoreId;
history = [{ role: 'system', content: buildSystemPrompt() }, ...restoreConvos[restoreId].messages];
renderThreadFromHistory();
refreshRagDocsRowVisibility();
} else {
startNewChat();
}
// Only meaningful outside the read-only shared view β€” the composer is
// disabled there anyway, so there's nothing useful to pre-fill it with.
handleShareTargetParams();
}
applyLanguage(currentLang);
applyFontSize();
renderSidebarList();
fetchStats();
})();
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js').catch(() => {});
});
}
</script>
</body>
</html>