File size: 43,750 Bytes
b4cd759
 
 
 
76a6627
 
 
 
 
 
b4cd759
 
76a6627
 
b4cd759
 
 
76a6627
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4cd759
76a6627
 
 
 
 
 
 
 
 
 
 
 
b4cd759
76a6627
 
 
 
 
 
 
 
b4cd759
76a6627
b4cd759
76a6627
 
b4cd759
76a6627
 
b4cd759
76a6627
 
 
 
 
 
 
 
 
 
 
 
 
b4cd759
76a6627
 
 
 
 
b4cd759
 
76a6627
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4cd759
76a6627
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4cd759
76a6627
 
b4cd759
 
76a6627
 
 
 
 
 
 
 
 
 
b4cd759
76a6627
 
b4cd759
76a6627
 
 
 
 
 
b4cd759
76a6627
 
 
 
 
 
 
 
 
 
 
 
 
 
b4cd759
76a6627
b4cd759
76a6627
b4cd759
 
 
76a6627
b4cd759
76a6627
 
b4cd759
76a6627
 
 
b4cd759
76a6627
 
 
 
 
b4cd759
76a6627
 
 
 
 
 
 
b4cd759
 
 
 
76a6627
b4cd759
76a6627
 
 
 
 
b4cd759
 
 
 
76a6627
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4cd759
 
 
 
76a6627
 
b4cd759
76a6627
 
b4cd759
76a6627
b4cd759
 
 
76a6627
 
b4cd759
 
 
76a6627
 
b4cd759
 
 
76a6627
b4cd759
76a6627
b4cd759
 
 
 
 
 
76a6627
 
 
 
b4cd759
76a6627
b4cd759
 
76a6627
b4cd759
76a6627
b4cd759
76a6627
b4cd759
76a6627
 
b4cd759
76a6627
b4cd759
 
 
 
 
76a6627
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4cd759
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no,viewport-fit=cover"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
<meta name="mobile-web-app-capable" content="yes"/>
<meta name="theme-color" content="#ffffff"/>
<title>Qwen 3.5 Vision — Local AI Chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Newsreader:ital,opsz,wght@0,6..72,400;1,6..72,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧠</text></svg>"/>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
  --bg:#ffffff;--bg-warm:#faf9f7;--bg-cool:#f4f2ef;
  --surface:#ffffff;--surface-raised:#fefefe;
  --border:#e8e5e0;--border-light:#f0ede8;
  --text:#1a1815;--text-2:#5c5650;--text-3:#9c958d;--text-4:#c4beb6;
  --accent:#4f6df5;--accent-hover:#3d5ae0;--accent-light:#eef1fe;--accent-text:#3b54c4;
  --green:#2d9d5e;--green-bg:#edf7f0;
  --red:#d44040;--red-bg:#fdf0f0;
  --amber:#c08520;--amber-bg:#fdf6e8;
  --radius:14px;--radius-sm:10px;--radius-xs:7px;
  --shadow-sm:0 1px 3px rgba(0,0,0,.04),0 1px 2px rgba(0,0,0,.02);
  --shadow-md:0 4px 16px rgba(0,0,0,.06),0 1px 4px rgba(0,0,0,.04);
  --shadow-lg:0 12px 40px rgba(0,0,0,.08),0 2px 8px rgba(0,0,0,.04);
  --font:'Plus Jakarta Sans',system-ui,-apple-system,sans-serif;
  --font-display:'Newsreader',Georgia,serif;
  --font-mono:'IBM Plex Mono','SF Mono',monospace;
  --safe-top:env(safe-area-inset-top,0px);
  --safe-bottom:env(safe-area-inset-bottom,0px);
}
html{font-size:16px;height:100%}
body{font-family:var(--font);background:var(--bg-warm);color:var(--text);height:100%;overflow:hidden;-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent;opacity:0}
body.ready{opacity:1;transition:opacity .3s}
input,textarea,button,select{font-family:inherit;font-size:inherit;-webkit-appearance:none;appearance:none}
::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:10px}
.screen{display:none;width:100%;height:100%}.screen.active{display:flex}

/* LANDING */
#landing{flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:24px;position:relative;overflow-y:auto;background:var(--bg)}
.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}
.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}
.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}
.landing-title em{font-style:italic;color:var(--accent)}
.landing-sub{font-size:.95rem;color:var(--text-2);max-width:460px;line-height:1.7;margin-bottom:32px;font-weight:400}
.landing-chips{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-bottom:32px}
.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}
.model-search-wrap{position:relative;width:100%;max-width:440px;margin-bottom:20px}
.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}
.model-search-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,109,245,.1)}
.model-search-input::placeholder{color:var(--text-4)}
.search-chevron{position:absolute;right:14px;top:50%;transform:translateY(-50%);color:var(--text-4);font-size:.65rem;pointer-events:none;transition:transform .2s}
.model-search-wrap.open .search-chevron{transform:translateY(-50%) rotate(180deg)}
.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:260px;overflow-y:auto;z-index:100;display:none;box-shadow:var(--shadow-lg)}
.model-search-wrap.open .model-dropdown{display:block}
.model-search-wrap.open .model-search-input{border-radius:var(--radius-sm) var(--radius-sm) 0 0;border-color:var(--accent)}
.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}
.model-item:last-child{border-bottom:none}
.model-item:hover,.model-item.active{background:var(--accent-light)}
.model-item-name{font-family:var(--font-mono);font-size:.78rem;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
.model-item-meta{display:flex;gap:5px;align-items:center;flex-shrink:0}
.size-badge{background:var(--accent-light);color:var(--accent-text);padding:3px 8px;border-radius:6px;font-family:var(--font-mono);font-size:.62rem;font-weight:600}
.cached-badge{background:var(--green-bg);color:var(--green);padding:3px 7px;border-radius:6px;font-family:var(--font-mono);font-size:.58rem;font-weight:700}
.model-dropdown-loading{padding:12px 14px;font-size:.75rem;color:var(--text-3);text-align:center}
.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}
.btn-load:hover{background:var(--accent-hover);box-shadow:0 4px 20px rgba(79,109,245,.3);transform:translateY(-1px)}
.btn-load:active{transform:translateY(0)}
.btn-load:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}
.landing-footer{margin-top:auto;padding:20px 0;font-family:var(--font-mono);font-size:.65rem;color:var(--text-4)}
.landing-footer a{color:var(--text-3);text-decoration:none}.landing-footer a:hover{color:var(--accent)}

/* LOADING */
#loading{flex-direction:column;align-items:center;justify-content:center;gap:24px;padding:24px;background:var(--bg)}
.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}
@keyframes spin{to{transform:rotate(360deg)}}
.loader-text{font-size:.88rem;color:var(--text-2);font-weight:500;text-align:center}
.loader-sub{font-size:.75rem;color:var(--text-3);margin-top:4px;text-align:center;line-height:1.5}
.download-bar{width:min(360px,85vw)}
.download-text{font-family:var(--font-mono);font-size:.68rem;color:var(--text-3);text-align:center;margin-bottom:6px}
.download-track{height:4px;background:var(--bg-cool);border-radius:2px;overflow:hidden}
.download-fill{height:100%;width:0%;background:var(--accent);border-radius:2px;transition:width .3s}

/* CHAT */
#chat{flex-direction:column;height:100%;background:var(--bg-warm)}
.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}
.chat-header-left{display:flex;align-items:center;gap:10px;min-width:0}
.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}
.chat-header-info{min-width:0}
.chat-header-title{font-size:.92rem;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.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}
.chat-header-status::before{content:"";width:5px;height:5px;background:var(--green);border-radius:50%;flex-shrink:0}
.chat-header-controls{display:flex;align-items:center;gap:6px;flex-shrink:0}
.toggle-pill{display:flex;align-items:center;gap:5px;cursor:pointer;user-select:none;padding:5px 10px;border-radius:100px;border:1px solid var(--border);background:var(--surface);transition:all .2s;min-height:32px}
.toggle-pill:has(input:checked){background:var(--accent-light);border-color:var(--accent)}
.toggle-pill input{display:none}
.toggle-dot{width:8px;height:8px;border-radius:50%;background:var(--text-4);transition:background .2s;flex-shrink:0}
.toggle-pill:has(input:checked) .toggle-dot{background:var(--accent)}
.toggle-lbl{font-size:.65rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);transition:color .2s;white-space:nowrap}
.toggle-pill:has(input:checked) .toggle-lbl{color:var(--accent-text)}
.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}
.btn-icon:hover{background:var(--bg-cool);color:var(--text-2)}
.btn-icon:active{transform:scale(.95)}
.btn-icon.active{background:var(--accent-light);color:var(--accent-text);border-color:var(--accent)}
.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}
.btn-reset:hover{color:var(--red);border-color:var(--red);background:var(--red-bg)}

.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}
.settings-panel.open{max-height:200px;padding:10px 16px}
.settings-row{display:flex;align-items:center;gap:5px}
.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}
.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}
.settings-select:focus{border-color:var(--accent)}
.settings-slider{width:64px;accent-color:var(--accent);height:3px;cursor:pointer}
.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}
.system-prompt-wrap{padding:0 16px;max-height:0;overflow:hidden;transition:max-height .3s,padding .3s;background:var(--bg)}
.system-prompt-wrap.open{max-height:170px;padding:8px 16px 10px;border-bottom:1px solid var(--border-light)}
.system-prompt-input{width:100%;padding:10px 12px;border-radius:var(--radius-sm);border:1.5px solid var(--border);background:var(--bg-warm);color:var(--text);outline:none;resize:vertical;min-height:48px;max-height:120px;font-size:.8rem;line-height:1.5}
.system-prompt-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,109,245,.08)}
.system-prompt-input::placeholder{color:var(--text-4)}
.stats-bar{display:flex;align-items:center;justify-content:center;gap:16px;padding:5px 16px;border-bottom:1px solid var(--border-light);background:var(--bg);flex-shrink:0;font-size:.62rem;flex-wrap:wrap}
.stats-bar:empty{display:none}
.stat{display:flex;align-items:center;gap:4px}
.stat-label{color:var(--text-4);text-transform:uppercase;letter-spacing:.06em;font-weight:600;font-family:var(--font-mono);font-size:.55rem}
.stat-value{color:var(--text-2);font-weight:600;font-variant-numeric:tabular-nums;font-family:var(--font-mono);font-size:.68rem}
.stat-value.hl{color:var(--accent-text)}
.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}
.error-banner.visible{display:block}

.chat-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:16px;scroll-behavior:smooth;-webkit-overflow-scrolling:touch}
.msg-group{display:flex;flex-direction:column;gap:3px;max-width:85%;animation:msgIn .25s ease}
.msg-group.user{align-self:flex-end}
.msg-group.assistant{align-self:flex-start}
@keyframes msgIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
.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}
.msg-group.assistant .msg-role{color:var(--accent-text)}
.msg-bubble{padding:12px 16px;border-radius:var(--radius);color:var(--text);font-size:.88rem;line-height:1.7;word-wrap:break-word;box-shadow:var(--shadow-sm)}
.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)}
.msg-group.assistant .msg-bubble{background:var(--surface);border:1px solid var(--border-light);border-bottom-left-radius:4px}
.msg-group.assistant .msg-bubble.generating{border-color:var(--accent);box-shadow:0 0 0 2px rgba(79,109,245,.08)}
.msg-image{max-width:min(220px,60vw);max-height:180px;border-radius:var(--radius-sm);margin-bottom:8px;display:block;object-fit:cover;border:1px solid var(--border)}

.msg-bubble.md{white-space:normal}
.msg-bubble.md p{margin:0 0 .5em}.msg-bubble.md p:last-child{margin:0}
.msg-bubble.md h1,.msg-bubble.md h2,.msg-bubble.md h3{margin:.7em 0 .35em;font-weight:600;line-height:1.3}
.msg-bubble.md h1{font-size:1.2em}.msg-bubble.md h2{font-size:1.1em}.msg-bubble.md h3{font-size:1em}
.msg-bubble.md h1:first-child,.msg-bubble.md h2:first-child,.msg-bubble.md h3:first-child{margin-top:0}
.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)}
.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}
.msg-bubble.md pre code{padding:0;background:none;color:var(--text);font-size:.78rem}
.msg-bubble.md ul,.msg-bubble.md ol{margin:.4em 0;padding-left:1.4em}
.msg-bubble.md li{margin:.15em 0}
.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}
.msg-bubble.md table{margin:.5em 0;border-collapse:collapse;width:100%;font-size:.82rem}
.msg-bubble.md th,.msg-bubble.md td{padding:6px 8px;border:1px solid var(--border);text-align:left}
.msg-bubble.md th{background:var(--bg-cool);font-weight:600;color:var(--text-2);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
.msg-bubble.md strong{font-weight:600}
.msg-bubble.md a{color:var(--accent)}
.msg-bubble.md hr{margin:.7em 0;border:none;border-top:1px solid var(--border-light)}
.msg-group.user .msg-bubble.md code{background:rgba(255,255,255,.2);color:#fff}
.msg-group.user .msg-bubble.md pre{background:rgba(0,0,0,.1);border-color:rgba(255,255,255,.15)}
.msg-group.user .msg-bubble.md pre code{color:#fff}
.msg-group.user .msg-bubble.md blockquote{background:rgba(255,255,255,.1);border-left-color:rgba(255,255,255,.4);color:rgba(255,255,255,.85)}

.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}
.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}
.think-toggle:hover{opacity:.75}
.think-arrow{font-size:.5rem;transition:transform .2s;display:inline-block}
.think-arrow.open{transform:rotate(90deg)}
.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}
.think-content.collapsed{max-height:0;margin-top:0;opacity:0}
.msg-stats{font-family:var(--font-mono);font-size:.58rem;color:var(--text-3);margin-top:5px;letter-spacing:.03em}
.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}
.thinking-dots span:nth-child(2){animation-delay:.2s}
.thinking-dots span:nth-child(3){animation-delay:.4s}
@keyframes dot{0%,80%,100%{opacity:.2;transform:scale(.8)}40%{opacity:1;transform:scale(1)}}

.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}
.image-preview-bar{display:none;align-items:center;gap:8px;margin-bottom:8px;padding:8px 10px;background:var(--bg-warm);border:1px solid var(--border-light);border-radius:var(--radius-sm)}
.image-preview-bar.visible{display:flex}
.image-preview-thumb{width:44px;height:44px;border-radius:var(--radius-xs);object-fit:cover;border:1px solid var(--border)}
.image-preview-name{font-family:var(--font-mono);font-size:.72rem;color:var(--text-2);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.btn-remove-image{background:none;border:none;color:var(--text-3);font-size:1.1rem;cursor:pointer;padding:6px;transition:color .15s;min-width:32px;min-height:32px;display:flex;align-items:center;justify-content:center}
.btn-remove-image:hover{color:var(--red)}
.chat-input-row{display:flex;align-items:flex-end;gap:6px}
.btn-attach{width:44px;height:44px;border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;flex-shrink:0;cursor:pointer;background:var(--bg-warm);border:1px solid var(--border);color:var(--text-3);transition:all .15s}
.btn-attach:hover:not(:disabled){border-color:var(--accent);color:var(--accent);background:var(--accent-light)}
.btn-attach:active:not(:disabled){transform:scale(.95)}
.btn-attach:disabled{opacity:.3;cursor:not-allowed}
.input-wrap{flex:1;position:relative}
.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}
.input-wrap textarea::placeholder{color:var(--text-4)}
.input-wrap textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,109,245,.08)}
.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}
.btn-send:disabled{opacity:.3;cursor:not-allowed}
.btn-send:not(:disabled):hover{background:var(--accent-hover);box-shadow:0 2px 8px rgba(79,109,245,.25)}
.btn-send:not(:disabled):active{transform:scale(.95)}
.btn-send .icon-stop{display:none}
.btn-send.stopping{background:var(--red)}
.btn-send.stopping .icon-send{display:none}
.btn-send.stopping .icon-stop{display:block}
.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}

.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}
.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}
.welcome-msg p{font-size:.85rem;line-height:1.65}

.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}
.toast.show{transform:translateX(-50%) translateY(0);opacity:1}
.toast.error{border-color:var(--red);color:var(--red)}
.toast.success{border-color:var(--green);color:var(--green)}

@media(max-width:640px){
  .landing-specs{gap:16px;flex-wrap:wrap}.chip{font-size:.62rem;padding:4px 10px}
  .landing-sub{font-size:.88rem;padding:0 8px}
  .chat-header{padding:8px 12px;padding-top:calc(8px + var(--safe-top));gap:6px}
  .chat-header-title{font-size:.84rem}
  .chat-header-controls{gap:4px}
  .toggle-pill{padding:4px 8px;min-height:28px}
  .toggle-lbl{font-size:.58rem}
  .btn-icon{width:28px;height:28px;font-size:.7rem}
  .btn-reset{font-size:.58rem;padding:5px 10px;min-height:28px}
  .settings-panel.open{gap:6px 12px;padding:8px 12px}
  .settings-slider{width:56px}
  .chat-messages{padding:12px}
  .msg-group{max-width:90%}
  .msg-bubble{padding:10px 14px;font-size:.85rem}
  .chat-input-area{padding:8px 10px;padding-bottom:calc(8px + var(--safe-bottom))}
  .btn-attach,.btn-send{width:42px;height:42px}
  .input-wrap textarea{min-height:42px;padding:10px 12px;font-size:.86rem}
  .stats-bar{gap:10px;padding:4px 12px}
  .system-prompt-wrap.open{padding:6px 12px 8px}
  .msg-image{max-width:min(180px,55vw)}
}
@media(max-width:380px){
  .toggle-lbl{display:none}
  .landing-title{font-size:2rem}
  .chat-header-title{font-size:.78rem;max-width:120px}
}
@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}
}
</style>
</head>
<body>
<div id="landing" class="screen active">
  <div class="landing-deco"></div>
  <div class="landing-tag">Local AI · No Server · WebGPU</div>
  <h1 class="landing-title">Qwen 3.5 <em>Vision</em></h1>
  <p class="landing-sub">Run a multimodal vision-language model entirely in your browser. No server, no API keys — your data stays on your device.</p>
  <div class="landing-chips"><span class="chip">Vision + Language</span><span class="chip">201 Languages</span><span class="chip">Reasoning</span><span class="chip">Markdown</span></div>
  <div class="model-search-wrap" id="modelSearchWrap">
    <input class="model-search-input" id="modelSearchInput" type="text" value="onnx-community/Qwen3.5-0.8B-ONNX" placeholder="Search Qwen3.5 models…" autocomplete="off"/>
    <span class="search-chevron"></span>
    <div class="model-dropdown" id="modelDropdown"></div>
  </div>
  <button class="btn-load" id="btnLoad">Load Model</button>
  <div class="landing-footer">Built with <a href="https://huggingface.co/docs/transformers.js" target="_blank">Transformers.js</a></div>
</div>
<div id="loading" class="screen">
  <div class="loader-ring" id="loaderRing"></div>
  <div><div class="loader-text" id="loaderText">Initializing…</div><div class="loader-sub" id="loaderSub">Weights are cached for future visits.</div></div>
  <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>
</div>
<div id="chat" class="screen">
  <div class="chat-header">
    <div class="chat-header-left"><div class="chat-avatar">Q</div><div class="chat-header-info"><div class="chat-header-title" id="chatTitle">Qwen 3.5 Vision</div><div class="chat-header-status">Ready</div></div></div>
    <div class="chat-header-controls">
      <label class="toggle-pill" title="Think step-by-step"><input type="checkbox" id="reasoningToggle"/><span class="toggle-dot"></span><span class="toggle-lbl">Think</span></label>
      <button class="btn-icon" id="btnSettings" title="Settings"></button>
      <button class="btn-icon" id="btnSysPrompt" title="System prompt">S</button>
      <button class="btn-reset" id="btnReset">Reset</button>
    </div>
  </div>
  <div class="settings-panel" id="settingsPanel">
    <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>
    <div class="settings-row"><span class="settings-label">Top-K</span><input type="range" class="settings-slider" id="topkSlider" min="1" max="100" value="50"/><span class="settings-val" id="topkVal">50</span></div>
    <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>
    <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>
  </div>
  <div class="system-prompt-wrap" id="sysPromptWrap"><textarea class="system-prompt-input" id="sysPromptInput" placeholder="System prompt (optional)…"></textarea></div>
  <div class="stats-bar" id="statsBar"></div>
  <div class="error-banner" id="errorBanner"></div>
  <div class="chat-messages" id="chatMessages"><div class="welcome-msg" id="welcomeMsg"><h3>Start a conversation</h3><p>Attach an image or type a message.<br/>Everything runs locally in your browser.</p></div></div>
  <div class="chat-input-area">
    <div class="image-preview-bar" id="imagePreview"><img class="image-preview-thumb" id="imageThumb" src="" alt=""/><span class="image-preview-name" id="imageName"></span><button class="btn-remove-image" id="btnRemoveImage">&times;</button></div>
    <div class="chat-input-row">
      <button class="btn-attach" id="btnAttach" title="Attach image"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></button>
      <input type="file" id="fileInput" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp" hidden/>
      <div class="input-wrap"><textarea id="msgInput" rows="1" placeholder="Type a message…"></textarea></div>
      <button class="btn-send" id="btnSend" disabled title="Send"><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><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></button>
    </div>
    <div class="chat-footer-note">100% local — no data leaves your device. AI can make mistakes.</div>
  </div>
</div>
<div class="toast" id="toast"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script>
<script type="module">
import{AutoProcessor,Qwen3_5ForConditionalGeneration,RawImage,TextStreamer,InterruptableStoppingCriteria}from"https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.0.0-next.6";
let processor=null,model=null,conversationImage=null,attachedImage=null,isGenerating=false;
let pastKeyValues=null,imageGridThw=null,promptHistory="";
const stoppingCriteria=new InterruptableStoppingCriteria();
let totalTokens=0,totalTime=0,sessionMsgCount=0;
const getTemp=()=>parseInt($("tempSlider").value)/100;
const getTopK=()=>parseInt($("topkSlider").value);
const getMaxTok=()=>parseInt($("maxTokSelect").value);
const getRepPen=()=>parseInt($("repPenSlider").value)/100;
const getSysPrompt=()=>$("sysPromptInput").value.trim();
document.fonts.ready.then(()=>document.body.classList.add("ready"));
const $=id=>document.getElementById(id);
const[$loaderTx,$loaderSub,$messages,$input,$btnSend,$btnLoad,$btnReset,$btnAttach,$fileInput,$imgPrev,$imgThumb,$imgName,$btnRemImg,$errBanner,$reasoning,$searchInput,$searchWrap,$dropdown,$downloadBar,$downloadText,$downloadFill,$statsBar,$toast]=["loaderText","loaderSub","chatMessages","msgInput","btnSend","btnLoad","btnReset","btnAttach","fileInput","imagePreview","imageThumb","imageName","btnRemoveImage","errorBanner","reasoningToggle","modelSearchInput","modelSearchWrap","modelDropdown","downloadBar","downloadText","downloadFill","statsBar","toast"].map($);
let toastTimer=null;
function showToast(m,t=""){clearTimeout(toastTimer);$toast.textContent=m;$toast.className="toast "+t+" show";toastTimer=setTimeout(()=>$toast.classList.remove("show"),3000)}
function updateStatsBar(tps=null,tokens=null){if(!totalTokens&&!tps){$statsBar.innerHTML="";return}let h="";if(tps!==null)h+=`<div class="stat"><span class="stat-label">Speed</span><span class="stat-value hl">${tps} t/s</span></div>`;if(tokens!==null)h+=`<div class="stat"><span class="stat-label">Tokens</span><span class="stat-value">${tokens}</span></div>`;if(totalTokens>0)h+=`<div class="stat"><span class="stat-label">Session</span><span class="stat-value">${totalTokens}</span></div>`;$statsBar.innerHTML=h}
const PRESET_MODELS=[{id:"onnx-community/Qwen3.5-0.8B-ONNX",size:"0.8B"},{id:"onnx-community/Qwen3.5-2B-ONNX",size:"2B"},{id:"onnx-community/Qwen3.5-4B-ONNX",size:"4B"}];
let searchTimer=null;
function renderDropdown(models){$dropdown.innerHTML=models.map(m=>`<div class="model-item" data-id="${m.id}"><span class="model-item-name">${m.id}</span><span class="model-item-meta">${m.cached?'<span class="cached-badge">CACHED</span>':""}${m.size?`<span class="size-badge">${m.size}</span>`:""}</span></div>`).join("");$dropdown.querySelectorAll(".model-item").forEach(el=>{el.addEventListener("click",()=>{$searchInput.value=el.dataset.id;closeDropdown()})})}
function openDropdown(){$searchWrap.classList.add("open");if(!$dropdown.innerHTML)renderDropdown(PRESET_MODELS)}
function closeDropdown(){$searchWrap.classList.remove("open")}
$searchInput.addEventListener("focus",()=>{openDropdown();renderDropdown(PRESET_MODELS)});
$searchInput.addEventListener("input",()=>{const q=$searchInput.value.trim().toLowerCase();if(q.length<2){renderDropdown(PRESET_MODELS);openDropdown();return}const local=PRESET_MODELS.filter(m=>m.id.toLowerCase().includes(q));if(local.length){renderDropdown(local);openDropdown()}clearTimeout(searchTimer);searchTimer=setTimeout(async()=>{try{$dropdown.innerHTML='<div class="model-dropdown-loading">Searching…</div>';openDropdown();const r=await fetch(`https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=onnx&limit=10&sort=downloads&direction=-1`);if(!r.ok)return;const d=await r.json();const res=d.filter(m=>m.id.toLowerCase().includes("qwen")||m.id.toLowerCase().includes("onnx")).map(m=>({id:m.id,size:m.id.match(/(\d+\.?\d*B)/i)?.[1]||""}));const c=[...local,...res.filter(r=>!local.find(l=>l.id===r.id))];if(c.length)renderDropdown(c);else $dropdown.innerHTML='<div class="model-dropdown-loading">No models found</div>'}catch(e){console.error(e)}},400)});
document.addEventListener("click",e=>{if(!e.target.closest(".model-search-wrap"))closeDropdown()});
$searchInput.addEventListener("keydown",e=>{if(e.key==="Enter"){e.preventDefault();closeDropdown()}if(e.key==="Escape")closeDropdown();const items=$dropdown.querySelectorAll(".model-item");if(!items.length)return;const active=$dropdown.querySelector(".model-item.active");let idx=Array.from(items).indexOf(active);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"})}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"})}if(e.key==="Enter"&&active){$searchInput.value=active.dataset.id;closeDropdown()}});
$("btnSettings").addEventListener("click",()=>{$("settingsPanel").classList.toggle("open");$("btnSettings").classList.toggle("active")});
$("btnSysPrompt").addEventListener("click",()=>{$("sysPromptWrap").classList.toggle("open");$("btnSysPrompt").classList.toggle("active")});
$("tempSlider").addEventListener("input",()=>$("tempVal").textContent=getTemp().toFixed(2));
$("topkSlider").addEventListener("input",()=>$("topkVal").textContent=getTopK());
$("repPenSlider").addEventListener("input",()=>$("repPenVal").textContent=getRepPen().toFixed(2));
function showScreen(id){document.querySelectorAll(".screen").forEach(s=>s.classList.toggle("active",s.id===id))}
$btnLoad.addEventListener("click",async()=>{const mid=$searchInput.value.trim();if(!mid){showToast("Enter a model ID","error");return}showScreen("loading");$downloadBar.style.display="none";try{$loaderTx.textContent="Loading processor…";processor=await AutoProcessor.from_pretrained(mid,{progress_callback:p=>{if(p.status==="download"){$downloadBar.style.display="";const pct=p.total?Math.round(p.loaded/p.total*100):0;$downloadFill.style.width=pct+"%";$downloadText.textContent=`Processor: ${pct}%`}}});$loaderTx.textContent="Loading model…";$loaderSub.textContent="This may take a minute on first visit.";model=await Qwen3_5ForConditionalGeneration.from_pretrained(mid,{dtype:{embed_tokens:"q4",vision_encoder:"fp16",decoder_model_merged:"q4"},device:"webgpu",progress_callback:p=>{if(p.status==="download"||p.status==="progress"){$downloadBar.style.display="";const pct=p.total?Math.round(p.loaded/p.total*100):0;$downloadFill.style.width=pct+"%";const mb=(p.loaded/1024/1024).toFixed(0);const tot=p.total?(p.total/1024/1024).toFixed(0):"?";$downloadText.textContent=`Model: ${mb}/${tot}MB (${pct}%)`}if(p.status==="done")$downloadFill.style.width="100%"}});$loaderTx.textContent="Ready!";const sl=mid.match(/(\d+\.?\d*B)/i)?.[1]||"";$("chatTitle").textContent=`Qwen 3.5 Vision${sl?" · "+sl:""}`;setTimeout(()=>showScreen("chat"),400)}catch(err){console.error(err);$loaderTx.textContent="Failed to load";$loaderSub.textContent=err.message;$("loaderRing").style.borderTopColor="var(--red)"}});
$btnAttach.addEventListener("click",()=>{if(!$btnAttach.disabled)$fileInput.click()});
$fileInput.addEventListener("change",async e=>{const f=e.target.files?.[0];if(!f)return;const d=URL.createObjectURL(f);const raw=await RawImage.read(d);const resized=await raw.resize(448,448);attachedImage={raw:resized,dataURL:d,name:f.name};$imgThumb.src=d;$imgName.textContent=f.name;$imgPrev.classList.add("visible");updateSendBtn();$fileInput.value=""});
$btnRemImg.addEventListener("click",clearAttachment);
function clearAttachment(){if(attachedImage?.dataURL)URL.revokeObjectURL(attachedImage.dataURL);attachedImage=null;$imgPrev.classList.remove("visible");$imgThumb.src="";$imgName.textContent="";updateSendBtn()}
$input.addEventListener("input",()=>{$input.style.height="auto";$input.style.height=Math.min($input.scrollHeight,120)+"px";updateSendBtn()});
$input.addEventListener("keydown",e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();if(!isGenerating)sendMessage()}});
$btnSend.addEventListener("click",()=>{if(isGenerating)stoppingCriteria.interrupt();else sendMessage()});
function updateSendBtn(){if(isGenerating){$btnSend.disabled=false;$btnSend.classList.add("stopping")}else{$btnSend.classList.remove("stopping");$btnSend.disabled=!$input.value.trim()&&!attachedImage}}
function disposePastKeyValues(){if(pastKeyValues){for(const t of Object.values(pastKeyValues))t.dispose();pastKeyValues=null}}
$input.addEventListener("paste",e=>{const items=e.clipboardData?.items;if(!items)return;for(const item of items){if(item.type.startsWith("image/")){e.preventDefault();const f=item.getAsFile();const dt=new DataTransfer();dt.items.add(f);$fileInput.files=dt.files;$fileInput.dispatchEvent(new Event("change"));break}}});
$btnReset.addEventListener("click",()=>{conversationImage=null;attachedImage=null;disposePastKeyValues();stoppingCriteria.reset();imageGridThw=null;promptHistory="";totalTokens=0;totalTime=0;sessionMsgCount=0;$imgPrev.classList.remove("visible");$btnAttach.disabled=false;$messages.innerHTML=`<div class="welcome-msg" id="welcomeMsg"><h3>Start a conversation</h3><p>Attach an image or type a message.<br/>Everything runs locally in your browser.</p></div>`;$errBanner.classList.remove("visible");$input.value="";$input.style.height="auto";updateStatsBar();updateSendBtn()});
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)}}
function escapeHtml(s){return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}
async function sendMessage(){if(isGenerating)return;const text=$input.value.trim();if(!text&&!attachedImage)return;$errBanner.classList.remove("visible");const welcome=$messages.querySelector(".welcome-msg");if(welcome)welcome.remove();const img=attachedImage;if(img)conversationImage=img.raw;appendMessage("user",text,img?.dataURL);sessionMsgCount++;$input.value="";$input.style.height="auto";clearAttachment();if(conversationImage)$btnAttach.disabled=true;isGenerating=true;updateSendBtn();const{groupEl,bubbleEl}=appendAssistantPlaceholder();try{const isFirstTurn=promptHistory==="";const enableThinking=$reasoning.checked;const sysPrompt=getSysPrompt();const maxTok=enableThinking?Math.max(getMaxTok(),2048):getMaxTok();let userPrompt="";if(isFirstTurn&&sysPrompt)userPrompt+=`<|im_start|>system\n${sysPrompt}<|im_end|>\n`;userPrompt+="<|im_start|>user\n";if(img?.raw)userPrompt+="<|vision_start|><|image_pad|><|vision_end|>";userPrompt+=(text||"")+"<|im_end|>\n";userPrompt+=enableThinking?"<|im_start|>assistant\n<think>\n":"<|im_start|>assistant\n<think>\n\n</think>\n\n";let inputs,generateArgs;if(img?.raw){const fp=(isFirstTurn?"":promptHistory+"\n")+userPrompt;inputs=await processor(fp,img.raw);if(inputs.image_grid_thw)imageGridThw=inputs.image_grid_thw;disposePastKeyValues();generateArgs={...inputs}}else if(isFirstTurn){inputs=await processor(userPrompt);generateArgs={...inputs}}else{const cp=promptHistory+"\n"+userPrompt;inputs=await processor(cp);generateArgs={...inputs,past_key_values:pastKeyValues};if(imageGridThw)generateArgs.image_grid_thw=imageGridThw}let fullText="",thinkingDone=!enableThinking,thinkBlock=null,thinkContentEl=null,thinkArrow=null,tokenCount=0,startTime=null;if(enableThinking){thinkBlock=document.createElement("div");thinkBlock.className="think-block";const toggle=document.createElement("div");toggle.className="think-toggle";thinkArrow=document.createElement("span");thinkArrow.className="think-arrow open";thinkArrow.textContent="▶";toggle.append(thinkArrow);toggle.append(document.createTextNode(" Thinking"));thinkContentEl=document.createElement("div");thinkContentEl.className="think-content";thinkBlock.append(toggle,thinkContentEl);bubbleEl.prepend(thinkBlock);toggle.addEventListener("click",()=>{thinkContentEl.classList.toggle("collapsed");thinkArrow.classList.toggle("open")})}let textNode=document.createElement("div");textNode.className="msg-text";bubbleEl.appendChild(textNode);const streamer=new TextStreamer(processor.tokenizer,{skip_prompt:true,skip_special_tokens:!enableThinking,token_callback_function:()=>{if(!startTime)startTime=performance.now();tokenCount++},callback_function:token=>{if(!thinkingDone){const endIdx=(fullText+token).indexOf("</think>");if(endIdx!==-1){thinkingDone=true;thinkContentEl.textContent=(fullText+token).slice(0,endIdx).trim();fullText=(fullText+token).slice(endIdx+"</think>".length);textNode.innerHTML=renderMarkdown(fullText.replace(/^\n+/,"").replace(/<\|im_end\|>/g,""));thinkContentEl.classList.add("collapsed");thinkArrow.classList.remove("open")}else{fullText+=token;thinkContentEl.textContent=fullText}}else{fullText+=token;textNode.innerHTML=renderMarkdown(fullText.replace(/^\n+/,"").replace(/<\|im_end\|>/g,""))}$messages.scrollTop=$messages.scrollHeight}});const result=await model.generate({...generateArgs,max_new_tokens:maxTok,do_sample:true,temperature:getTemp(),top_k:getTopK(),repetition_penalty:getRepPen(),streamer,stopping_criteria:stoppingCriteria,return_dict_in_generate:true});pastKeyValues=result.past_key_values;promptHistory=processor.batch_decode(result.sequences,{skip_special_tokens:false})[0];if(thinkingDone){const cleaned=fullText.replace(/^\n+/,"").replace(/<\|im_end\|>/g,"");textNode.innerHTML=renderMarkdown(cleaned);if(cleaned.includes("`")||cleaned.includes("#")||cleaned.includes("|")||cleaned.includes("*"))bubbleEl.classList.add("md")}if(tokenCount>0&&startTime){const elapsed=(performance.now()-startTime)/1000;const tps=(tokenCount/elapsed).toFixed(1);const se=document.createElement("div");se.className="msg-stats";se.textContent=`${tokenCount} tokens · ${tps} tok/s · ${elapsed.toFixed(1)}s`;groupEl.appendChild(se);totalTokens+=tokenCount;totalTime+=elapsed;updateStatsBar(tps,tokenCount)}sessionMsgCount++;bubbleEl.classList.remove("generating")}catch(err){console.error(err);groupEl.remove();$errBanner.textContent="Error: "+err.message;$errBanner.classList.add("visible");showToast("Generation failed","error")}isGenerating=false;stoppingCriteria.reset();updateSendBtn();$messages.scrollTop=$messages.scrollHeight}
function appendMessage(role,text,imageDataURL){const g=document.createElement("div");g.className=`msg-group ${role}`;const r=document.createElement("div");r.className="msg-role";r.textContent=role==="user"?"You":"Qwen 3.5";g.appendChild(r);if(imageDataURL){const i=document.createElement("img");i.className="msg-image";i.src=imageDataURL;i.alt="attached";g.appendChild(i)}const b=document.createElement("div");b.className="msg-bubble";b.textContent=text;g.appendChild(b);$messages.appendChild(g);$messages.scrollTop=$messages.scrollHeight;return g}
function appendAssistantPlaceholder(){const g=document.createElement("div");g.className="msg-group assistant";const r=document.createElement("div");r.className="msg-role";r.textContent="Qwen 3.5";g.appendChild(r);const b=document.createElement("div");b.className="msg-bubble generating";const d=document.createElement("span");d.className="thinking-dots";for(let i=0;i<3;i++)d.appendChild(document.createElement("span"));b.appendChild(d);g.appendChild(b);$messages.appendChild(g);$messages.scrollTop=$messages.scrollHeight;return{groupEl:g,bubbleEl:b}}
window.visualViewport?.addEventListener("resize",()=>{$messages.scrollTop=$messages.scrollHeight});
(async()=>{if(!navigator.gpu){showToast("WebGPU not available","error");$btnLoad.disabled=true;return}try{const a=await navigator.gpu.requestAdapter({powerPreference:"high-performance"});if(!a){showToast("No WebGPU adapter","error");$btnLoad.disabled=true}}catch(e){showToast("WebGPU init failed","error")}})();
</script>
</body>
</html>