Download index.html from Devseis/Nox: direct link, hf CLI and curl.
- Browser
- Download file 149 kB
-
https://huggingface.co/spaces/Devseis/Nox/resolve/main/index.html
- Command line
-
hf download hf://spaces/Devseis/Nox/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Devseis/Nox/resolve/main/index.html
149 kB
| <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> · <a href="privacy.html" id="footerPrivacyLink">Privacy & 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> | |