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

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +140 -179
index.html CHANGED
@@ -25,7 +25,6 @@
25
  --amber:#c08520;--amber-bg:#fdf6e8;
26
  --radius:14px;--radius-sm:10px;--radius-xs:7px;
27
  --shadow-sm:0 1px 3px rgba(0,0,0,.04),0 1px 2px rgba(0,0,0,.02);
28
- --shadow-md:0 4px 16px rgba(0,0,0,.06),0 1px 4px rgba(0,0,0,.04);
29
  --shadow-lg:0 12px 40px rgba(0,0,0,.08),0 2px 8px rgba(0,0,0,.04);
30
  --font:'Plus Jakarta Sans',system-ui,-apple-system,sans-serif;
31
  --font-display:'Newsreader',Georgia,serif;
@@ -39,7 +38,6 @@ body.ready{opacity:1;transition:opacity .3s}
39
  input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appearance:none;appearance:none}
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}
@@ -48,39 +46,34 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
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}
52
- .landing-chips{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-bottom:32px}
53
- .chip{font-family:var(--font-mono);font-size:.68rem;font-weight:500;padding:5px 12px;border-radius:100px;border:1px solid var(--border);color:var(--text-2);background:var(--surface);letter-spacing:.02em}
54
  .model-search-wrap{position:relative;width:100%;max-width:440px;margin-bottom:20px}
55
- .model-search-input{width:100%;padding:14px 40px 14px 16px;border-radius:var(--radius-sm);border:1.5px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font-mono);font-size:.82rem;outline:none;transition:border-color .2s,box-shadow .2s}
56
  .model-search-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,109,245,.1)}
57
  .model-search-input::placeholder{color:var(--text-4)}
58
  .search-chevron{position:absolute;right:14px;top:50%;transform:translateY(-50%);color:var(--text-4);font-size:.65rem;pointer-events:none;transition:transform .2s}
59
  .model-search-wrap.open .search-chevron{transform:translateY(-50%) rotate(180deg)}
60
- .model-dropdown{position:absolute;top:100%;left:0;right:0;background:var(--surface);border:1.5px solid var(--accent);border-top:none;border-radius:0 0 var(--radius-sm) var(--radius-sm);max-height:320px;overflow-y:auto;z-index:100;display:none;box-shadow:var(--shadow-lg)}
61
  .model-search-wrap.open .model-dropdown{display:block}
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}
78
  .btn-load:hover{background:var(--accent-hover);box-shadow:0 4px 20px rgba(79,109,245,.3);transform:translateY(-1px)}
79
  .btn-load:active{transform:translateY(0)}
80
  .btn-load:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}
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)}}
@@ -90,13 +83,12 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
90
  .download-text{font-family:var(--font-mono);font-size:.68rem;color:var(--text-3);text-align:center;margin-bottom:6px}
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}
97
  .chat-avatar{width:36px;height:36px;border-radius:var(--radius-sm);background:linear-gradient(135deg,var(--accent),#8b5cf6);display:flex;align-items:center;justify-content:center;font-family:var(--font-display);font-size:1.05rem;color:#fff;font-weight:600;flex-shrink:0}
98
  .chat-header-info{min-width:0}
99
- .chat-header-title{font-size:.92rem;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
100
  .chat-header-status{font-family:var(--font-mono);font-size:.6rem;color:var(--green);letter-spacing:.05em;display:flex;align-items:center;gap:4px;font-weight:500}
101
  .chat-header-status::before{content:"";width:5px;height:5px;background:var(--green);border-radius:50%;flex-shrink:0}
102
  .chat-header-controls{display:flex;align-items:center;gap:6px;flex-shrink:0}
@@ -108,8 +100,7 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
108
  .toggle-lbl{font-size:.65rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);transition:color .2s;white-space:nowrap}
109
  .toggle-pill:has(input:checked) .toggle-lbl{color:var(--accent-text)}
110
  .btn-icon{background:var(--surface);border:1px solid var(--border);color:var(--text-3);width:32px;height:32px;border-radius:var(--radius-xs);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;font-size:.78rem;flex-shrink:0}
111
- .btn-icon:hover{background:var(--bg-cool);color:var(--text-2)}
112
- .btn-icon:active{transform:scale(.95)}
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)}
@@ -118,7 +109,6 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
118
  .settings-row{display:flex;align-items:center;gap:5px}
119
  .settings-label{font-family:var(--font-mono);font-size:.6rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-3);min-width:40px;white-space:nowrap}
120
  .settings-select{padding:4px 8px;border-radius:var(--radius-xs);border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:.72rem;outline:none;cursor:pointer;min-height:32px}
121
- .settings-select:focus{border-color:var(--accent)}
122
  .settings-slider{width:64px;accent-color:var(--accent);height:3px;cursor:pointer}
123
  .settings-val{font-family:var(--font-mono);font-size:.68rem;color:var(--text-2);font-variant-numeric:tabular-nums;min-width:28px;text-align:right}
124
  .system-prompt-wrap{padding:0 16px;max-height:0;overflow:hidden;transition:max-height .3s,padding .3s;background:var(--bg)}
@@ -144,11 +134,9 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
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}
150
  .msg-bubble.md h1{font-size:1.2em}.msg-bubble.md h2{font-size:1.1em}.msg-bubble.md h3{font-size:1em}
151
- .msg-bubble.md h1:first-child,.msg-bubble.md h2:first-child,.msg-bubble.md h3:first-child{margin-top:0}
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}
@@ -156,40 +144,37 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
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}
172
- .input-wrap textarea{width:100%;min-height:44px;max-height:120px;padding:11px 14px;background:var(--bg-warm);border:1.5px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:var(--font);font-size:.88rem;line-height:1.5;resize:none;outline:none;transition:border-color .2s,box-shadow .2s}
173
  .input-wrap textarea::placeholder{color:var(--text-4)}
174
  .input-wrap textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,109,245,.08)}
175
  .btn-send{width:44px;height:44px;border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;flex-shrink:0;cursor:pointer;background:var(--accent);border:none;color:#fff;transition:all .15s}
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}
@@ -199,10 +184,8 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
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>
@@ -212,30 +195,25 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
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>
237
  <div class="chat-header-controls">
238
- <label class="toggle-pill" title="Stream responses"><input type="checkbox" id="streamToggle" checked/><span class="toggle-dot"></span><span class="toggle-lbl">Stream</span></label>
239
  <button class="btn-icon" id="btnSettings" title="Settings">βš™</button>
240
  <button class="btn-icon" id="btnSysPrompt" title="System prompt">S</button>
241
  <button class="btn-reset" id="btnReset">Reset</button>
@@ -244,17 +222,16 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
244
  <div class="settings-panel" id="settingsPanel">
245
  <div class="settings-row"><span class="settings-label">Temp</span><input type="range" class="settings-slider" id="tempSlider" min="0" max="200" value="70"/><span class="settings-val" id="tempVal">0.70</span></div>
246
  <div class="settings-row"><span class="settings-label">Top-P</span><input type="range" class="settings-slider" id="toppSlider" min="1" max="100" value="95"/><span class="settings-val" id="toppVal">0.95</span></div>
247
- <div class="settings-row"><span class="settings-label">Tokens</span><select class="settings-select" id="maxTokSelect"><option value="256">256</option><option value="512" selected>512</option><option value="1024">1024</option><option value="2048">2048</option><option value="4096">4096</option></select></div>
248
- <div class="settings-row"><span class="settings-label">Rep</span><input type="range" class="settings-slider" id="repPenSlider" min="100" max="200" value="110"/><span class="settings-val" id="repPenVal">1.10</span></div>
249
  </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>
257
- <button class="btn-send" id="btnSend" disabled title="Send">
258
  <svg class="icon-send" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
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>
@@ -267,76 +244,73 @@ input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appea
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;
@@ -348,42 +322,38 @@ function updateStatsBar(tps=null,tokens=null){
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=>{
@@ -392,7 +362,7 @@ $searchInput.addEventListener("keydown",e=>{
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 ═══ */
@@ -400,112 +370,103 @@ $("btnSettings").addEventListener("click",()=>{$("settingsPanel").classList.togg
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>
 
25
  --amber:#c08520;--amber-bg:#fdf6e8;
26
  --radius:14px;--radius-sm:10px;--radius-xs:7px;
27
  --shadow-sm:0 1px 3px rgba(0,0,0,.04),0 1px 2px rgba(0,0,0,.02);
 
28
  --shadow-lg:0 12px 40px rgba(0,0,0,.08),0 2px 8px rgba(0,0,0,.04);
29
  --font:'Plus Jakarta Sans',system-ui,-apple-system,sans-serif;
30
  --font-display:'Newsreader',Georgia,serif;
 
38
  input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appearance:none;appearance:none}
39
  ::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:10px}
40
  .screen{display:none;width:100%;height:100%}.screen.active{display:flex}
 
41
  #landing{flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:24px;position:relative;overflow-y:auto;background:var(--bg)}
42
  .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}
43
  .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.desktop{background:var(--accent-light);color:var(--accent-text)}
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:28px;font-weight:400}
50
+ .model-count-info{font-family:var(--font-mono);font-size:.7rem;color:var(--text-3);margin-bottom:16px}
 
51
  .model-search-wrap{position:relative;width:100%;max-width:440px;margin-bottom:20px}
52
+ .model-search-input{width:100%;padding:14px 40px 14px 16px;border-radius:var(--radius-sm);border:1.5px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font-mono);font-size:.8rem;outline:none;transition:border-color .2s,box-shadow .2s}
53
  .model-search-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,109,245,.1)}
54
  .model-search-input::placeholder{color:var(--text-4)}
55
  .search-chevron{position:absolute;right:14px;top:50%;transform:translateY(-50%);color:var(--text-4);font-size:.65rem;pointer-events:none;transition:transform .2s}
56
  .model-search-wrap.open .search-chevron{transform:translateY(-50%) rotate(180deg)}
57
+ .model-dropdown{position:absolute;top:100%;left:0;right:0;background:var(--surface);border:1.5px solid var(--accent);border-top:none;border-radius:0 0 var(--radius-sm) var(--radius-sm);max-height:350px;overflow-y:auto;z-index:100;display:none;box-shadow:var(--shadow-lg)}
58
  .model-search-wrap.open .model-dropdown{display:block}
59
  .model-search-wrap.open .model-search-input{border-radius:var(--radius-sm) var(--radius-sm) 0 0;border-color:var(--accent)}
60
+ .model-item{padding:10px 14px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:6px;border-bottom:1px solid var(--border-light);transition:background .1s;min-height:44px}
61
  .model-item:last-child{border-bottom:none}
62
  .model-item:hover:not(.locked),.model-item.active:not(.locked){background:var(--accent-light)}
63
+ .model-item.locked{opacity:.35;cursor:not-allowed}
64
+ .model-item-name{font-family:var(--font-mono);font-size:.72rem;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
65
  .model-item.locked .model-item-name{text-decoration:line-through}
66
+ .model-item-meta{display:flex;gap:3px;align-items:center;flex-shrink:0}
67
+ .vram-badge{background:var(--amber-bg);color:var(--amber);padding:2px 6px;border-radius:5px;font-family:var(--font-mono);font-size:.54rem;font-weight:600}
68
+ .mobile-ok{background:var(--green-bg);color:var(--green);padding:2px 6px;border-radius:5px;font-family:var(--font-mono);font-size:.52rem;font-weight:700}
69
+ .lock-badge{background:var(--red-bg);color:var(--red);padding:2px 6px;border-radius:5px;font-family:var(--font-mono);font-size:.52rem;font-weight:700}
70
+ .model-section-label{padding:7px 14px 3px;font-family:var(--font-mono);font-size:.56rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--text-3);background:var(--bg-warm);position:sticky;top:0;z-index:1}
 
 
 
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}
72
  .btn-load:hover{background:var(--accent-hover);box-shadow:0 4px 20px rgba(79,109,245,.3);transform:translateY(-1px)}
73
  .btn-load:active{transform:translateY(0)}
74
  .btn-load:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}
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
  #loading{flex-direction:column;align-items:center;justify-content:center;gap:24px;padding:24px;background:var(--bg)}
78
  .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}
79
  @keyframes spin{to{transform:rotate(360deg)}}
 
83
  .download-text{font-family:var(--font-mono);font-size:.68rem;color:var(--text-3);text-align:center;margin-bottom:6px}
84
  .download-track{height:4px;background:var(--bg-cool);border-radius:2px;overflow:hidden}
85
  .download-fill{height:100%;width:0%;background:var(--accent);border-radius:2px;transition:width .3s}
 
86
  #chat{flex-direction:column;height:100%;background:var(--bg-warm)}
87
  .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}
88
  .chat-header-left{display:flex;align-items:center;gap:10px;min-width:0}
89
  .chat-avatar{width:36px;height:36px;border-radius:var(--radius-sm);background:linear-gradient(135deg,var(--accent),#8b5cf6);display:flex;align-items:center;justify-content:center;font-family:var(--font-display);font-size:1.05rem;color:#fff;font-weight:600;flex-shrink:0}
90
  .chat-header-info{min-width:0}
91
+ .chat-header-title{font-size:.88rem;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
92
  .chat-header-status{font-family:var(--font-mono);font-size:.6rem;color:var(--green);letter-spacing:.05em;display:flex;align-items:center;gap:4px;font-weight:500}
93
  .chat-header-status::before{content:"";width:5px;height:5px;background:var(--green);border-radius:50%;flex-shrink:0}
94
  .chat-header-controls{display:flex;align-items:center;gap:6px;flex-shrink:0}
 
100
  .toggle-lbl{font-size:.65rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);transition:color .2s;white-space:nowrap}
101
  .toggle-pill:has(input:checked) .toggle-lbl{color:var(--accent-text)}
102
  .btn-icon{background:var(--surface);border:1px solid var(--border);color:var(--text-3);width:32px;height:32px;border-radius:var(--radius-xs);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;font-size:.78rem;flex-shrink:0}
103
+ .btn-icon:hover{background:var(--bg-cool);color:var(--text-2)}.btn-icon:active{transform:scale(.95)}
 
104
  .btn-icon.active{background:var(--accent-light);color:var(--accent-text);border-color:var(--accent)}
105
  .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}
106
  .btn-reset:hover{color:var(--red);border-color:var(--red);background:var(--red-bg)}
 
109
  .settings-row{display:flex;align-items:center;gap:5px}
110
  .settings-label{font-family:var(--font-mono);font-size:.6rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-3);min-width:40px;white-space:nowrap}
111
  .settings-select{padding:4px 8px;border-radius:var(--radius-xs);border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:.72rem;outline:none;cursor:pointer;min-height:32px}
 
112
  .settings-slider{width:64px;accent-color:var(--accent);height:3px;cursor:pointer}
113
  .settings-val{font-family:var(--font-mono);font-size:.68rem;color:var(--text-2);font-variant-numeric:tabular-nums;min-width:28px;text-align:right}
114
  .system-prompt-wrap{padding:0 16px;max-height:0;overflow:hidden;transition:max-height .3s,padding .3s;background:var(--bg)}
 
134
  .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)}
135
  .msg-group.assistant .msg-bubble{background:var(--surface);border:1px solid var(--border-light);border-bottom-left-radius:4px}
136
  .msg-group.assistant .msg-bubble.generating{border-color:var(--accent);box-shadow:0 0 0 2px rgba(79,109,245,.08)}
137
+ .msg-bubble.md{white-space:normal}.msg-bubble.md p{margin:0 0 .5em}.msg-bubble.md p:last-child{margin:0}
 
138
  .msg-bubble.md h1,.msg-bubble.md h2,.msg-bubble.md h3{margin:.7em 0 .35em;font-weight:600;line-height:1.3}
139
  .msg-bubble.md h1{font-size:1.2em}.msg-bubble.md h2{font-size:1.1em}.msg-bubble.md h3{font-size:1em}
 
140
  .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)}
141
  .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}
142
  .msg-bubble.md pre code{padding:0;background:none;color:var(--text);font-size:.78rem}
 
144
  .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}
145
  .msg-bubble.md table{margin:.5em 0;border-collapse:collapse;width:100%;font-size:.82rem}
146
  .msg-bubble.md th,.msg-bubble.md td{padding:6px 8px;border:1px solid var(--border);text-align:left}
147
+ .msg-bubble.md th{background:var(--bg-cool);font-weight:600;color:var(--text-2);font-size:.72rem;text-transform:uppercase}
148
  .msg-bubble.md strong{font-weight:600}.msg-bubble.md a{color:var(--accent)}
 
149
  .msg-group.user .msg-bubble.md code{background:rgba(255,255,255,.2);color:#fff}
150
  .msg-group.user .msg-bubble.md pre{background:rgba(0,0,0,.1);border-color:rgba(255,255,255,.15)}
151
  .msg-group.user .msg-bubble.md pre code{color:#fff}
152
+ .msg-stats{font-family:var(--font-mono);font-size:.58rem;color:var(--text-3);margin-top:5px}
153
  .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}
154
  .thinking-dots span:nth-child(2){animation-delay:.2s}.thinking-dots span:nth-child(3){animation-delay:.4s}
155
  @keyframes dot{0%,80%,100%{opacity:.2;transform:scale(.8)}40%{opacity:1;transform:scale(1)}}
156
  .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}
157
  .chat-input-row{display:flex;align-items:flex-end;gap:6px}
158
+ .input-wrap{flex:1}.input-wrap textarea{width:100%;min-height:44px;max-height:120px;padding:11px 14px;background:var(--bg-warm);border:1.5px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:var(--font);font-size:.88rem;line-height:1.5;resize:none;outline:none;transition:border-color .2s,box-shadow .2s}
 
159
  .input-wrap textarea::placeholder{color:var(--text-4)}
160
  .input-wrap textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,109,245,.08)}
161
  .btn-send{width:44px;height:44px;border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;flex-shrink:0;cursor:pointer;background:var(--accent);border:none;color:#fff;transition:all .15s}
162
  .btn-send:disabled{opacity:.3;cursor:not-allowed}
163
+ .btn-send:not(:disabled):hover{background:var(--accent-hover)}
164
  .btn-send:not(:disabled):active{transform:scale(.95)}
165
  .btn-send .icon-stop{display:none}.btn-send.stopping{background:var(--red)}
166
  .btn-send.stopping .icon-send{display:none}.btn-send.stopping .icon-stop{display:block}
167
+ .chat-footer-note{font-family:var(--font-mono);font-size:.55rem;color:var(--text-4);text-align:center;margin-top:6px;padding:0 8px}
168
  .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}
169
  .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}
170
  .welcome-msg p{font-size:.85rem;line-height:1.65}
171
  .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}
172
  .toast.show{transform:translateX(-50%) translateY(0);opacity:1}
173
  .toast.error{border-color:var(--red);color:var(--red)}.toast.success{border-color:var(--green);color:var(--green)}
 
174
  @media(max-width:640px){
175
+ .landing-sub{font-size:.88rem;padding:0 8px}
176
  .chat-header{padding:8px 12px;padding-top:calc(8px + var(--safe-top));gap:6px}
177
+ .chat-header-title{font-size:.82rem}.chat-header-controls{gap:4px}
178
  .toggle-pill{padding:4px 8px;min-height:28px}.toggle-lbl{font-size:.58rem}
179
  .btn-icon{width:28px;height:28px;font-size:.7rem}
180
  .btn-reset{font-size:.58rem;padding:5px 10px;min-height:28px}
 
184
  .chat-input-area{padding:8px 10px;padding-bottom:calc(8px + var(--safe-bottom))}
185
  .btn-send{width:42px;height:42px}
186
  .input-wrap textarea{min-height:42px;padding:10px 12px;font-size:.86rem}
 
187
  }
188
  @media(max-width:380px){.toggle-lbl{display:none}.landing-title{font-size:2rem}.chat-header-title{font-size:.78rem;max-width:120px}}
 
189
  </style>
190
  </head>
191
  <body>
 
195
  <div class="device-badge" id="deviceBadge"></div>
196
  <h1 class="landing-title">Local <em>AI Chat</em></h1>
197
  <p class="landing-sub">Run open-source LLMs entirely in your browser. No server, no API keys β€” powered by WebLLM.</p>
198
+ <div class="model-count-info" id="modelCountInfo">Loading model list…</div>
 
 
 
 
 
199
  <div class="model-search-wrap" id="modelSearchWrap">
200
+ <input class="model-search-input" id="modelSearchInput" type="text" value="" placeholder="Select a model…" autocomplete="off"/>
201
  <span class="search-chevron">β–Ύ</span>
202
  <div class="model-dropdown" id="modelDropdown"></div>
203
  </div>
204
+ <button class="btn-load" id="btnLoad" disabled>Load Model</button>
205
  <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>
206
  </div>
207
  <div id="loading" class="screen">
208
  <div class="loader-ring" id="loaderRing"></div>
209
+ <div><div class="loader-text" id="loaderText">Initializing…</div><div class="loader-sub" id="loaderSub">Model is cached after first download.</div></div>
210
  <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>
211
  </div>
212
  <div id="chat" class="screen">
213
  <div class="chat-header">
214
  <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>
215
  <div class="chat-header-controls">
216
+ <label class="toggle-pill" title="Stream"><input type="checkbox" id="streamToggle" checked/><span class="toggle-dot"></span><span class="toggle-lbl">Stream</span></label>
217
  <button class="btn-icon" id="btnSettings" title="Settings">βš™</button>
218
  <button class="btn-icon" id="btnSysPrompt" title="System prompt">S</button>
219
  <button class="btn-reset" id="btnReset">Reset</button>
 
222
  <div class="settings-panel" id="settingsPanel">
223
  <div class="settings-row"><span class="settings-label">Temp</span><input type="range" class="settings-slider" id="tempSlider" min="0" max="200" value="70"/><span class="settings-val" id="tempVal">0.70</span></div>
224
  <div class="settings-row"><span class="settings-label">Top-P</span><input type="range" class="settings-slider" id="toppSlider" min="1" max="100" value="95"/><span class="settings-val" id="toppVal">0.95</span></div>
225
+ <div class="settings-row"><span class="settings-label">Tokens</span><select class="settings-select" id="maxTokSelect"><option value="256">256</option><option value="512" selected>512</option><option value="1024">1024</option><option value="2048">2048</option></select></div>
 
226
  </div>
227
  <div class="system-prompt-wrap" id="sysPromptWrap"><textarea class="system-prompt-input" id="sysPromptInput" placeholder="System prompt (optional)…"></textarea></div>
228
  <div class="stats-bar" id="statsBar"></div>
229
  <div class="error-banner" id="errorBanner"></div>
230
+ <div class="chat-messages" id="chatMessages"><div class="welcome-msg"><h3>Start a conversation</h3><p>Type a message below.<br/>Everything runs locally.</p></div></div>
231
  <div class="chat-input-area">
232
  <div class="chat-input-row">
233
  <div class="input-wrap"><textarea id="msgInput" rows="1" placeholder="Type a message…"></textarea></div>
234
+ <button class="btn-send" id="btnSend" disabled>
235
  <svg class="icon-send" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
236
  <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>
237
  </button>
 
244
  <script type="module">
245
  import * as webllm from "https://esm.run/@mlc-ai/web-llm";
246
 
247
+ /* ═══ DEVICE DETECT ═══ */
248
+ const isMobile=(()=>{
249
+ const ua=navigator.userAgent||"";
250
+ if(/Mobile|Android|iPhone|iPad|iPod|webOS|BlackBerry|Opera Mini|IEMobile/i.test(ua))return true;
251
+ if('ontouchstart' in window && window.innerWidth<768)return true;
 
252
  return false;
253
  })();
254
+ const MOBILE_VRAM_LIMIT=1500;
255
 
256
+ /* ═══ READ REAL MODEL LIST FROM WEBLLM ═══ */
257
+ const allModels=webllm.prebuiltAppConfig.model_list;
258
+ console.log(`WebLLM has ${allModels.length} prebuilt models`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
+ function getFamily(id){
261
+ if(/^Qwen3[^.]/i.test(id))return"Qwen3";
262
+ if(/^Qwen2/i.test(id))return"Qwen2.5";
263
+ if(/^Qwen/i.test(id))return"Qwen";
264
+ if(/^DeepSeek/i.test(id))return"DeepSeek";
265
+ if(/^SmolLM/i.test(id))return"SmolLM";
266
+ if(/^Llama-3\.2/i.test(id))return"Llama 3.2";
267
+ if(/^Llama-3\.1/i.test(id))return"Llama 3.1";
268
+ if(/^Llama/i.test(id))return"Llama";
269
+ if(/^gemma/i.test(id))return"Gemma";
270
+ if(/^Phi/i.test(id))return"Phi";
271
+ if(/^Mistral/i.test(id))return"Mistral";
272
+ if(/^Hermes/i.test(id))return"Hermes";
273
+ if(/^TinyLlama/i.test(id))return"TinyLlama";
274
+ return"Other";
275
+ }
276
+
277
+ /* Build enriched model list */
278
+ const models=allModels.map(m=>{
279
+ const vramMB=m.vram_required_MB||m.required_features?.includes("shader-f16")?m.vram_required_MB:m.vram_required_MB;
280
+ const vram=vramMB?(vramMB>1024?(vramMB/1024).toFixed(1)+"GB":Math.round(vramMB)+"MB"):"?";
281
+ const mobileOk=m.low_resource_required===true||(vramMB&&vramMB<=MOBILE_VRAM_LIMIT);
282
+ const needsF16=m.required_features?.includes("shader-f16");
283
+ return{id:m.model_id,vramMB:vramMB||9999,vram,mobileOk,family:getFamily(m.model_id),needsF16};
284
+ }).sort((a,b)=>a.vramMB-b.vramMB);
285
 
286
  /* ═══ STATE ═══ */
287
+ let engine=null,isGenerating=false,abortController=null,chatHistory=[],totalTokens=0;
288
  const $=id=>document.getElementById(id);
289
+ document.fonts.ready.then(()=>document.body.classList.add("ready"));
290
+ const $messages=$("chatMessages"),$input=$("msgInput"),$btnSend=$("btnSend"),$btnLoad=$("btnLoad");
291
+ const $searchInput=$("modelSearchInput"),$searchWrap=$("modelSearchWrap"),$dropdown=$("modelDropdown");
292
+ const $downloadBar=$("downloadBar"),$downloadText=$("downloadText"),$downloadFill=$("downloadFill");
293
+ const $statsBar=$("statsBar"),$toast=$("toast"),$errBanner=$("errorBanner");
294
  const getTemp=()=>parseInt($("tempSlider").value)/100;
295
  const getTopP=()=>parseInt($("toppSlider").value)/100;
296
  const getMaxTok=()=>parseInt($("maxTokSelect").value);
 
297
  const getSysPrompt=()=>$("sysPromptInput").value.trim();
 
 
 
 
 
 
298
 
299
+ /* Device badge */
300
+ const $badge=$("deviceBadge");
301
+ if(isMobile){$badge.textContent="MOBILE MODE";$badge.className="device-badge mobile"}
302
+ else{$badge.textContent="DESKTOP MODE";$badge.className="device-badge desktop"}
303
+
304
+ /* Count */
305
+ const availableCount=isMobile?models.filter(m=>m.mobileOk).length:models.length;
306
+ $("modelCountInfo").textContent=`${availableCount} models available (${models.length} total)`;
 
307
 
308
+ /* Default model */
309
+ const defaultModel=isMobile
310
+ ?(models.find(m=>m.mobileOk&&m.family==="SmolLM")||models.find(m=>m.mobileOk)||models[0])
311
+ :(models.find(m=>m.family==="Qwen3"&&m.vramMB<3000)||models.find(m=>m.family==="Llama 3.2")||models[0]);
312
+ $searchInput.value=defaultModel?.id||models[0]?.id||"";
313
+ $btnLoad.disabled=false;
314
 
315
  /* ═══ TOAST ═══ */
316
  let toastTimer=null;
 
322
  let h="";
323
  if(tps!==null)h+=`<div class="stat"><span class="stat-label">Speed</span><span class="stat-value hl">${tps} t/s</span></div>`;
324
  if(tokens!==null)h+=`<div class="stat"><span class="stat-label">Tokens</span><span class="stat-value">${tokens}</span></div>`;
325
+ if(totalTokens>0)h+=`<div class="stat"><span class="stat-label">Total</span><span class="stat-value">${totalTokens}</span></div>`;
326
  $statsBar.innerHTML=h;
327
  }
328
 
329
+ /* ═══ DROPDOWN ═══ */
330
  function renderDropdown(filter=""){
331
  const q=filter.toLowerCase();
332
+ const filtered=q?models.filter(m=>m.id.toLowerCase().includes(q)||m.family.toLowerCase().includes(q)):models;
333
  const families=[...new Set(filtered.map(m=>m.family))];
334
  let html="";
335
  for(const fam of families){
336
  const group=filtered.filter(m=>m.family===fam);
337
+ html+=`<div class="model-section-label">${fam} (${group.length})</div>`;
338
  for(const m of group){
339
+ const locked=isMobile&&!m.mobileOk;
340
  const cls=locked?"model-item locked":"model-item";
341
+ let badges=`<span class="vram-badge">${m.vram}</span>`;
342
+ if(m.mobileOk)badges+=`<span class="mobile-ok">MOBILE</span>`;
343
+ if(locked)badges+=`<span class="lock-badge">PC</span>`;
344
+ html+=`<div class="${cls}" data-id="${m.id}"><span class="model-item-name">${m.id}</span><span class="model-item-meta">${badges}</span></div>`;
 
 
 
345
  }
346
  }
347
+ if(!filtered.length)html='<div class="model-dropdown-loading">No models match</div>';
348
  $dropdown.innerHTML=html;
349
  $dropdown.querySelectorAll(".model-item:not(.locked)").forEach(el=>{
350
  el.addEventListener("click",()=>{$searchInput.value=el.dataset.id;closeDropdown()});
351
  });
352
  }
353
+ function openDropdown(){$searchWrap.classList.add("open");renderDropdown($searchInput.value)}
 
354
  function closeDropdown(){$searchWrap.classList.remove("open")}
355
+ $searchInput.addEventListener("click",openDropdown);
356
+ $searchInput.addEventListener("focus",openDropdown);
357
  $searchInput.addEventListener("input",()=>renderDropdown($searchInput.value));
358
  document.addEventListener("click",e=>{if(!e.target.closest(".model-search-wrap"))closeDropdown()});
359
  $searchInput.addEventListener("keydown",e=>{
 
362
  const active=$dropdown.querySelector(".model-item.active");let idx=Array.from(items).indexOf(active);
363
  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"})}
364
  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"})}
365
+ if(e.key==="Enter"){e.preventDefault();if(active)$searchInput.value=active.dataset.id;closeDropdown()}
366
  });
367
 
368
  /* ═══ SETTINGS ═══ */
 
370
  $("btnSysPrompt").addEventListener("click",()=>{$("sysPromptWrap").classList.toggle("open");$("btnSysPrompt").classList.toggle("active")});
371
  $("tempSlider").addEventListener("input",()=>$("tempVal").textContent=getTemp().toFixed(2));
372
  $("toppSlider").addEventListener("input",()=>$("toppVal").textContent=getTopP().toFixed(2));
 
373
  function showScreen(id){document.querySelectorAll(".screen").forEach(s=>s.classList.toggle("active",s.id===id))}
374
 
375
+ /* ═══ LOAD ═══ */
376
  $btnLoad.addEventListener("click",async()=>{
377
+ const mid=$searchInput.value.trim();if(!mid){showToast("Select a model","error");return}
378
+ const mInfo=models.find(x=>x.id===mid);
379
+ if(isMobile&&mInfo&&!mInfo.mobileOk){showToast("Too large for mobile β€” pick a MOBILE model","error");return}
 
380
  showScreen("loading");$downloadBar.style.display="none";
381
  try{
382
  $("loaderText").textContent="Initializing WebLLM…";
383
+ engine=await webllm.CreateMLCEngine(mid,{
384
+ initProgressCallback:p=>{$downloadBar.style.display="";const pct=Math.round(p.progress*100);$downloadFill.style.width=pct+"%";$downloadText.textContent=p.text||`${pct}%`;$("loaderText").textContent=p.text||"Loading…"}
385
  });
386
  $("loaderText").textContent="Ready!";
387
+ $("chatTitle").textContent=mid.replace(/-MLC$/,"").replace(/-q[0-9]f[0-9]+[_0-9]*/,"");
388
  setTimeout(()=>showScreen("chat"),300);
389
  showToast("Model loaded!","success");
390
  }catch(err){
391
+ console.error(err);$("loaderText").textContent="Failed";$("loaderSub").textContent=err.message;
392
+ $("loaderRing").style.borderTopColor="var(--red)";showToast("Load failed","error");
 
393
  }
394
  });
395
 
396
  /* ═══ INPUT ═══ */
397
  $input.addEventListener("input",()=>{$input.style.height="auto";$input.style.height=Math.min($input.scrollHeight,120)+"px";updateSendBtn()});
398
  $input.addEventListener("keydown",e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();if(!isGenerating)sendMessage()}});
399
+ $btnSend.addEventListener("click",()=>{if(isGenerating&&abortController){abortController.abort();abortController=null}else sendMessage()});
400
  function updateSendBtn(){if(isGenerating){$btnSend.disabled=false;$btnSend.classList.add("stopping")}else{$btnSend.classList.remove("stopping");$btnSend.disabled=!$input.value.trim()}}
 
401
 
402
  /* ═══ RESET ═══ */
403
+ $("btnReset").addEventListener("click",async()=>{
404
+ chatHistory=[];totalTokens=0;if(engine)try{await engine.resetChat()}catch(e){}
405
+ $messages.innerHTML='<div class="welcome-msg"><h3>Start a conversation</h3><p>Type a message below.</p></div>';
 
406
  $errBanner.classList.remove("visible");$input.value="";$input.style.height="auto";updateStatsBar();updateSendBtn();
407
  });
408
 
409
  /* ═══ MARKDOWN ═══ */
410
+ function renderMd(t){if(typeof marked==="undefined")return esc(t);try{marked.setOptions({breaks:true,gfm:true});return marked.parse(t)}catch{return esc(t)}}
411
+ function esc(s){return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}
412
 
413
  /* ═══ SEND ═══ */
414
  async function sendMessage(){
415
  if(isGenerating||!engine)return;const text=$input.value.trim();if(!text)return;
416
+ $errBanner.classList.remove("visible");const w=$messages.querySelector(".welcome-msg");if(w)w.remove();
417
+ appendMsg("user",text);chatHistory.push({role:"user",content:text});
 
418
  $input.value="";$input.style.height="auto";isGenerating=true;updateSendBtn();
419
+ const{g,b}=appendPlaceholder();
420
  try{
421
+ const msgs=[];const sp=getSysPrompt();if(sp)msgs.push({role:"system",content:sp});msgs.push(...chatHistory);
422
+ let full="",tc=0;const t0=performance.now();
423
+ const tn=document.createElement("div");tn.className="msg-text";b.appendChild(tn);
424
+ if($("streamToggle").checked){
 
425
  abortController=new AbortController();
426
+ const stream=await engine.chat.completions.create({messages:msgs,temperature:getTemp(),top_p:getTopP(),max_tokens:getMaxTok(),stream:true,stream_options:{include_usage:true}});
427
  for await(const chunk of stream){
428
  if(abortController?.signal?.aborted)break;
429
+ const d=chunk.choices?.[0]?.delta?.content;
430
+ if(d){full+=d;tc++;tn.innerHTML=renderMd(full);$messages.scrollTop=$messages.scrollHeight}
431
+ if(chunk.usage)tc=chunk.usage.completion_tokens||tc;
432
  }
433
  }else{
434
+ const r=await engine.chat.completions.create({messages:msgs,temperature:getTemp(),top_p:getTopP(),max_tokens:getMaxTok()});
435
+ full=r.choices[0]?.message?.content||"";tc=r.usage?.completion_tokens||0;tn.innerHTML=renderMd(full);
436
  }
437
+ chatHistory.push({role:"assistant",content:full});
438
+ if(full.includes("`")||full.includes("#")||full.includes("*"))b.classList.add("md");
439
+ const el=(performance.now()-t0)/1000;const tps=tc>0?(tc/el).toFixed(1):"?";
440
+ const se=document.createElement("div");se.className="msg-stats";se.textContent=`${tc} tok Β· ${tps} t/s Β· ${el.toFixed(1)}s`;g.appendChild(se);
441
+ totalTokens+=tc;updateStatsBar(tps,tc);b.classList.remove("generating");
442
  }catch(err){
443
+ if(err.name==="AbortError"){b.classList.remove("generating");showToast("Stopped")}
444
+ else{console.error(err);g.remove();$errBanner.textContent="Error: "+err.message;$errBanner.classList.add("visible");showToast("Failed","error")}
445
  }
446
  isGenerating=false;abortController=null;updateSendBtn();$messages.scrollTop=$messages.scrollHeight;
447
  }
448
+ function appendMsg(role,text){
 
 
449
  const g=document.createElement("div");g.className=`msg-group ${role}`;
450
  const r=document.createElement("div");r.className="msg-role";r.textContent=role==="user"?"You":"AI";g.appendChild(r);
451
  const b=document.createElement("div");b.className="msg-bubble";b.textContent=text;g.appendChild(b);
452
+ $messages.appendChild(g);$messages.scrollTop=$messages.scrollHeight;
453
  }
454
+ function appendPlaceholder(){
455
  const g=document.createElement("div");g.className="msg-group assistant";
456
  const r=document.createElement("div");r.className="msg-role";r.textContent="AI";g.appendChild(r);
457
  const b=document.createElement("div");b.className="msg-bubble generating";
458
  const d=document.createElement("span");d.className="thinking-dots";
459
  for(let i=0;i<3;i++)d.appendChild(document.createElement("span"));
460
  b.appendChild(d);g.appendChild(b);$messages.appendChild(g);$messages.scrollTop=$messages.scrollHeight;
461
+ return{g,b};
462
  }
463
  window.visualViewport?.addEventListener("resize",()=>{$messages.scrollTop=$messages.scrollHeight});
464
 
465
+ /* ═══ WEBGPU ═══ */
466
  (async()=>{
467
+ if(!navigator.gpu){showToast("WebGPU not available β€” try Chrome/Edge","error");$btnLoad.disabled=true;return}
468
  try{const a=await navigator.gpu.requestAdapter({powerPreference:"high-performance"});if(!a){showToast("No WebGPU adapter","error");$btnLoad.disabled=true}}
469
+ catch(e){showToast("WebGPU failed","error")}
470
  })();
471
  </script>
472
  </body>