openfree commited on
Commit
a2a305e
Β·
verified Β·
1 Parent(s): cb0aa69

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +245 -350
index.html CHANGED
@@ -40,10 +40,12 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
40
  ::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:10px}
41
  .screen{display:none;width:100%;height:100%}.screen.active{display:flex}
42
 
43
- /* LANDING */
44
  #landing{flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:24px;position:relative;overflow-y:auto;background:var(--bg)}
45
  .landing-deco{position:absolute;width:min(500px,90vw);height:min(500px,90vw);border-radius:50%;background:radial-gradient(circle,rgba(79,109,245,.07) 0%,transparent 70%);top:50%;left:50%;transform:translate(-50%,-55%);pointer-events:none}
46
  .landing-tag{font-family:var(--font-mono);font-size:.68rem;letter-spacing:.12em;text-transform:uppercase;color:var(--accent-text);background:var(--accent-light);padding:6px 16px;border-radius:100px;margin-bottom:24px;font-weight:500}
 
 
 
47
  .landing-title{font-family:var(--font-display);font-size:clamp(2.4rem,7vw,4.5rem);font-weight:400;line-height:1.1;letter-spacing:-.02em;color:var(--text);margin-bottom:12px}
48
  .landing-title em{font-style:italic;color:var(--accent)}
49
  .landing-sub{font-size:.95rem;color:var(--text-2);max-width:460px;line-height:1.7;margin-bottom:32px;font-weight:400}
@@ -60,12 +62,16 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
60
  .model-search-wrap.open .model-search-input{border-radius:var(--radius-sm) var(--radius-sm) 0 0;border-color:var(--accent)}
61
  .model-item{padding:11px 14px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:8px;border-bottom:1px solid var(--border-light);transition:background .1s;min-height:44px}
62
  .model-item:last-child{border-bottom:none}
63
- .model-item:hover,.model-item.active{background:var(--accent-light)}
 
64
  .model-item-name{font-family:var(--font-mono);font-size:.75rem;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
65
- .model-item-meta{display:flex;gap:5px;align-items:center;flex-shrink:0}
66
- .size-badge{background:var(--accent-light);color:var(--accent-text);padding:3px 8px;border-radius:6px;font-family:var(--font-mono);font-size:.6rem;font-weight:600}
67
- .vram-badge{background:var(--amber-bg);color:var(--amber);padding:3px 7px;border-radius:6px;font-family:var(--font-mono);font-size:.58rem;font-weight:600}
68
- .mobile-badge{background:var(--green-bg);color:var(--green);padding:3px 7px;border-radius:6px;font-family:var(--font-mono);font-size:.56rem;font-weight:700;letter-spacing:.03em}
 
 
 
69
  .model-dropdown-loading{padding:12px 14px;font-size:.75rem;color:var(--text-3);text-align:center}
70
  .model-section-label{padding:8px 14px 4px;font-family:var(--font-mono);font-size:.58rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--text-3);background:var(--bg-warm)}
71
  .btn-load{font-family:var(--font);font-size:.9rem;font-weight:600;color:#fff;background:var(--accent);border:none;padding:14px 40px;border-radius:100px;cursor:pointer;transition:all .2s;box-shadow:0 2px 12px rgba(79,109,245,.25);min-height:48px}
@@ -75,7 +81,6 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
75
  .landing-footer{margin-top:auto;padding:20px 0;font-family:var(--font-mono);font-size:.65rem;color:var(--text-4)}
76
  .landing-footer a{color:var(--text-3);text-decoration:none}.landing-footer a:hover{color:var(--accent)}
77
 
78
- /* LOADING */
79
  #loading{flex-direction:column;align-items:center;justify-content:center;gap:24px;padding:24px;background:var(--bg)}
80
  .loader-ring{width:56px;height:56px;border:2.5px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}
81
  @keyframes spin{to{transform:rotate(360deg)}}
@@ -86,7 +91,6 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
86
  .download-track{height:4px;background:var(--bg-cool);border-radius:2px;overflow:hidden}
87
  .download-fill{height:100%;width:0%;background:var(--accent);border-radius:2px;transition:width .3s}
88
 
89
- /* CHAT */
90
  #chat{flex-direction:column;height:100%;background:var(--bg-warm)}
91
  .chat-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;padding-top:calc(10px + var(--safe-top));border-bottom:1px solid var(--border-light);background:var(--bg);flex-shrink:0;min-height:56px;gap:8px}
92
  .chat-header-left{display:flex;align-items:center;gap:10px;min-width:0}
@@ -109,7 +113,6 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
109
  .btn-icon.active{background:var(--accent-light);color:var(--accent-text);border-color:var(--accent)}
110
  .btn-reset{font-family:var(--font-mono);font-size:.62rem;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--text-3);background:var(--surface);border:1px solid var(--border);padding:6px 12px;border-radius:100px;cursor:pointer;transition:all .15s;white-space:nowrap;min-height:32px}
111
  .btn-reset:hover{color:var(--red);border-color:var(--red);background:var(--red-bg)}
112
-
113
  .settings-panel{padding:0 16px;background:var(--bg);border-bottom:1px solid var(--border-light);display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;justify-content:center;max-height:0;overflow:hidden;transition:max-height .3s,padding .3s}
114
  .settings-panel.open{max-height:200px;padding:10px 16px}
115
  .settings-row{display:flex;align-items:center;gap:5px}
@@ -131,11 +134,9 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
131
  .stat-value.hl{color:var(--accent-text)}
132
  .error-banner{display:none;padding:10px 16px;background:var(--red-bg);border:1px solid rgba(212,64,64,.2);border-radius:var(--radius-sm);color:var(--red);font-size:.8rem;margin:8px 16px 0}
133
  .error-banner.visible{display:block}
134
-
135
  .chat-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:16px;scroll-behavior:smooth;-webkit-overflow-scrolling:touch}
136
  .msg-group{display:flex;flex-direction:column;gap:3px;max-width:85%;animation:msgIn .25s ease}
137
- .msg-group.user{align-self:flex-end}
138
- .msg-group.assistant{align-self:flex-start}
139
  @keyframes msgIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
140
  .msg-role{font-family:var(--font-mono);font-size:.58rem;letter-spacing:.08em;text-transform:uppercase;color:var(--text-3);margin-bottom:1px;font-weight:600}
141
  .msg-group.assistant .msg-role{color:var(--accent-text)}
@@ -143,7 +144,6 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
143
  .msg-group.user .msg-bubble{background:var(--accent);color:#fff;border-bottom-right-radius:4px;box-shadow:0 2px 8px rgba(79,109,245,.15)}
144
  .msg-group.assistant .msg-bubble{background:var(--surface);border:1px solid var(--border-light);border-bottom-left-radius:4px}
145
  .msg-group.assistant .msg-bubble.generating{border-color:var(--accent);box-shadow:0 0 0 2px rgba(79,109,245,.08)}
146
-
147
  .msg-bubble.md{white-space:normal}
148
  .msg-bubble.md p{margin:0 0 .5em}.msg-bubble.md p:last-child{margin:0}
149
  .msg-bubble.md h1,.msg-bubble.md h2,.msg-bubble.md h3{margin:.7em 0 .35em;font-weight:600;line-height:1.3}
@@ -152,32 +152,20 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
152
  .msg-bubble.md code{font-family:var(--font-mono);font-size:.84em;padding:2px 6px;background:var(--bg-cool);border-radius:5px;color:var(--accent-text)}
153
  .msg-bubble.md pre{margin:.5em 0;padding:12px;background:var(--bg-warm);border:1px solid var(--border-light);border-radius:var(--radius-sm);overflow-x:auto;line-height:1.5}
154
  .msg-bubble.md pre code{padding:0;background:none;color:var(--text);font-size:.78rem}
155
- .msg-bubble.md ul,.msg-bubble.md ol{margin:.4em 0;padding-left:1.4em}
156
- .msg-bubble.md li{margin:.15em 0}
157
  .msg-bubble.md blockquote{margin:.5em 0;padding:8px 12px;border-left:3px solid var(--accent);color:var(--text-2);background:var(--accent-light);border-radius:0 var(--radius-xs) var(--radius-xs) 0}
158
  .msg-bubble.md table{margin:.5em 0;border-collapse:collapse;width:100%;font-size:.82rem}
159
  .msg-bubble.md th,.msg-bubble.md td{padding:6px 8px;border:1px solid var(--border);text-align:left}
160
  .msg-bubble.md th{background:var(--bg-cool);font-weight:600;color:var(--text-2);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
161
- .msg-bubble.md strong{font-weight:600}
162
- .msg-bubble.md a{color:var(--accent)}
163
  .msg-bubble.md hr{margin:.7em 0;border:none;border-top:1px solid var(--border-light)}
164
  .msg-group.user .msg-bubble.md code{background:rgba(255,255,255,.2);color:#fff}
165
  .msg-group.user .msg-bubble.md pre{background:rgba(0,0,0,.1);border-color:rgba(255,255,255,.15)}
166
  .msg-group.user .msg-bubble.md pre code{color:#fff}
167
-
168
- .think-block{margin-bottom:8px;border-left:2.5px solid var(--accent);padding:8px 12px;background:var(--accent-light);border-radius:0 var(--radius-xs) var(--radius-xs) 0}
169
- .think-toggle{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-family:var(--font-mono);font-size:.6rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--accent-text);min-height:28px}
170
- .think-toggle:hover{opacity:.75}
171
- .think-arrow{font-size:.5rem;transition:transform .2s;display:inline-block}
172
- .think-arrow.open{transform:rotate(90deg)}
173
- .think-content{font-size:.8rem;color:var(--text-2);line-height:1.65;white-space:pre-wrap;word-wrap:break-word;margin-top:6px;overflow:hidden;max-height:50vh;transition:max-height .3s,margin .3s,opacity .2s}
174
- .think-content.collapsed{max-height:0;margin-top:0;opacity:0}
175
  .msg-stats{font-family:var(--font-mono);font-size:.58rem;color:var(--text-3);margin-top:5px;letter-spacing:.03em}
176
  .thinking-dots span{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-right:4px;animation:dot 1.2s ease-in-out infinite}
177
- .thinking-dots span:nth-child(2){animation-delay:.2s}
178
- .thinking-dots span:nth-child(3){animation-delay:.4s}
179
  @keyframes dot{0%,80%,100%{opacity:.2;transform:scale(.8)}40%{opacity:1;transform:scale(1)}}
180
-
181
  .chat-input-area{padding:10px 12px;padding-bottom:calc(10px + var(--safe-bottom));border-top:1px solid var(--border-light);background:var(--bg);flex-shrink:0}
182
  .chat-input-row{display:flex;align-items:flex-end;gap:6px}
183
  .input-wrap{flex:1;position:relative}
@@ -188,82 +176,61 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
188
  .btn-send:disabled{opacity:.3;cursor:not-allowed}
189
  .btn-send:not(:disabled):hover{background:var(--accent-hover);box-shadow:0 2px 8px rgba(79,109,245,.25)}
190
  .btn-send:not(:disabled):active{transform:scale(.95)}
191
- .btn-send .icon-stop{display:none}
192
- .btn-send.stopping{background:var(--red)}
193
- .btn-send.stopping .icon-send{display:none}
194
- .btn-send.stopping .icon-stop{display:block}
195
  .chat-footer-note{font-family:var(--font-mono);font-size:.55rem;color:var(--text-4);text-align:center;margin-top:6px;letter-spacing:.03em;padding:0 8px}
196
-
197
  .welcome-msg{text-align:center;padding:40px 20px;color:var(--text-3);flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center}
198
  .welcome-msg h3{font-family:var(--font-display);font-size:1.5rem;color:var(--text-2);margin-bottom:8px;font-weight:400;font-style:italic}
199
  .welcome-msg p{font-size:.85rem;line-height:1.65}
200
-
201
  .toast{position:fixed;bottom:calc(24px + var(--safe-bottom));left:50%;transform:translateX(-50%) translateY(20px);padding:10px 20px;border-radius:var(--radius-sm);font-size:.82rem;font-weight:500;background:var(--surface);color:var(--text);border:1px solid var(--border);opacity:0;transition:all .3s;z-index:1000;max-width:min(480px,90vw);box-shadow:var(--shadow-lg);pointer-events:none}
202
  .toast.show{transform:translateX(-50%) translateY(0);opacity:1}
203
- .toast.error{border-color:var(--red);color:var(--red)}
204
- .toast.success{border-color:var(--green);color:var(--green)}
205
 
206
  @media(max-width:640px){
207
- .chip{font-size:.62rem;padding:4px 10px}
208
- .landing-sub{font-size:.88rem;padding:0 8px}
209
  .chat-header{padding:8px 12px;padding-top:calc(8px + var(--safe-top));gap:6px}
210
- .chat-header-title{font-size:.84rem}
211
- .chat-header-controls{gap:4px}
212
- .toggle-pill{padding:4px 8px;min-height:28px}
213
- .toggle-lbl{font-size:.58rem}
214
  .btn-icon{width:28px;height:28px;font-size:.7rem}
215
  .btn-reset{font-size:.58rem;padding:5px 10px;min-height:28px}
216
- .settings-panel.open{gap:6px 12px;padding:8px 12px}
217
- .settings-slider{width:56px}
218
- .chat-messages{padding:12px}
219
- .msg-group{max-width:90%}
220
  .msg-bubble{padding:10px 14px;font-size:.85rem}
221
  .chat-input-area{padding:8px 10px;padding-bottom:calc(8px + var(--safe-bottom))}
222
  .btn-send{width:42px;height:42px}
223
  .input-wrap textarea{min-height:42px;padding:10px 12px;font-size:.86rem}
224
- .stats-bar{gap:10px;padding:4px 12px}
225
- .system-prompt-wrap.open{padding:6px 12px 8px}
226
- }
227
- @media(max-width:380px){
228
- .toggle-lbl{display:none}
229
- .landing-title{font-size:2rem}
230
- .chat-header-title{font-size:.78rem;max-width:120px}
231
- }
232
- @media(max-height:500px) and (orientation:landscape){
233
- .chat-header{padding:6px 12px;min-height:44px}
234
- .chat-avatar{width:28px;height:28px;font-size:.8rem}
235
- .chat-messages{padding:8px 12px;gap:10px}
236
  }
 
 
237
  </style>
238
  </head>
239
  <body>
240
-
241
  <div id="landing" class="screen active">
242
  <div class="landing-deco"></div>
243
- <div class="landing-tag">WebLLM Β· Mobile Ready Β· WebGPU</div>
 
244
  <h1 class="landing-title">Local <em>AI Chat</em></h1>
245
- <p class="landing-sub">Run open-source LLMs entirely in your browser β€” desktop and mobile. No server, no API keys, powered by WebLLM.</p>
246
  <div class="landing-chips">
247
- <span class="chip">Qwen Β· Llama Β· Gemma</span>
248
- <span class="chip">Mobile + Desktop</span>
249
  <span class="chip">Streaming</span>
250
  <span class="chip">Markdown</span>
 
251
  </div>
252
  <div class="model-search-wrap" id="modelSearchWrap">
253
- <input class="model-search-input" id="modelSearchInput" type="text" value="SmolLM2-360M-Instruct-q4f16_1-MLC" placeholder="Select a model…" autocomplete="off" readonly/>
254
  <span class="search-chevron">β–Ύ</span>
255
  <div class="model-dropdown" id="modelDropdown"></div>
256
  </div>
257
  <button class="btn-load" id="btnLoad">Load Model</button>
258
  <div class="landing-footer">Powered by <a href="https://webllm.mlc.ai/" target="_blank">WebLLM</a> + <a href="https://mlc.ai/" target="_blank">MLC-AI</a></div>
259
  </div>
260
-
261
  <div id="loading" class="screen">
262
  <div class="loader-ring" id="loaderRing"></div>
263
  <div><div class="loader-text" id="loaderText">Initializing engine…</div><div class="loader-sub" id="loaderSub">Model is cached after first download.</div></div>
264
  <div class="download-bar" id="downloadBar" style="display:none"><div class="download-text" id="downloadText">Downloading…</div><div class="download-track"><div class="download-fill" id="downloadFill"></div></div></div>
265
  </div>
266
-
267
  <div id="chat" class="screen">
268
  <div class="chat-header">
269
  <div class="chat-header-left"><div class="chat-avatar">W</div><div class="chat-header-info"><div class="chat-header-title" id="chatTitle">WebLLM Chat</div><div class="chat-header-status">Ready</div></div></div>
@@ -283,7 +250,7 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
283
  <div class="system-prompt-wrap" id="sysPromptWrap"><textarea class="system-prompt-input" id="sysPromptInput" placeholder="System prompt (optional)…"></textarea></div>
284
  <div class="stats-bar" id="statsBar"></div>
285
  <div class="error-banner" id="errorBanner"></div>
286
- <div class="chat-messages" id="chatMessages"><div class="welcome-msg" id="welcomeMsg"><h3>Start a conversation</h3><p>Type a message below. The model runs entirely in your browser β€” desktop and mobile.</p></div></div>
287
  <div class="chat-input-area">
288
  <div class="chat-input-row">
289
  <div class="input-wrap"><textarea id="msgInput" rows="1" placeholder="Type a message…"></textarea></div>
@@ -292,325 +259,253 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
292
  <svg class="icon-stop" width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
293
  </button>
294
  </div>
295
- <div class="chat-footer-note">100% local β€” no data leaves your device. Works on mobile and desktop.</div>
296
  </div>
297
  </div>
298
-
299
  <div class="toast" id="toast"></div>
300
  <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script>
301
-
302
  <script type="module">
303
  import * as webllm from "https://esm.run/@mlc-ai/web-llm";
304
 
305
- /* ─── Model catalog ─── */
 
 
 
 
 
 
 
 
 
306
  const MODELS = [
307
- { id: "SmolLM2-135M-Instruct-q0f32-MLC", label: "SmolLM2 135M", vram: "719MB", mobile: true },
308
- { id: "SmolLM2-360M-Instruct-q4f16_1-MLC", label: "SmolLM2 360M", vram: "489MB", mobile: true },
309
- { id: "Qwen2.5-0.5B-Instruct-q4f16_1-MLC", label: "Qwen2.5 0.5B", vram: "945MB", mobile: true },
310
- { id: "Qwen2.5-1.5B-Instruct-q4f16_1-MLC", label: "Qwen2.5 1.5B", vram: "1.5GB", mobile: true },
311
- { id: "gemma-2-2b-it-q4f16_1-MLC-1k", label: "Gemma-2 2B (1k ctx)", vram: "1.6GB", mobile: true },
312
- { id: "Llama-3.2-1B-Instruct-q4f16_1-MLC", label: "Llama 3.2 1B", vram: "1.1GB", mobile: true },
313
- { id: "Llama-3.2-3B-Instruct-q4f16_1-MLC", label: "Llama 3.2 3B", vram: "2.2GB", mobile: false },
314
- { id: "gemma-2-2b-it-q4f16_1-MLC", label: "Gemma-2 2B (4k ctx)", vram: "1.9GB", mobile: false },
315
- { id: "Qwen2.5-3B-Instruct-q4f16_1-MLC", label: "Qwen2.5 3B", vram: "2.3GB", mobile: false },
316
- { id: "Phi-3.5-mini-instruct-q4f16_1-MLC", label: "Phi-3.5 Mini 3.8B", vram: "3.0GB", mobile: false },
317
- { id: "Mistral-7B-Instruct-v0.3-q4f16_1-MLC", label: "Mistral 7B", vram: "5.0GB", mobile: false },
318
- { id: "Llama-3.1-8B-Instruct-q4f16_1-MLC", label: "Llama 3.1 8B", vram: "5.1GB", mobile: false },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  ];
320
 
321
- /* ���── State ─── */
322
- let engine = null;
323
- let isGenerating = false;
324
- let abortController = null;
325
- let chatHistory = [];
326
- let totalTokens = 0;
327
-
328
- const $ = id => document.getElementById(id);
329
- const getTemp = () => parseInt($("tempSlider").value) / 100;
330
- const getTopP = () => parseInt($("toppSlider").value) / 100;
331
- const getMaxTok = () => parseInt($("maxTokSelect").value);
332
- const getRepPen = () => parseInt($("repPenSlider").value) / 100;
333
- const getSysPrompt = () => $("sysPromptInput").value.trim();
334
-
335
- document.fonts.ready.then(() => document.body.classList.add("ready"));
336
-
337
- const $loaderTx = $("loaderText"), $loaderSub = $("loaderSub");
338
- const $messages = $("chatMessages"), $input = $("msgInput"), $btnSend = $("btnSend");
339
- const $btnLoad = $("btnLoad"), $btnReset = $("btnReset");
340
- const $errBanner = $("errorBanner"), $streamToggle = $("streamToggle");
341
- const $searchInput = $("modelSearchInput"), $searchWrap = $("modelSearchWrap"), $dropdown = $("modelDropdown");
342
- const $downloadBar = $("downloadBar"), $downloadText = $("downloadText"), $downloadFill = $("downloadFill");
343
- const $statsBar = $("statsBar"), $toast = $("toast");
344
-
345
- /* ─── Toast ─── */
346
- let toastTimer = null;
347
- function showToast(m, t = "") { clearTimeout(toastTimer); $toast.textContent = m; $toast.className = "toast " + t + " show"; toastTimer = setTimeout(() => $toast.classList.remove("show"), 3000); }
348
 
349
- /* ─── Stats ─── */
350
- function updateStatsBar(tps = null, tokens = null) {
351
- if (!totalTokens && !tps) { $statsBar.innerHTML = ""; return; }
352
- let h = "";
353
- if (tps !== null) h += `<div class="stat"><span class="stat-label">Speed</span><span class="stat-value hl">${tps} t/s</span></div>`;
354
- if (tokens !== null) h += `<div class="stat"><span class="stat-label">Tokens</span><span class="stat-value">${tokens}</span></div>`;
355
- if (totalTokens > 0) h += `<div class="stat"><span class="stat-label">Session</span><span class="stat-value">${totalTokens}</span></div>`;
356
- h += `<div class="stat"><span class="stat-label">Msgs</span><span class="stat-value">${chatHistory.length}</span></div>`;
357
- $statsBar.innerHTML = h;
 
 
 
 
 
 
 
358
  }
359
 
360
- /* ─── Model dropdown ─── */
361
- function renderDropdown(filter = "") {
362
- const q = filter.toLowerCase();
363
- const filtered = q ? MODELS.filter(m => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)) : MODELS;
364
- const mobileModels = filtered.filter(m => m.mobile);
365
- const desktopModels = filtered.filter(m => !m.mobile);
366
- let html = "";
367
- if (mobileModels.length) {
368
- html += '<div class="model-section-label">Mobile-friendly</div>';
369
- html += mobileModels.map(m => modelItemHtml(m)).join("");
370
- }
371
- if (desktopModels.length) {
372
- html += '<div class="model-section-label">Desktop (more VRAM)</div>';
373
- html += desktopModels.map(m => modelItemHtml(m)).join("");
 
 
 
 
 
 
374
  }
375
- if (!filtered.length) html = '<div class="model-dropdown-loading">No models found</div>';
376
- $dropdown.innerHTML = html;
377
- $dropdown.querySelectorAll(".model-item").forEach(el => {
378
- el.addEventListener("click", () => { $searchInput.value = el.dataset.id; closeDropdown(); });
379
  });
380
  }
381
 
382
- function modelItemHtml(m) {
383
- return `<div class="model-item" data-id="${m.id}">
384
- <span class="model-item-name">${m.label}</span>
385
- <span class="model-item-meta">
386
- ${m.mobile ? '<span class="mobile-badge">MOBILE</span>' : ''}
387
- <span class="vram-badge">${m.vram}</span>
388
- </span>
389
- </div>`;
390
- }
391
-
392
- function openDropdown() { $searchWrap.classList.add("open"); renderDropdown(); }
393
- function closeDropdown() { $searchWrap.classList.remove("open"); }
394
- $searchInput.addEventListener("click", () => { $searchInput.removeAttribute("readonly"); openDropdown(); });
395
- $searchInput.addEventListener("focus", () => { $searchInput.removeAttribute("readonly"); openDropdown(); });
396
- $searchInput.addEventListener("input", () => renderDropdown($searchInput.value));
397
- document.addEventListener("click", e => { if (!e.target.closest(".model-search-wrap")) closeDropdown(); });
398
- $searchInput.addEventListener("keydown", e => {
399
- if (e.key === "Escape") closeDropdown();
400
- const items = $dropdown.querySelectorAll(".model-item");
401
- if (!items.length) return;
402
- const active = $dropdown.querySelector(".model-item.active");
403
- let idx = Array.from(items).indexOf(active);
404
- if (e.key === "ArrowDown") { e.preventDefault(); idx = Math.min(idx + 1, items.length - 1); items.forEach(i => i.classList.remove("active")); items[idx].classList.add("active"); items[idx].scrollIntoView({ block: "nearest" }); }
405
- if (e.key === "ArrowUp") { e.preventDefault(); idx = Math.max(idx - 1, 0); items.forEach(i => i.classList.remove("active")); items[idx].classList.add("active"); items[idx].scrollIntoView({ block: "nearest" }); }
406
- if (e.key === "Enter") { e.preventDefault(); if (active) { $searchInput.value = active.dataset.id; } closeDropdown(); }
407
  });
408
 
409
- /* ─── Settings ─── */
410
- $("btnSettings").addEventListener("click", () => { $("settingsPanel").classList.toggle("open"); $("btnSettings").classList.toggle("active"); });
411
- $("btnSysPrompt").addEventListener("click", () => { $("sysPromptWrap").classList.toggle("open"); $("btnSysPrompt").classList.toggle("active"); });
412
- $("tempSlider").addEventListener("input", () => $("tempVal").textContent = getTemp().toFixed(2));
413
- $("toppSlider").addEventListener("input", () => $("toppVal").textContent = getTopP().toFixed(2));
414
- $("repPenSlider").addEventListener("input", () => $("repPenVal").textContent = getRepPen().toFixed(2));
415
-
416
- function showScreen(id) { document.querySelectorAll(".screen").forEach(s => s.classList.toggle("active", s.id === id)); }
417
-
418
- /* ─── Engine loading ─── */
419
- $btnLoad.addEventListener("click", async () => {
420
- const modelId = $searchInput.value.trim();
421
- if (!modelId) { showToast("Select a model", "error"); return; }
422
- showScreen("loading");
423
- $downloadBar.style.display = "none";
424
- try {
425
- $loaderTx.textContent = "Initializing WebLLM…";
426
- engine = await webllm.CreateMLCEngine(modelId, {
427
- initProgressCallback: (progress) => {
428
- $downloadBar.style.display = "";
429
- const pct = Math.round(progress.progress * 100);
430
- $downloadFill.style.width = pct + "%";
431
- $downloadText.textContent = progress.text || `Loading: ${pct}%`;
432
- $loaderTx.textContent = progress.text || "Loading model…";
433
- }
434
  });
435
- $loaderTx.textContent = "Ready!";
436
- const m = MODELS.find(m => m.id === modelId);
437
- $("chatTitle").textContent = m ? m.label : modelId.split("-").slice(0, 3).join(" ");
438
- setTimeout(() => showScreen("chat"), 300);
439
- showToast("Model loaded!", "success");
440
- } catch (err) {
441
- console.error(err);
442
- $loaderTx.textContent = "Failed to load";
443
- $loaderSub.textContent = err.message;
444
- $("loaderRing").style.borderTopColor = "var(--red)";
445
- showToast("Load failed: " + err.message.slice(0, 60), "error");
446
  }
447
  });
448
 
449
- /* ─── Input ─── */
450
- $input.addEventListener("input", () => { $input.style.height = "auto"; $input.style.height = Math.min($input.scrollHeight, 120) + "px"; updateSendBtn(); });
451
- $input.addEventListener("keydown", e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (!isGenerating) sendMessage(); } });
452
- $btnSend.addEventListener("click", () => { if (isGenerating) abortGeneration(); else sendMessage(); });
453
- function updateSendBtn() {
454
- if (isGenerating) { $btnSend.disabled = false; $btnSend.classList.add("stopping"); }
455
- else { $btnSend.classList.remove("stopping"); $btnSend.disabled = !$input.value.trim(); }
456
- }
457
- function abortGeneration() { if (abortController) { abortController.abort(); abortController = null; } }
458
-
459
- /* ─── Reset ─── */
460
- $btnReset.addEventListener("click", async () => {
461
- chatHistory = [];
462
- totalTokens = 0;
463
- if (engine) try { await engine.resetChat(); } catch (e) { console.warn(e); }
464
- $messages.innerHTML = '<div class="welcome-msg" id="welcomeMsg"><h3>Start a conversation</h3><p>Type a message below. The model runs entirely in your browser.</p></div>';
465
- $errBanner.classList.remove("visible");
466
- $input.value = ""; $input.style.height = "auto";
467
- updateStatsBar(); updateSendBtn();
468
  });
469
 
470
- /* ─── Markdown ─── */
471
- function renderMarkdown(t) {
472
- if (typeof marked === "undefined") return escapeHtml(t);
473
- try { marked.setOptions({ breaks: true, gfm: true }); return marked.parse(t); } catch { return escapeHtml(t); }
474
- }
475
- function escapeHtml(s) { return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
476
 
477
- /* ─── Send message ─── */
478
- async function sendMessage() {
479
- if (isGenerating || !engine) return;
480
- const text = $input.value.trim();
481
- if (!text) return;
482
  $errBanner.classList.remove("visible");
483
- const welcome = $messages.querySelector(".welcome-msg");
484
- if (welcome) welcome.remove();
485
-
486
- appendMessage("user", text);
487
- chatHistory.push({ role: "user", content: text });
488
-
489
- $input.value = ""; $input.style.height = "auto";
490
- isGenerating = true;
491
- updateSendBtn();
492
-
493
- const { groupEl, bubbleEl } = appendAssistantPlaceholder();
494
-
495
- try {
496
- const messages = [];
497
- const sysPrompt = getSysPrompt();
498
- if (sysPrompt) messages.push({ role: "system", content: sysPrompt });
499
  messages.push(...chatHistory);
500
-
501
- const useStream = $streamToggle.checked;
502
- let fullText = "";
503
- let tokenCount = 0;
504
- const startTime = performance.now();
505
-
506
- const textNode = document.createElement("div");
507
- textNode.className = "msg-text";
508
- bubbleEl.appendChild(textNode);
509
-
510
- if (useStream) {
511
- abortController = new AbortController();
512
- const stream = await engine.chat.completions.create({
513
- messages,
514
- temperature: getTemp(),
515
- top_p: getTopP(),
516
- max_tokens: getMaxTok(),
517
- frequency_penalty: getRepPen() - 1,
518
- stream: true,
519
- stream_options: { include_usage: true },
520
- });
521
-
522
- for await (const chunk of stream) {
523
- if (abortController?.signal?.aborted) break;
524
- const delta = chunk.choices?.[0]?.delta?.content;
525
- if (delta) {
526
- fullText += delta;
527
- tokenCount++;
528
- textNode.innerHTML = renderMarkdown(fullText);
529
- $messages.scrollTop = $messages.scrollHeight;
530
- }
531
- if (chunk.usage) tokenCount = chunk.usage.completion_tokens || tokenCount;
532
  }
533
- } else {
534
- const reply = await engine.chat.completions.create({
535
- messages,
536
- temperature: getTemp(),
537
- top_p: getTopP(),
538
- max_tokens: getMaxTok(),
539
- frequency_penalty: getRepPen() - 1,
540
- });
541
- fullText = reply.choices[0]?.message?.content || "";
542
- tokenCount = reply.usage?.completion_tokens || 0;
543
- textNode.innerHTML = renderMarkdown(fullText);
544
- }
545
-
546
- chatHistory.push({ role: "assistant", content: fullText });
547
-
548
- if (fullText.includes("`") || fullText.includes("#") || fullText.includes("|") || fullText.includes("*")) {
549
- bubbleEl.classList.add("md");
550
- }
551
-
552
- const elapsed = (performance.now() - startTime) / 1000;
553
- const tps = tokenCount > 0 ? (tokenCount / elapsed).toFixed(1) : "?";
554
- const statsEl = document.createElement("div");
555
- statsEl.className = "msg-stats";
556
- statsEl.textContent = `${tokenCount} tokens Β· ${tps} tok/s Β· ${elapsed.toFixed(1)}s`;
557
- groupEl.appendChild(statsEl);
558
-
559
- totalTokens += tokenCount;
560
- updateStatsBar(tps, tokenCount);
561
- bubbleEl.classList.remove("generating");
562
-
563
- } catch (err) {
564
- if (err.name === "AbortError") {
565
- bubbleEl.classList.remove("generating");
566
- showToast("Generation stopped", "");
567
- } else {
568
- console.error(err);
569
- groupEl.remove();
570
- $errBanner.textContent = "Error: " + err.message;
571
- $errBanner.classList.add("visible");
572
- showToast("Generation failed", "error");
573
  }
 
 
 
 
 
 
 
 
574
  }
575
-
576
- isGenerating = false;
577
- abortController = null;
578
- updateSendBtn();
579
- $messages.scrollTop = $messages.scrollHeight;
580
  }
581
 
582
- /* ─── Render helpers ─── */
583
- function appendMessage(role, text) {
584
- const g = document.createElement("div"); g.className = `msg-group ${role}`;
585
- const r = document.createElement("div"); r.className = "msg-role"; r.textContent = role === "user" ? "You" : "AI"; g.appendChild(r);
586
- const b = document.createElement("div"); b.className = "msg-bubble"; b.textContent = text; g.appendChild(b);
587
- $messages.appendChild(g); $messages.scrollTop = $messages.scrollHeight; return g;
588
  }
589
-
590
- function appendAssistantPlaceholder() {
591
- const g = document.createElement("div"); g.className = "msg-group assistant";
592
- const r = document.createElement("div"); r.className = "msg-role"; r.textContent = "AI"; g.appendChild(r);
593
- const b = document.createElement("div"); b.className = "msg-bubble generating";
594
- const d = document.createElement("span"); d.className = "thinking-dots";
595
- for (let i = 0; i < 3; i++) d.appendChild(document.createElement("span"));
596
- b.appendChild(d); g.appendChild(b); $messages.appendChild(g); $messages.scrollTop = $messages.scrollHeight;
597
- return { groupEl: g, bubbleEl: b };
598
  }
 
599
 
600
- /* ─── Mobile keyboard resize ─── */
601
- window.visualViewport?.addEventListener("resize", () => { $messages.scrollTop = $messages.scrollHeight; });
602
-
603
- /* ─── WebGPU check ─── */
604
- (async () => {
605
- if (!navigator.gpu) {
606
- showToast("WebGPU not available β€” try Chrome or Edge", "error");
607
- $btnLoad.disabled = true;
608
- return;
609
- }
610
- try {
611
- const a = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
612
- if (!a) { showToast("No WebGPU adapter found", "error"); $btnLoad.disabled = true; }
613
- } catch (e) { showToast("WebGPU init failed", "error"); }
614
  })();
615
  </script>
616
  </body>
 
40
  ::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:10px}
41
  .screen{display:none;width:100%;height:100%}.screen.active{display:flex}
42
 
 
43
  #landing{flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:24px;position:relative;overflow-y:auto;background:var(--bg)}
44
  .landing-deco{position:absolute;width:min(500px,90vw);height:min(500px,90vw);border-radius:50%;background:radial-gradient(circle,rgba(79,109,245,.07) 0%,transparent 70%);top:50%;left:50%;transform:translate(-50%,-55%);pointer-events:none}
45
  .landing-tag{font-family:var(--font-mono);font-size:.68rem;letter-spacing:.12em;text-transform:uppercase;color:var(--accent-text);background:var(--accent-light);padding:6px 16px;border-radius:100px;margin-bottom:24px;font-weight:500}
46
+ .device-badge{font-family:var(--font-mono);font-size:.6rem;font-weight:600;padding:4px 12px;border-radius:100px;margin-bottom:8px;letter-spacing:.06em}
47
+ .device-badge.mobile{background:var(--green-bg);color:var(--green)}
48
+ .device-badge.desktop{background:var(--accent-light);color:var(--accent-text)}
49
  .landing-title{font-family:var(--font-display);font-size:clamp(2.4rem,7vw,4.5rem);font-weight:400;line-height:1.1;letter-spacing:-.02em;color:var(--text);margin-bottom:12px}
50
  .landing-title em{font-style:italic;color:var(--accent)}
51
  .landing-sub{font-size:.95rem;color:var(--text-2);max-width:460px;line-height:1.7;margin-bottom:32px;font-weight:400}
 
62
  .model-search-wrap.open .model-search-input{border-radius:var(--radius-sm) var(--radius-sm) 0 0;border-color:var(--accent)}
63
  .model-item{padding:11px 14px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:8px;border-bottom:1px solid var(--border-light);transition:background .1s;min-height:44px}
64
  .model-item:last-child{border-bottom:none}
65
+ .model-item:hover:not(.locked),.model-item.active:not(.locked){background:var(--accent-light)}
66
+ .model-item.locked{opacity:.4;cursor:not-allowed;pointer-events:none}
67
  .model-item-name{font-family:var(--font-mono);font-size:.75rem;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
68
+ .model-item.locked .model-item-name{text-decoration:line-through}
69
+ .model-item-meta{display:flex;gap:4px;align-items:center;flex-shrink:0}
70
+ .size-badge{background:var(--accent-light);color:var(--accent-text);padding:3px 7px;border-radius:6px;font-family:var(--font-mono);font-size:.58rem;font-weight:600}
71
+ .vram-badge{background:var(--amber-bg);color:var(--amber);padding:3px 6px;border-radius:6px;font-family:var(--font-mono);font-size:.56rem;font-weight:600}
72
+ .mobile-badge{background:var(--green-bg);color:var(--green);padding:3px 6px;border-radius:6px;font-family:var(--font-mono);font-size:.54rem;font-weight:700;letter-spacing:.03em}
73
+ .lock-badge{background:var(--red-bg);color:var(--red);padding:3px 6px;border-radius:6px;font-family:var(--font-mono);font-size:.52rem;font-weight:700;letter-spacing:.03em}
74
+ .new-badge{background:#edf7f0;color:#16803c;padding:2px 6px;border-radius:5px;font-family:var(--font-mono);font-size:.5rem;font-weight:700;letter-spacing:.04em}
75
  .model-dropdown-loading{padding:12px 14px;font-size:.75rem;color:var(--text-3);text-align:center}
76
  .model-section-label{padding:8px 14px 4px;font-family:var(--font-mono);font-size:.58rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--text-3);background:var(--bg-warm)}
77
  .btn-load{font-family:var(--font);font-size:.9rem;font-weight:600;color:#fff;background:var(--accent);border:none;padding:14px 40px;border-radius:100px;cursor:pointer;transition:all .2s;box-shadow:0 2px 12px rgba(79,109,245,.25);min-height:48px}
 
81
  .landing-footer{margin-top:auto;padding:20px 0;font-family:var(--font-mono);font-size:.65rem;color:var(--text-4)}
82
  .landing-footer a{color:var(--text-3);text-decoration:none}.landing-footer a:hover{color:var(--accent)}
83
 
 
84
  #loading{flex-direction:column;align-items:center;justify-content:center;gap:24px;padding:24px;background:var(--bg)}
85
  .loader-ring{width:56px;height:56px;border:2.5px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}
86
  @keyframes spin{to{transform:rotate(360deg)}}
 
91
  .download-track{height:4px;background:var(--bg-cool);border-radius:2px;overflow:hidden}
92
  .download-fill{height:100%;width:0%;background:var(--accent);border-radius:2px;transition:width .3s}
93
 
 
94
  #chat{flex-direction:column;height:100%;background:var(--bg-warm)}
95
  .chat-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;padding-top:calc(10px + var(--safe-top));border-bottom:1px solid var(--border-light);background:var(--bg);flex-shrink:0;min-height:56px;gap:8px}
96
  .chat-header-left{display:flex;align-items:center;gap:10px;min-width:0}
 
113
  .btn-icon.active{background:var(--accent-light);color:var(--accent-text);border-color:var(--accent)}
114
  .btn-reset{font-family:var(--font-mono);font-size:.62rem;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--text-3);background:var(--surface);border:1px solid var(--border);padding:6px 12px;border-radius:100px;cursor:pointer;transition:all .15s;white-space:nowrap;min-height:32px}
115
  .btn-reset:hover{color:var(--red);border-color:var(--red);background:var(--red-bg)}
 
116
  .settings-panel{padding:0 16px;background:var(--bg);border-bottom:1px solid var(--border-light);display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;justify-content:center;max-height:0;overflow:hidden;transition:max-height .3s,padding .3s}
117
  .settings-panel.open{max-height:200px;padding:10px 16px}
118
  .settings-row{display:flex;align-items:center;gap:5px}
 
134
  .stat-value.hl{color:var(--accent-text)}
135
  .error-banner{display:none;padding:10px 16px;background:var(--red-bg);border:1px solid rgba(212,64,64,.2);border-radius:var(--radius-sm);color:var(--red);font-size:.8rem;margin:8px 16px 0}
136
  .error-banner.visible{display:block}
 
137
  .chat-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:16px;scroll-behavior:smooth;-webkit-overflow-scrolling:touch}
138
  .msg-group{display:flex;flex-direction:column;gap:3px;max-width:85%;animation:msgIn .25s ease}
139
+ .msg-group.user{align-self:flex-end}.msg-group.assistant{align-self:flex-start}
 
140
  @keyframes msgIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
141
  .msg-role{font-family:var(--font-mono);font-size:.58rem;letter-spacing:.08em;text-transform:uppercase;color:var(--text-3);margin-bottom:1px;font-weight:600}
142
  .msg-group.assistant .msg-role{color:var(--accent-text)}
 
144
  .msg-group.user .msg-bubble{background:var(--accent);color:#fff;border-bottom-right-radius:4px;box-shadow:0 2px 8px rgba(79,109,245,.15)}
145
  .msg-group.assistant .msg-bubble{background:var(--surface);border:1px solid var(--border-light);border-bottom-left-radius:4px}
146
  .msg-group.assistant .msg-bubble.generating{border-color:var(--accent);box-shadow:0 0 0 2px rgba(79,109,245,.08)}
 
147
  .msg-bubble.md{white-space:normal}
148
  .msg-bubble.md p{margin:0 0 .5em}.msg-bubble.md p:last-child{margin:0}
149
  .msg-bubble.md h1,.msg-bubble.md h2,.msg-bubble.md h3{margin:.7em 0 .35em;font-weight:600;line-height:1.3}
 
152
  .msg-bubble.md code{font-family:var(--font-mono);font-size:.84em;padding:2px 6px;background:var(--bg-cool);border-radius:5px;color:var(--accent-text)}
153
  .msg-bubble.md pre{margin:.5em 0;padding:12px;background:var(--bg-warm);border:1px solid var(--border-light);border-radius:var(--radius-sm);overflow-x:auto;line-height:1.5}
154
  .msg-bubble.md pre code{padding:0;background:none;color:var(--text);font-size:.78rem}
155
+ .msg-bubble.md ul,.msg-bubble.md ol{margin:.4em 0;padding-left:1.4em}.msg-bubble.md li{margin:.15em 0}
 
156
  .msg-bubble.md blockquote{margin:.5em 0;padding:8px 12px;border-left:3px solid var(--accent);color:var(--text-2);background:var(--accent-light);border-radius:0 var(--radius-xs) var(--radius-xs) 0}
157
  .msg-bubble.md table{margin:.5em 0;border-collapse:collapse;width:100%;font-size:.82rem}
158
  .msg-bubble.md th,.msg-bubble.md td{padding:6px 8px;border:1px solid var(--border);text-align:left}
159
  .msg-bubble.md th{background:var(--bg-cool);font-weight:600;color:var(--text-2);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
160
+ .msg-bubble.md strong{font-weight:600}.msg-bubble.md a{color:var(--accent)}
 
161
  .msg-bubble.md hr{margin:.7em 0;border:none;border-top:1px solid var(--border-light)}
162
  .msg-group.user .msg-bubble.md code{background:rgba(255,255,255,.2);color:#fff}
163
  .msg-group.user .msg-bubble.md pre{background:rgba(0,0,0,.1);border-color:rgba(255,255,255,.15)}
164
  .msg-group.user .msg-bubble.md pre code{color:#fff}
 
 
 
 
 
 
 
 
165
  .msg-stats{font-family:var(--font-mono);font-size:.58rem;color:var(--text-3);margin-top:5px;letter-spacing:.03em}
166
  .thinking-dots span{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-right:4px;animation:dot 1.2s ease-in-out infinite}
167
+ .thinking-dots span:nth-child(2){animation-delay:.2s}.thinking-dots span:nth-child(3){animation-delay:.4s}
 
168
  @keyframes dot{0%,80%,100%{opacity:.2;transform:scale(.8)}40%{opacity:1;transform:scale(1)}}
 
169
  .chat-input-area{padding:10px 12px;padding-bottom:calc(10px + var(--safe-bottom));border-top:1px solid var(--border-light);background:var(--bg);flex-shrink:0}
170
  .chat-input-row{display:flex;align-items:flex-end;gap:6px}
171
  .input-wrap{flex:1;position:relative}
 
176
  .btn-send:disabled{opacity:.3;cursor:not-allowed}
177
  .btn-send:not(:disabled):hover{background:var(--accent-hover);box-shadow:0 2px 8px rgba(79,109,245,.25)}
178
  .btn-send:not(:disabled):active{transform:scale(.95)}
179
+ .btn-send .icon-stop{display:none}.btn-send.stopping{background:var(--red)}
180
+ .btn-send.stopping .icon-send{display:none}.btn-send.stopping .icon-stop{display:block}
 
 
181
  .chat-footer-note{font-family:var(--font-mono);font-size:.55rem;color:var(--text-4);text-align:center;margin-top:6px;letter-spacing:.03em;padding:0 8px}
 
182
  .welcome-msg{text-align:center;padding:40px 20px;color:var(--text-3);flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center}
183
  .welcome-msg h3{font-family:var(--font-display);font-size:1.5rem;color:var(--text-2);margin-bottom:8px;font-weight:400;font-style:italic}
184
  .welcome-msg p{font-size:.85rem;line-height:1.65}
 
185
  .toast{position:fixed;bottom:calc(24px + var(--safe-bottom));left:50%;transform:translateX(-50%) translateY(20px);padding:10px 20px;border-radius:var(--radius-sm);font-size:.82rem;font-weight:500;background:var(--surface);color:var(--text);border:1px solid var(--border);opacity:0;transition:all .3s;z-index:1000;max-width:min(480px,90vw);box-shadow:var(--shadow-lg);pointer-events:none}
186
  .toast.show{transform:translateX(-50%) translateY(0);opacity:1}
187
+ .toast.error{border-color:var(--red);color:var(--red)}.toast.success{border-color:var(--green);color:var(--green)}
 
188
 
189
  @media(max-width:640px){
190
+ .chip{font-size:.62rem;padding:4px 10px}.landing-sub{font-size:.88rem;padding:0 8px}
 
191
  .chat-header{padding:8px 12px;padding-top:calc(8px + var(--safe-top));gap:6px}
192
+ .chat-header-title{font-size:.84rem}.chat-header-controls{gap:4px}
193
+ .toggle-pill{padding:4px 8px;min-height:28px}.toggle-lbl{font-size:.58rem}
 
 
194
  .btn-icon{width:28px;height:28px;font-size:.7rem}
195
  .btn-reset{font-size:.58rem;padding:5px 10px;min-height:28px}
196
+ .settings-panel.open{gap:6px 12px;padding:8px 12px}.settings-slider{width:56px}
197
+ .chat-messages{padding:12px}.msg-group{max-width:90%}
 
 
198
  .msg-bubble{padding:10px 14px;font-size:.85rem}
199
  .chat-input-area{padding:8px 10px;padding-bottom:calc(8px + var(--safe-bottom))}
200
  .btn-send{width:42px;height:42px}
201
  .input-wrap textarea{min-height:42px;padding:10px 12px;font-size:.86rem}
202
+ .stats-bar{gap:10px;padding:4px 12px}.system-prompt-wrap.open{padding:6px 12px 8px}
 
 
 
 
 
 
 
 
 
 
 
203
  }
204
+ @media(max-width:380px){.toggle-lbl{display:none}.landing-title{font-size:2rem}.chat-header-title{font-size:.78rem;max-width:120px}}
205
+ @media(max-height:500px) and (orientation:landscape){.chat-header{padding:6px 12px;min-height:44px}.chat-avatar{width:28px;height:28px;font-size:.8rem}.chat-messages{padding:8px 12px;gap:10px}}
206
  </style>
207
  </head>
208
  <body>
 
209
  <div id="landing" class="screen active">
210
  <div class="landing-deco"></div>
211
+ <div class="landing-tag">WebLLM Β· Mobile + Desktop Β· WebGPU</div>
212
+ <div class="device-badge" id="deviceBadge"></div>
213
  <h1 class="landing-title">Local <em>AI Chat</em></h1>
214
+ <p class="landing-sub">Run open-source LLMs entirely in your browser. No server, no API keys β€” powered by WebLLM.</p>
215
  <div class="landing-chips">
216
+ <span class="chip">Qwen3 Β· Llama Β· Gemma</span>
 
217
  <span class="chip">Streaming</span>
218
  <span class="chip">Markdown</span>
219
+ <span class="chip" id="chipDevice"></span>
220
  </div>
221
  <div class="model-search-wrap" id="modelSearchWrap">
222
+ <input class="model-search-input" id="modelSearchInput" type="text" value="" placeholder="Select a model…" autocomplete="off" readonly/>
223
  <span class="search-chevron">β–Ύ</span>
224
  <div class="model-dropdown" id="modelDropdown"></div>
225
  </div>
226
  <button class="btn-load" id="btnLoad">Load Model</button>
227
  <div class="landing-footer">Powered by <a href="https://webllm.mlc.ai/" target="_blank">WebLLM</a> + <a href="https://mlc.ai/" target="_blank">MLC-AI</a></div>
228
  </div>
 
229
  <div id="loading" class="screen">
230
  <div class="loader-ring" id="loaderRing"></div>
231
  <div><div class="loader-text" id="loaderText">Initializing engine…</div><div class="loader-sub" id="loaderSub">Model is cached after first download.</div></div>
232
  <div class="download-bar" id="downloadBar" style="display:none"><div class="download-text" id="downloadText">Downloading…</div><div class="download-track"><div class="download-fill" id="downloadFill"></div></div></div>
233
  </div>
 
234
  <div id="chat" class="screen">
235
  <div class="chat-header">
236
  <div class="chat-header-left"><div class="chat-avatar">W</div><div class="chat-header-info"><div class="chat-header-title" id="chatTitle">WebLLM Chat</div><div class="chat-header-status">Ready</div></div></div>
 
250
  <div class="system-prompt-wrap" id="sysPromptWrap"><textarea class="system-prompt-input" id="sysPromptInput" placeholder="System prompt (optional)…"></textarea></div>
251
  <div class="stats-bar" id="statsBar"></div>
252
  <div class="error-banner" id="errorBanner"></div>
253
+ <div class="chat-messages" id="chatMessages"><div class="welcome-msg" id="welcomeMsg"><h3>Start a conversation</h3><p>Type a message below.<br/>Everything runs locally in your browser.</p></div></div>
254
  <div class="chat-input-area">
255
  <div class="chat-input-row">
256
  <div class="input-wrap"><textarea id="msgInput" rows="1" placeholder="Type a message…"></textarea></div>
 
259
  <svg class="icon-stop" width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
260
  </button>
261
  </div>
262
+ <div class="chat-footer-note">100% local β€” no data leaves your device.</div>
263
  </div>
264
  </div>
 
265
  <div class="toast" id="toast"></div>
266
  <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script>
 
267
  <script type="module">
268
  import * as webllm from "https://esm.run/@mlc-ai/web-llm";
269
 
270
+ /* ═══ DEVICE DETECTION ═══ */
271
+ const isMobile = (()=>{
272
+ const ua = navigator.userAgent || "";
273
+ if (/Mobile|Android|iPhone|iPad|iPod|webOS|BlackBerry|Opera Mini|IEMobile/i.test(ua)) return true;
274
+ if ('ontouchstart' in window && window.innerWidth < 768) return true;
275
+ if (navigator.maxTouchPoints > 1 && window.innerWidth < 1024) return true;
276
+ return false;
277
+ })();
278
+
279
+ /* ═══ MODEL CATALOG (2025~2026 Latest) ═══ */
280
  const MODELS = [
281
+ // ── Qwen3 (Latest, 2025) ──
282
+ { id:"Qwen3-0.6B-q4f16_1-MLC", label:"Qwen3 0.6B", family:"Qwen3", vram:"500MB", maxVramMB:500, mobile:true, isNew:true, desc:"Thinking support, multilingual" },
283
+ { id:"Qwen3-1.7B-q4f16_1-MLC", label:"Qwen3 1.7B", family:"Qwen3", vram:"1.2GB", maxVramMB:1200, mobile:true, isNew:true, desc:"Best mobile quality" },
284
+ { id:"Qwen3-4B-q4f16_1-MLC", label:"Qwen3 4B", family:"Qwen3", vram:"2.5GB", maxVramMB:2500, mobile:false, isNew:true, desc:"Reasoning + code" },
285
+ { id:"Qwen3-8B-q4f16_1-MLC", label:"Qwen3 8B", family:"Qwen3", vram:"5.0GB", maxVramMB:5000, mobile:false, isNew:true, desc:"Full-size chat" },
286
+ { id:"Qwen3-30B-A3B-q4f16_1-MLC", label:"Qwen3 30B MoE (3B active)", family:"Qwen3", vram:"3.0GB", maxVramMB:3000, mobile:false, isNew:true, desc:"MoE efficiency" },
287
+ // ── Qwen2.5 (Stable) ──
288
+ { id:"Qwen2.5-0.5B-Instruct-q4f16_1-MLC", label:"Qwen2.5 0.5B Instruct", family:"Qwen2.5", vram:"945MB", maxVramMB:945, mobile:true, isNew:false, desc:"Lightweight instruct" },
289
+ { id:"Qwen2.5-1.5B-Instruct-q4f16_1-MLC", label:"Qwen2.5 1.5B Instruct", family:"Qwen2.5", vram:"1.5GB", maxVramMB:1500, mobile:false, isNew:false, desc:"Balanced quality" },
290
+ { id:"Qwen2.5-3B-Instruct-q4f16_1-MLC", label:"Qwen2.5 3B Instruct", family:"Qwen2.5", vram:"2.3GB", maxVramMB:2300, mobile:false, isNew:false, desc:"Strong multilingual" },
291
+ // ── DeepSeek R1 Distill ──
292
+ { id:"DeepSeek-R1-Distill-Qwen-1.5B-q4f16_1-MLC", label:"DeepSeek-R1 1.5B (Qwen)", family:"DeepSeek", vram:"1.5GB", maxVramMB:1500, mobile:false, isNew:true, desc:"Reasoning distilled" },
293
+ // ── SmolLM2 (Ultra-light) ──
294
+ { id:"SmolLM2-135M-Instruct-q0f32-MLC", label:"SmolLM2 135M", family:"SmolLM2", vram:"719MB", maxVramMB:719, mobile:true, isNew:false, desc:"Ultra-lightweight" },
295
+ { id:"SmolLM2-360M-Instruct-q4f16_1-MLC", label:"SmolLM2 360M", family:"SmolLM2", vram:"489MB", maxVramMB:489, mobile:true, isNew:false, desc:"Fastest loading" },
296
+ { id:"SmolLM2-1.7B-Instruct-q4f16_1-MLC", label:"SmolLM2 1.7B", family:"SmolLM2", vram:"1.2GB", maxVramMB:1200, mobile:true, isNew:false, desc:"Best SmolLM" },
297
+ // ── Llama 3.2 / 3.1 ──
298
+ { id:"Llama-3.2-1B-Instruct-q4f16_1-MLC", label:"Llama 3.2 1B", family:"Llama", vram:"1.1GB", maxVramMB:1100, mobile:true, isNew:false, desc:"Meta compact" },
299
+ { id:"Llama-3.2-3B-Instruct-q4f16_1-MLC", label:"Llama 3.2 3B", family:"Llama", vram:"2.2GB", maxVramMB:2200, mobile:false, isNew:false, desc:"Strong general" },
300
+ { id:"Llama-3.1-8B-Instruct-q4f16_1-MLC", label:"Llama 3.1 8B", family:"Llama", vram:"5.1GB", maxVramMB:5100, mobile:false, isNew:false, desc:"Full Llama" },
301
+ // ── Gemma 2 ──
302
+ { id:"gemma-2-2b-it-q4f16_1-MLC-1k", label:"Gemma-2 2B (1k ctx)", family:"Gemma", vram:"1.6GB", maxVramMB:1600, mobile:true, isNew:false, desc:"Short context mobile" },
303
+ { id:"gemma-2-2b-it-q4f16_1-MLC", label:"Gemma-2 2B (4k ctx)", family:"Gemma", vram:"1.9GB", maxVramMB:1900, mobile:false, isNew:false, desc:"Full context" },
304
+ // ── Phi 3.5 ──
305
+ { id:"Phi-3.5-mini-instruct-q4f16_1-MLC", label:"Phi-3.5 Mini 3.8B", family:"Phi", vram:"3.0GB", maxVramMB:3000, mobile:false, isNew:false, desc:"Microsoft reasoning" },
306
+ // ── Mistral ──
307
+ { id:"Mistral-7B-Instruct-v0.3-q4f16_1-MLC", label:"Mistral 7B v0.3", family:"Mistral", vram:"5.0GB", maxVramMB:5000, mobile:false, isNew:false, desc:"Classic Mistral" },
308
  ];
309
 
310
+ const MOBILE_VRAM_LIMIT = 1300; // MB
311
+
312
+ /* ═══ STATE ═══ */
313
+ let engine=null, isGenerating=false, abortController=null, chatHistory=[], totalTokens=0;
314
+ const $=id=>document.getElementById(id);
315
+ const getTemp=()=>parseInt($("tempSlider").value)/100;
316
+ const getTopP=()=>parseInt($("toppSlider").value)/100;
317
+ const getMaxTok=()=>parseInt($("maxTokSelect").value);
318
+ const getRepPen=()=>parseInt($("repPenSlider").value)/100;
319
+ const getSysPrompt=()=>$("sysPromptInput").value.trim();
320
+ document.fonts.ready.then(()=>document.body.classList.add("ready"));
321
+ const $messages=$("chatMessages"),$input=$("msgInput"),$btnSend=$("btnSend"),$btnLoad=$("btnLoad"),$btnReset=$("btnReset");
322
+ const $errBanner=$("errorBanner"),$streamToggle=$("streamToggle");
323
+ const $searchInput=$("modelSearchInput"),$searchWrap=$("modelSearchWrap"),$dropdown=$("modelDropdown");
324
+ const $downloadBar=$("downloadBar"),$downloadText=$("downloadText"),$downloadFill=$("downloadFill");
325
+ const $statsBar=$("statsBar"),$toast=$("toast");
326
+
327
+ /* ═══ DEVICE BADGE ═══ */
328
+ const $badge=$("deviceBadge"), $chipDev=$("chipDevice");
329
+ if(isMobile){
330
+ $badge.textContent="MOBILE DETECTED";$badge.className="device-badge mobile";
331
+ $chipDev.textContent="Mobile optimized";
332
+ }else{
333
+ $badge.textContent="DESKTOP DETECTED";$badge.className="device-badge desktop";
334
+ $chipDev.textContent="All models available";
335
+ }
 
336
 
337
+ /* ═══ SET DEFAULT MODEL ═══ */
338
+ const defaultModel = isMobile ? "Qwen3-0.6B-q4f16_1-MLC" : "Qwen3-4B-q4f16_1-MLC";
339
+ $searchInput.value = defaultModel;
340
+
341
+ /* ═══ TOAST ═══ */
342
+ let toastTimer=null;
343
+ function showToast(m,t=""){clearTimeout(toastTimer);$toast.textContent=m;$toast.className="toast "+t+" show";toastTimer=setTimeout(()=>$toast.classList.remove("show"),3000)}
344
+
345
+ /* ═══ STATS ═══ */
346
+ function updateStatsBar(tps=null,tokens=null){
347
+ if(!totalTokens&&!tps){$statsBar.innerHTML="";return}
348
+ let h="";
349
+ if(tps!==null)h+=`<div class="stat"><span class="stat-label">Speed</span><span class="stat-value hl">${tps} t/s</span></div>`;
350
+ if(tokens!==null)h+=`<div class="stat"><span class="stat-label">Tokens</span><span class="stat-value">${tokens}</span></div>`;
351
+ if(totalTokens>0)h+=`<div class="stat"><span class="stat-label">Session</span><span class="stat-value">${totalTokens}</span></div>`;
352
+ $statsBar.innerHTML=h;
353
  }
354
 
355
+ /* ═══ MODEL DROPDOWN ═══ */
356
+ function renderDropdown(filter=""){
357
+ const q=filter.toLowerCase();
358
+ const filtered=q?MODELS.filter(m=>m.label.toLowerCase().includes(q)||m.id.toLowerCase().includes(q)||m.family.toLowerCase().includes(q)):MODELS;
359
+ const families=[...new Set(filtered.map(m=>m.family))];
360
+ let html="";
361
+ for(const fam of families){
362
+ const group=filtered.filter(m=>m.family===fam);
363
+ html+=`<div class="model-section-label">${fam}</div>`;
364
+ for(const m of group){
365
+ const locked=isMobile&&m.maxVramMB>MOBILE_VRAM_LIMIT;
366
+ const cls=locked?"model-item locked":"model-item";
367
+ const badges=[
368
+ m.isNew?'<span class="new-badge">NEW</span>':"",
369
+ m.mobile?'<span class="mobile-badge">MOBILE</span>':"",
370
+ `<span class="vram-badge">${m.vram}</span>`,
371
+ locked?'<span class="lock-badge">PC ONLY</span>':"",
372
+ ].filter(Boolean).join("");
373
+ html+=`<div class="${cls}" data-id="${m.id}" title="${m.desc}"><span class="model-item-name">${m.label}</span><span class="model-item-meta">${badges}</span></div>`;
374
+ }
375
  }
376
+ if(!filtered.length)html='<div class="model-dropdown-loading">No models found</div>';
377
+ $dropdown.innerHTML=html;
378
+ $dropdown.querySelectorAll(".model-item:not(.locked)").forEach(el=>{
379
+ el.addEventListener("click",()=>{$searchInput.value=el.dataset.id;closeDropdown()});
380
  });
381
  }
382
 
383
+ function openDropdown(){$searchWrap.classList.add("open");renderDropdown()}
384
+ function closeDropdown(){$searchWrap.classList.remove("open")}
385
+ $searchInput.addEventListener("click",()=>{$searchInput.removeAttribute("readonly");openDropdown()});
386
+ $searchInput.addEventListener("focus",()=>{$searchInput.removeAttribute("readonly");openDropdown()});
387
+ $searchInput.addEventListener("input",()=>renderDropdown($searchInput.value));
388
+ document.addEventListener("click",e=>{if(!e.target.closest(".model-search-wrap"))closeDropdown()});
389
+ $searchInput.addEventListener("keydown",e=>{
390
+ if(e.key==="Escape")closeDropdown();
391
+ const items=$dropdown.querySelectorAll(".model-item:not(.locked)");if(!items.length)return;
392
+ const active=$dropdown.querySelector(".model-item.active");let idx=Array.from(items).indexOf(active);
393
+ if(e.key==="ArrowDown"){e.preventDefault();idx=Math.min(idx+1,items.length-1);items.forEach(i=>i.classList.remove("active"));items[idx].classList.add("active");items[idx].scrollIntoView({block:"nearest"})}
394
+ if(e.key==="ArrowUp"){e.preventDefault();idx=Math.max(idx-1,0);items.forEach(i=>i.classList.remove("active"));items[idx].classList.add("active");items[idx].scrollIntoView({block:"nearest"})}
395
+ if(e.key==="Enter"){e.preventDefault();if(active){$searchInput.value=active.dataset.id}closeDropdown()}
 
 
 
 
 
 
 
 
 
 
 
 
396
  });
397
 
398
+ /* ═══ SETTINGS ═══ */
399
+ $("btnSettings").addEventListener("click",()=>{$("settingsPanel").classList.toggle("open");$("btnSettings").classList.toggle("active")});
400
+ $("btnSysPrompt").addEventListener("click",()=>{$("sysPromptWrap").classList.toggle("open");$("btnSysPrompt").classList.toggle("active")});
401
+ $("tempSlider").addEventListener("input",()=>$("tempVal").textContent=getTemp().toFixed(2));
402
+ $("toppSlider").addEventListener("input",()=>$("toppVal").textContent=getTopP().toFixed(2));
403
+ $("repPenSlider").addEventListener("input",()=>$("repPenVal").textContent=getRepPen().toFixed(2));
404
+ function showScreen(id){document.querySelectorAll(".screen").forEach(s=>s.classList.toggle("active",s.id===id))}
405
+
406
+ /* ═══ ENGINE LOAD ═══ */
407
+ $btnLoad.addEventListener("click",async()=>{
408
+ const modelId=$searchInput.value.trim();
409
+ if(!modelId){showToast("Select a model","error");return}
410
+ const m=MODELS.find(x=>x.id===modelId);
411
+ if(isMobile&&m&&m.maxVramMB>MOBILE_VRAM_LIMIT){showToast("This model requires a PC β€” too large for mobile","error");return}
412
+ showScreen("loading");$downloadBar.style.display="none";
413
+ try{
414
+ $("loaderText").textContent="Initializing WebLLM…";
415
+ engine=await webllm.CreateMLCEngine(modelId,{
416
+ initProgressCallback:(p)=>{$downloadBar.style.display="";const pct=Math.round(p.progress*100);$downloadFill.style.width=pct+"%";$downloadText.textContent=p.text||`Loading: ${pct}%`;$("loaderText").textContent=p.text||"Loading model…"}
 
 
 
 
 
 
417
  });
418
+ $("loaderText").textContent="Ready!";
419
+ $("chatTitle").textContent=m?m.label:modelId;
420
+ setTimeout(()=>showScreen("chat"),300);
421
+ showToast("Model loaded!","success");
422
+ }catch(err){
423
+ console.error(err);$("loaderText").textContent="Failed to load";
424
+ $("loaderSub").textContent=err.message;$("loaderRing").style.borderTopColor="var(--red)";
425
+ showToast("Load failed","error");
 
 
 
426
  }
427
  });
428
 
429
+ /* ═══ INPUT ═══ */
430
+ $input.addEventListener("input",()=>{$input.style.height="auto";$input.style.height=Math.min($input.scrollHeight,120)+"px";updateSendBtn()});
431
+ $input.addEventListener("keydown",e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();if(!isGenerating)sendMessage()}});
432
+ $btnSend.addEventListener("click",()=>{if(isGenerating)abortGeneration();else sendMessage()});
433
+ function updateSendBtn(){if(isGenerating){$btnSend.disabled=false;$btnSend.classList.add("stopping")}else{$btnSend.classList.remove("stopping");$btnSend.disabled=!$input.value.trim()}}
434
+ function abortGeneration(){if(abortController){abortController.abort();abortController=null}}
435
+
436
+ /* ═══ RESET ═══ */
437
+ $btnReset.addEventListener("click",async()=>{
438
+ chatHistory=[];totalTokens=0;
439
+ if(engine)try{await engine.resetChat()}catch(e){}
440
+ $messages.innerHTML='<div class="welcome-msg"><h3>Start a conversation</h3><p>Type a message below.<br/>Everything runs locally in your browser.</p></div>';
441
+ $errBanner.classList.remove("visible");$input.value="";$input.style.height="auto";updateStatsBar();updateSendBtn();
 
 
 
 
 
 
442
  });
443
 
444
+ /* ═══ MARKDOWN ═══ */
445
+ function renderMarkdown(t){if(typeof marked==="undefined")return escapeHtml(t);try{marked.setOptions({breaks:true,gfm:true});return marked.parse(t)}catch{return escapeHtml(t)}}
446
+ function escapeHtml(s){return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}
 
 
 
447
 
448
+ /* ═══ SEND ═══ */
449
+ async function sendMessage(){
450
+ if(isGenerating||!engine)return;const text=$input.value.trim();if(!text)return;
 
 
451
  $errBanner.classList.remove("visible");
452
+ const w=$messages.querySelector(".welcome-msg");if(w)w.remove();
453
+ appendMessage("user",text);chatHistory.push({role:"user",content:text});
454
+ $input.value="";$input.style.height="auto";isGenerating=true;updateSendBtn();
455
+ const{groupEl,bubbleEl}=appendAssistantPlaceholder();
456
+ try{
457
+ const messages=[];const sp=getSysPrompt();if(sp)messages.push({role:"system",content:sp});
 
 
 
 
 
 
 
 
 
 
458
  messages.push(...chatHistory);
459
+ let fullText="",tokenCount=0;const startTime=performance.now();
460
+ const textNode=document.createElement("div");textNode.className="msg-text";bubbleEl.appendChild(textNode);
461
+ if($streamToggle.checked){
462
+ abortController=new AbortController();
463
+ const stream=await engine.chat.completions.create({messages,temperature:getTemp(),top_p:getTopP(),max_tokens:getMaxTok(),frequency_penalty:getRepPen()-1,stream:true,stream_options:{include_usage:true}});
464
+ for await(const chunk of stream){
465
+ if(abortController?.signal?.aborted)break;
466
+ const delta=chunk.choices?.[0]?.delta?.content;
467
+ if(delta){fullText+=delta;tokenCount++;textNode.innerHTML=renderMarkdown(fullText);$messages.scrollTop=$messages.scrollHeight}
468
+ if(chunk.usage)tokenCount=chunk.usage.completion_tokens||tokenCount;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  }
470
+ }else{
471
+ const reply=await engine.chat.completions.create({messages,temperature:getTemp(),top_p:getTopP(),max_tokens:getMaxTok(),frequency_penalty:getRepPen()-1});
472
+ fullText=reply.choices[0]?.message?.content||"";tokenCount=reply.usage?.completion_tokens||0;textNode.innerHTML=renderMarkdown(fullText);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
  }
474
+ chatHistory.push({role:"assistant",content:fullText});
475
+ if(fullText.includes("`")||fullText.includes("#")||fullText.includes("|")||fullText.includes("*"))bubbleEl.classList.add("md");
476
+ const elapsed=(performance.now()-startTime)/1000;const tps=tokenCount>0?(tokenCount/elapsed).toFixed(1):"?";
477
+ const se=document.createElement("div");se.className="msg-stats";se.textContent=`${tokenCount} tokens Β· ${tps} tok/s Β· ${elapsed.toFixed(1)}s`;groupEl.appendChild(se);
478
+ totalTokens+=tokenCount;updateStatsBar(tps,tokenCount);bubbleEl.classList.remove("generating");
479
+ }catch(err){
480
+ if(err.name==="AbortError"){bubbleEl.classList.remove("generating");showToast("Stopped","")}
481
+ else{console.error(err);groupEl.remove();$errBanner.textContent="Error: "+err.message;$errBanner.classList.add("visible");showToast("Failed","error")}
482
  }
483
+ isGenerating=false;abortController=null;updateSendBtn();$messages.scrollTop=$messages.scrollHeight;
 
 
 
 
484
  }
485
 
486
+ /* ═══ RENDER ═══ */
487
+ function appendMessage(role,text){
488
+ const g=document.createElement("div");g.className=`msg-group ${role}`;
489
+ const r=document.createElement("div");r.className="msg-role";r.textContent=role==="user"?"You":"AI";g.appendChild(r);
490
+ const b=document.createElement("div");b.className="msg-bubble";b.textContent=text;g.appendChild(b);
491
+ $messages.appendChild(g);$messages.scrollTop=$messages.scrollHeight;return g;
492
  }
493
+ function appendAssistantPlaceholder(){
494
+ const g=document.createElement("div");g.className="msg-group assistant";
495
+ const r=document.createElement("div");r.className="msg-role";r.textContent="AI";g.appendChild(r);
496
+ const b=document.createElement("div");b.className="msg-bubble generating";
497
+ const d=document.createElement("span");d.className="thinking-dots";
498
+ for(let i=0;i<3;i++)d.appendChild(document.createElement("span"));
499
+ b.appendChild(d);g.appendChild(b);$messages.appendChild(g);$messages.scrollTop=$messages.scrollHeight;
500
+ return{groupEl:g,bubbleEl:b};
 
501
  }
502
+ window.visualViewport?.addEventListener("resize",()=>{$messages.scrollTop=$messages.scrollHeight});
503
 
504
+ /* ═══ WEBGPU CHECK ═══ */
505
+ (async()=>{
506
+ if(!navigator.gpu){showToast("WebGPU not available β€” try Chrome or Edge","error");$btnLoad.disabled=true;return}
507
+ try{const a=await navigator.gpu.requestAdapter({powerPreference:"high-performance"});if(!a){showToast("No WebGPU adapter","error");$btnLoad.disabled=true}}
508
+ catch(e){showToast("WebGPU init failed","error")}
 
 
 
 
 
 
 
 
 
509
  })();
510
  </script>
511
  </body>