openfree commited on
Commit
76a6627
·
verified ·
1 Parent(s): b4cd759

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +276 -846
index.html CHANGED
@@ -2,925 +2,355 @@
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8"/>
5
- <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
6
- <title>Qwen 3.5 Vision — In-Browser AI Chat</title>
 
 
 
 
7
  <link rel="preconnect" href="https://fonts.googleapis.com"/>
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
9
- <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Instrument+Serif:ital@0;1&family=Manrope:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
10
- <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>"/>
11
  <style>
12
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
13
  :root{
14
- --bg:#0b0c0f;--surface:#131518;--surface-2:#1a1d22;--surface-3:#22262c;
15
- --border:#2a2e36;--border-light:#353a44;
16
- --text:#e8e4de;--text-dim:#8a8680;--text-muted:#5c5955;
17
- --accent:#e8a84c;--accent-dim:#c4862e;
18
- --accent-glow:rgba(232,168,76,0.12);--accent-glow-strong:rgba(232,168,76,0.25);
19
- --red:#d45a5a;--green:#6abf7b;--blue:#5b9bd5;--yellow:#e2c25a;
20
- --radius:12px;--radius-sm:8px;
21
- --font-body:'Manrope',sans-serif;--font-display:'Instrument Serif',serif;--font-mono:'DM Mono',monospace;
 
 
 
 
 
 
 
 
 
22
  }
23
- html{font-size:16px}
24
- body{font-family:var(--font-body);background:var(--bg);color:var(--text);min-height:100dvh;overflow:hidden;-webkit-font-smoothing:antialiased;opacity:0}
25
- body.ready{opacity:1;transition:opacity .4s}
26
- body::before{content:"";position:fixed;inset:0;background:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");pointer-events:none;z-index:9999}
27
- input,textarea,button,select{font-family:inherit;font-size:inherit}
28
- ::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:10px}
29
-
30
- .screen{display:none;width:100%;height:100dvh}.screen.active{display:flex}
31
-
32
- /* ─── LANDING ─── */
33
- #landing{flex-direction:column;align-items:center;justify-content:center;text-align:center;position:relative;overflow:hidden}
34
- .landing-glow{position:absolute;width:600px;height:600px;border-radius:50%;background:radial-gradient(circle,var(--accent-glow-strong) 0%,transparent 70%);top:50%;left:50%;transform:translate(-50%,-55%);animation:breathe 6s ease-in-out infinite;pointer-events:none}
35
- @keyframes breathe{0%,100%{opacity:.5;transform:translate(-50%,-55%) scale(1)}50%{opacity:.8;transform:translate(-50%,-55%) scale(1.12)}}
36
- .landing-tag{font-family:var(--font-mono);font-size:.72rem;letter-spacing:.15em;text-transform:uppercase;color:var(--accent);background:var(--accent-glow);border:1px solid rgba(232,168,76,.2);padding:6px 16px;border-radius:100px;margin-bottom:28px;position:relative}
37
- .landing-title{font-family:var(--font-display);font-size:clamp(3rem,8vw,5.5rem);font-weight:400;line-height:1.05;letter-spacing:-.02em;color:var(--text);position:relative;margin-bottom:16px}
38
  .landing-title em{font-style:italic;color:var(--accent)}
39
- .landing-sub{font-size:1.05rem;color:var(--text-dim);max-width:520px;line-height:1.65;margin-bottom:36px;position:relative;font-weight:300}
40
- .landing-specs{display:flex;gap:32px;margin-bottom:36px;position:relative}
41
- .spec{text-align:center}.spec-value{font-family:var(--font-mono);font-size:1rem;font-weight:500;color:var(--text)}.spec-label{font-size:.7rem;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted);margin-top:4px}
42
-
43
- /* Model search */
44
- .model-search-wrap{position:relative;width:420px;max-width:90vw;margin-bottom:20px}
45
- .model-search-input{width:100%;padding:12px 38px 12px 16px;border-radius:10px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);font-family:var(--font-mono);font-size:.85rem;outline:none;transition:border-color .2s}
46
- .model-search-input:focus{border-color:var(--accent)}
47
- .model-search-input::placeholder{color:var(--text-muted)}
48
- .search-chevron{position:absolute;right:14px;top:50%;transform:translateY(-50%);color:var(--text-muted);font-size:.7rem;pointer-events:none;transition:transform .2s}
49
  .model-search-wrap.open .search-chevron{transform:translateY(-50%) rotate(180deg)}
50
- .model-dropdown{position:absolute;top:100%;left:0;right:0;background:var(--surface-2);border:1px solid var(--accent);border-top:none;border-radius:0 0 10px 10px;max-height:300px;overflow-y:auto;z-index:100;display:none;box-shadow:0 8px 24px rgba(0,0,0,.4)}
51
  .model-search-wrap.open .model-dropdown{display:block}
52
- .model-search-wrap.open .model-search-input{border-radius:10px 10px 0 0;border-color:var(--accent)}
53
- .model-item{padding:10px 14px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:8px;border-bottom:1px solid rgba(255,255,255,.04);transition:background .1s}
54
  .model-item:last-child{border-bottom:none}
55
- .model-item:hover,.model-item.active{background:var(--accent-glow)}
56
- .model-item-name{font-family:var(--font-mono);font-size:.8rem;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
57
  .model-item-meta{display:flex;gap:5px;align-items:center;flex-shrink:0}
58
- .size-badge{background:var(--accent-glow);color:var(--accent);padding:2px 7px;border-radius:4px;font-family:var(--font-mono);font-size:.65rem;font-weight:600}
59
- .cached-badge{background:rgba(106,191,123,.15);color:var(--green);padding:2px 6px;border-radius:4px;font-family:var(--font-mono);font-size:.6rem;font-weight:700;letter-spacing:.05em}
60
- .model-dropdown-loading{padding:10px 14px;font-size:.75rem;color:var(--text-muted);text-align:center}
61
-
62
- .btn-load-group{display:inline-flex;align-items:stretch;border-radius:100px;position:relative;box-shadow:0 0 40px var(--accent-glow-strong);transition:all .25s}
63
- .btn-load-group:hover{transform:translateY(-2px);box-shadow:0 0 60px var(--accent-glow-strong),0 8px 30px rgba(0,0,0,.4)}
64
- .btn-load{font-family:var(--font-body);font-size:.92rem;font-weight:600;letter-spacing:.03em;color:var(--bg);background:var(--accent);border:none;padding:16px 36px;border-radius:100px;cursor:pointer}
65
- .btn-load:disabled{opacity:.4;cursor:not-allowed}
66
- .landing-footer{position:absolute;bottom:28px;font-family:var(--font-mono);font-size:.68rem;color:var(--text-muted);letter-spacing:.06em}
67
- .landing-footer a{color:var(--text-dim);text-decoration:none}.landing-footer a:hover{color:var(--accent)}
68
-
69
- /* ─── LOADING ─── */
70
- #loading{flex-direction:column;align-items:center;justify-content:center;gap:28px}
71
- .loader-ring{width:72px;height:72px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 1s linear infinite}
72
  @keyframes spin{to{transform:rotate(360deg)}}
73
- .loader-text{font-family:var(--font-mono);font-size:.82rem;color:var(--text-dim);letter-spacing:.05em;text-align:center}
74
- .loader-sub{font-size:.72rem;color:var(--text-muted);margin-top:8px;text-align:center;line-height:1.5}
75
- .download-bar{width:360px;max-width:80vw;margin-top:4px}
76
- .download-text{font-family:var(--font-mono);font-size:.7rem;color:var(--text-muted);text-align:center;margin-bottom:6px}
77
- .download-track{height:3px;background:var(--border);border-radius:2px;overflow:hidden}
78
  .download-fill{height:100%;width:0%;background:var(--accent);border-radius:2px;transition:width .3s}
79
 
80
- /* ─── CHAT ─── */
81
- #chat{flex-direction:column;height:100dvh}
82
- .chat-header{display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-bottom:1px solid var(--border);background:var(--surface);flex-shrink:0}
83
- .chat-header-left{display:flex;align-items:center;gap:12px}
84
- .chat-avatar{width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--accent-dim));display:flex;align-items:center;justify-content:center;font-family:var(--font-display);font-size:1rem;color:var(--bg);font-weight:600}
85
- .chat-header-title{font-family:var(--font-display);font-size:1.15rem}
86
- .chat-header-status{font-family:var(--font-mono);font-size:.65rem;color:var(--green);letter-spacing:.06em;display:flex;align-items:center;gap:5px}
87
- .chat-header-status::before{content:"";width:6px;height:6px;background:var(--green);border-radius:50%}
88
- .chat-header-controls{display:flex;align-items:center;gap:10px}
89
- .toggle-wrap{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none}
90
- .toggle-wrap input{display:none}
91
- .toggle-slider{width:30px;height:16px;background:var(--border);border-radius:100px;position:relative;transition:background .2s}
92
- .toggle-slider::after{content:"";position:absolute;width:12px;height:12px;border-radius:50%;background:var(--text-muted);top:2px;left:2px;transition:all .2s}
93
- .toggle-wrap input:checked+.toggle-slider{background:var(--accent-glow)}
94
- .toggle-wrap input:checked+.toggle-slider::after{background:var(--accent);left:16px}
95
- .toggle-lbl{font-family:var(--font-mono);font-size:.6rem;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted);transition:color .2s}
96
- .toggle-wrap input:checked~.toggle-lbl{color:var(--accent)}
97
- .btn-icon{background:transparent;border:1px solid var(--border);color:var(--text-muted);width:28px;height:28px;border-radius:6px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;font-size:.8rem}
98
- .btn-icon:hover{background:var(--surface-2);color:var(--text-dim)}
99
- .btn-reset{font-family:var(--font-mono);font-size:.65rem;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted);background:transparent;border:1px solid var(--border);padding:6px 12px;border-radius:100px;cursor:pointer;transition:all .2s}
100
- .btn-reset:hover{color:var(--red);border-color:var(--red)}
101
-
102
- /* Stats bar */
103
- .stats-bar{display:flex;align-items:center;justify-content:center;gap:24px;padding:6px 20px;border-bottom:1px solid var(--border);background:var(--surface);flex-shrink:0;font-size:.65rem}
104
- .stats-bar:empty{display:none}
105
- .stat{display:flex;align-items:center;gap:5px}
106
- .stat-label{color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em;font-weight:600;font-family:var(--font-mono);font-size:.58rem}
107
- .stat-value{color:var(--text-dim);font-weight:600;font-variant-numeric:tabular-nums;font-family:var(--font-mono);font-size:.7rem}
108
- .stat-value.hl{color:var(--accent)}
109
-
110
- /* Settings panel */
111
- .settings-panel{padding:0 20px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;flex-wrap:wrap;gap:8px 20px;align-items:center;justify-content:center;max-height:0;overflow:hidden;transition:max-height .35s ease,padding .35s ease}
112
- .settings-panel.open{max-height:200px;padding:10px 20px}
113
- .settings-row{display:flex;align-items:center;gap:6px}
114
- .settings-label{font-family:var(--font-mono);font-size:.58rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);min-width:44px;white-space:nowrap}
115
- .settings-select{padding:3px 6px;border-radius:5px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);font-size:.72rem;outline:none;cursor:pointer}
116
  .settings-select:focus{border-color:var(--accent)}
117
- .settings-slider{width:70px;accent-color:var(--accent);height:3px;cursor:pointer}
118
- .settings-val{font-family:var(--font-mono);font-size:.68rem;color:var(--text-dim);font-variant-numeric:tabular-nums;min-width:28px;text-align:right}
119
-
120
- /* System prompt */
121
- .system-prompt-wrap{padding:0 20px;max-height:0;overflow:hidden;transition:max-height .3s,padding .3s;background:var(--surface)}
122
- .system-prompt-wrap.open{max-height:180px;padding:8px 20px 10px;border-bottom:1px solid var(--border)}
123
- .system-prompt-input{width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);outline:none;resize:vertical;min-height:50px;max-height:140px;font-size:.78rem;line-height:1.5;font-family:var(--font-body)}
124
- .system-prompt-input:focus{border-color:var(--accent)}
125
- .system-prompt-input::placeholder{color:var(--text-muted)}
 
 
 
 
 
 
126
 
127
- /* Messages */
128
- .chat-messages{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:16px;scroll-behavior:smooth}
129
- .msg-group{display:flex;flex-direction:column;gap:4px;max-width:78%;animation:msgIn .3s ease}
130
  .msg-group.user{align-self:flex-end}
131
  .msg-group.assistant{align-self:flex-start}
132
- @keyframes msgIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
133
- .msg-role{font-family:var(--font-mono);font-size:.6rem;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted);margin-bottom:2px}
134
- .msg-group.assistant .msg-role{color:var(--accent-dim)}
135
- .msg-bubble{padding:12px 16px;border-radius:var(--radius);border:1px solid var(--border);color:var(--text);font-size:.88rem;line-height:1.7;word-wrap:break-word}
136
- .msg-group.user .msg-bubble{background:var(--surface-3);border-bottom-right-radius:4px}
137
- .msg-group.assistant .msg-bubble{background:var(--surface);border-bottom-left-radius:4px}
138
- .msg-group.assistant .msg-bubble.generating{border-color:var(--accent);box-shadow:0 0 20px var(--accent-glow)}
139
- .msg-image{max-width:220px;max-height:180px;border-radius:var(--radius-sm);margin-bottom:8px;display:block;object-fit:cover;border:1px solid var(--border)}
140
-
141
- /* Markdown content */
142
  .msg-bubble.md p{margin:0 0 .5em}.msg-bubble.md p:last-child{margin:0}
143
- .msg-bubble.md h1,.msg-bubble.md h2,.msg-bubble.md h3,.msg-bubble.md h4{margin:.8em 0 .4em;font-weight:600;color:var(--text);line-height:1.3}
144
- .msg-bubble.md h1{font-size:1.3em}.msg-bubble.md h2{font-size:1.15em}.msg-bubble.md h3{font-size:1em}
145
  .msg-bubble.md h1:first-child,.msg-bubble.md h2:first-child,.msg-bubble.md h3:first-child{margin-top:0}
146
- .msg-bubble.md code{font-family:var(--font-mono);font-size:.85em;padding:2px 6px;background:var(--surface-3);border-radius:4px;color:var(--accent)}
147
- .msg-bubble.md pre{margin:.5em 0;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;overflow-x:auto;line-height:1.5}
148
- .msg-bubble.md pre code{padding:0;background:none;color:var(--text);font-size:.8rem}
149
- .msg-bubble.md ul,.msg-bubble.md ol{margin:.4em 0;padding-left:1.5em}
150
- .msg-bubble.md li{margin:.2em 0}
151
- .msg-bubble.md blockquote{margin:.5em 0;padding:6px 12px;border-left:3px solid var(--border-light);color:var(--text-dim);background:var(--surface-2);border-radius:0 4px 4px 0}
152
  .msg-bubble.md table{margin:.5em 0;border-collapse:collapse;width:100%;font-size:.82rem}
153
- .msg-bubble.md th,.msg-bubble.md td{padding:5px 8px;border:1px solid var(--border);text-align:left}
154
- .msg-bubble.md th{background:var(--surface-2);font-weight:600;color:var(--text-dim);font-size:.72rem;text-transform:uppercase;letter-spacing:.04em}
155
- .msg-bubble.md strong{font-weight:600;color:var(--text)}
156
- .msg-bubble.md a{color:var(--accent);text-decoration:none}
157
- .msg-bubble.md a:hover{text-decoration:underline}
158
- .msg-bubble.md hr{margin:.8em 0;border:none;border-top:1px solid var(--border)}
159
-
160
- /* Thinking block */
161
- .think-block{margin-bottom:8px;border-left:2px solid var(--accent);padding:8px 12px;background:var(--accent-glow);border-radius:0 6px 6px 0}
162
- .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:.06em;color:var(--accent)}
163
- .think-toggle:hover{opacity:.8}
164
- .think-arrow{font-size:.55rem;transition:transform .2s;display:inline-block}
 
 
165
  .think-arrow.open{transform:rotate(90deg)}
166
- .think-content{font-size:.8rem;color:var(--text-dim);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}
167
  .think-content.collapsed{max-height:0;margin-top:0;opacity:0}
168
-
169
- /* Gen stats */
170
- .msg-stats{font-family:var(--font-mono);font-size:.6rem;color:var(--text-muted);margin-top:6px;letter-spacing:.03em}
171
-
172
- /* Thinking dots */
173
  .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}
174
  .thinking-dots span:nth-child(2){animation-delay:.2s}
175
  .thinking-dots span:nth-child(3){animation-delay:.4s}
176
- @keyframes dot{0%,80%,100%{opacity:.25;transform:scale(.8)}40%{opacity:1;transform:scale(1)}}
177
 
178
- /* Input area */
179
- .chat-input-area{padding:12px 20px 16px;border-top:1px solid var(--border);background:var(--surface);flex-shrink:0}
180
- .image-preview-bar{display:none;align-items:center;gap:10px;margin-bottom:10px;padding:6px 10px;background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-sm)}
181
  .image-preview-bar.visible{display:flex}
182
- .image-preview-thumb{width:40px;height:40px;border-radius:6px;object-fit:cover;border:1px solid var(--border)}
183
- .image-preview-name{font-family:var(--font-mono);font-size:.72rem;color:var(--text-dim);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
184
- .btn-remove-image{background:none;border:none;color:var(--text-muted);font-size:1rem;cursor:pointer;padding:4px;transition:color .2s}
185
  .btn-remove-image:hover{color:var(--red)}
186
- .chat-input-row{display:flex;align-items:flex-end;gap:8px}
187
- .btn-attach{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;cursor:pointer;background:var(--surface-2);border:1px solid var(--border);color:var(--text-dim);font-size:1.1rem;transition:all .2s}
188
- .btn-attach:hover:not(:disabled){border-color:var(--accent);color:var(--accent);background:var(--accent-glow)}
189
- .btn-attach:disabled{opacity:.35;cursor:not-allowed}
 
190
  .input-wrap{flex:1;position:relative}
191
- .input-wrap textarea{width:100%;min-height:40px;max-height:140px;padding:9px 14px;background:var(--surface-2);border:1px solid var(--border);border-radius:10px;color:var(--text);font-family:var(--font-body);font-size:.86rem;line-height:1.5;resize:none;outline:none;transition:border-color .2s}
192
- .input-wrap textarea::placeholder{color:var(--text-muted)}
193
- .input-wrap textarea:focus{border-color:var(--accent)}
194
- .btn-send{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;cursor:pointer;background:var(--accent);border:none;color:var(--bg);font-size:1rem;transition:all .2s}
195
- .btn-send:disabled{opacity:.35;cursor:not-allowed}
196
- .btn-send:not(:disabled):hover{transform:translateY(-1px);box-shadow:0 4px 20px var(--accent-glow-strong)}
 
197
  .btn-send .icon-stop{display:none}
198
  .btn-send.stopping{background:var(--red)}
199
  .btn-send.stopping .icon-send{display:none}
200
  .btn-send.stopping .icon-stop{display:block}
201
- .chat-footer-note{font-family:var(--font-mono);font-size:.58rem;color:var(--text-muted);text-align:center;margin-top:8px;letter-spacing:.04em}
202
 
203
- /* Toast */
204
- .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(20px);padding:10px 20px;border-radius:8px;font-size:.82rem;font-weight:500;background:var(--surface-2);color:var(--text);border:1px solid var(--border);opacity:0;transition:all .3s;z-index:1000;max-width:480px;pointer-events:none}
 
 
 
205
  .toast.show{transform:translateX(-50%) translateY(0);opacity:1}
206
  .toast.error{border-color:var(--red);color:var(--red)}
207
  .toast.success{border-color:var(--green);color:var(--green)}
208
 
209
- /* Error banner */
210
- .error-banner{display:none;padding:10px 16px;background:rgba(212,90,90,.1);border:1px solid rgba(212,90,90,.3);border-radius:var(--radius-sm);color:var(--red);font-size:.8rem;margin:10px 20px 0}
211
- .error-banner.visible{display:block}
212
-
213
- /* Welcome */
214
- .welcome-msg{text-align:center;padding:48px 24px;color:var(--text-muted);flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center}
215
- .welcome-msg h3{font-family:var(--font-display);font-size:1.4rem;color:var(--text-dim);margin-bottom:8px;font-weight:400}
216
- .welcome-msg p{font-size:.85rem;line-height:1.6}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  </style>
218
  </head>
219
  <body>
220
-
221
- <!-- ═══ LANDING ═══ -->
222
  <div id="landing" class="screen active">
223
- <div class="landing-glow"></div>
224
- <div class="landing-tag">Multimodal AI · 100% Local · WebGPU</div>
225
  <h1 class="landing-title">Qwen 3.5 <em>Vision</em></h1>
226
- <p class="landing-sub">
227
- Run a multimodal vision-language model entirely in your browser.
228
- No server, no API keys — powered by Transformers.js and WebGPU.
229
- </p>
230
- <div class="landing-specs">
231
- <div class="spec"><div class="spec-value">Vision + Language</div><div class="spec-label">Unified Multimodal</div></div>
232
- <div class="spec"><div class="spec-value">201 Languages</div><div class="spec-label">Global Coverage</div></div>
233
- <div class="spec"><div class="spec-value">Reasoning</div><div class="spec-label">Code · Agents · Visual</div></div>
234
- </div>
235
-
236
  <div class="model-search-wrap" id="modelSearchWrap">
237
- <input class="model-search-input" id="modelSearchInput" type="text"
238
- value="onnx-community/Qwen3.5-0.8B-ONNX" placeholder="Search Qwen3.5 models on HuggingFace..." autocomplete="off"/>
239
  <span class="search-chevron">▾</span>
240
  <div class="model-dropdown" id="modelDropdown"></div>
241
  </div>
242
-
243
- <div class="btn-load-group">
244
- <button class="btn-load" id="btnLoad">Load Model</button>
245
- </div>
246
-
247
- <div class="landing-footer">
248
- Built with <a href="https://huggingface.co/docs/transformers.js" target="_blank">Transformers.js</a>
249
- · Combined Edition
250
- </div>
251
  </div>
252
-
253
- <!-- ═══ LOADING ═══ -->
254
  <div id="loading" class="screen">
255
  <div class="loader-ring" id="loaderRing"></div>
256
- <div>
257
- <div class="loader-text" id="loaderText">Initializing model…</div>
258
- <div class="loader-sub" id="loaderSub">Model weights are cached for future visits.</div>
259
- </div>
260
- <div class="download-bar" id="downloadBar" style="display:none">
261
- <div class="download-text" id="downloadText">Downloading…</div>
262
- <div class="download-track"><div class="download-fill" id="downloadFill"></div></div>
263
- </div>
264
  </div>
265
-
266
- <!-- ═══ CHAT ═══ -->
267
  <div id="chat" class="screen">
268
  <div class="chat-header">
269
- <div class="chat-header-left">
270
- <div class="chat-avatar">Q</div>
271
- <div>
272
- <div class="chat-header-title" id="chatTitle">Qwen 3.5 Vision</div>
273
- <div class="chat-header-status">Ready on WebGPU</div>
274
- </div>
275
- </div>
276
  <div class="chat-header-controls">
277
- <label class="toggle-wrap" title="Think step-by-step before answering">
278
- <input type="checkbox" id="reasoningToggle"/>
279
- <span class="toggle-slider"></span>
280
- <span class="toggle-lbl">Reasoning</span>
281
- </label>
282
  <button class="btn-icon" id="btnSettings" title="Settings">⚙</button>
283
  <button class="btn-icon" id="btnSysPrompt" title="System prompt">S</button>
284
  <button class="btn-reset" id="btnReset">Reset</button>
285
  </div>
286
  </div>
287
-
288
- <!-- Settings panel -->
289
  <div class="settings-panel" id="settingsPanel">
290
- <div class="settings-row">
291
- <span class="settings-label">Temp</span>
292
- <input type="range" class="settings-slider" id="tempSlider" min="0" max="200" value="70"/>
293
- <span class="settings-val" id="tempVal">0.70</span>
294
- </div>
295
- <div class="settings-row">
296
- <span class="settings-label">Top-K</span>
297
- <input type="range" class="settings-slider" id="topkSlider" min="1" max="100" value="50"/>
298
- <span class="settings-val" id="topkVal">50</span>
299
- </div>
300
- <div class="settings-row">
301
- <span class="settings-label">Max Tok</span>
302
- <select class="settings-select" id="maxTokSelect">
303
- <option value="256">256</option>
304
- <option value="512" selected>512</option>
305
- <option value="1024">1024</option>
306
- <option value="2048">2048</option>
307
- <option value="4096">4096</option>
308
- </select>
309
- </div>
310
- <div class="settings-row">
311
- <span class="settings-label">Rep Pen</span>
312
- <input type="range" class="settings-slider" id="repPenSlider" min="100" max="200" value="110"/>
313
- <span class="settings-val" id="repPenVal">1.10</span>
314
- </div>
315
  </div>
316
-
317
- <!-- System prompt -->
318
- <div class="system-prompt-wrap" id="sysPromptWrap">
319
- <textarea class="system-prompt-input" id="sysPromptInput" placeholder="Enter system prompt (optional)..."></textarea>
320
- </div>
321
-
322
- <!-- Stats bar -->
323
  <div class="stats-bar" id="statsBar"></div>
324
-
325
  <div class="error-banner" id="errorBanner"></div>
326
-
327
- <div class="chat-messages" id="chatMessages">
328
- <div class="welcome-msg" id="welcomeMsg">
329
- <h3>Start a conversation</h3>
330
- <p>Optionally attach an image, then type your message.<br/>The model runs entirely in your browser.</p>
331
- </div>
332
- </div>
333
-
334
  <div class="chat-input-area">
335
- <div class="image-preview-bar" id="imagePreview">
336
- <img class="image-preview-thumb" id="imageThumb" src="" alt=""/>
337
- <span class="image-preview-name" id="imageName"></span>
338
- <button class="btn-remove-image" id="btnRemoveImage">&times;</button>
339
- </div>
340
  <div class="chat-input-row">
341
- <button class="btn-attach" id="btnAttach" title="Attach image">
342
- <svg width="17" height="17" 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>
343
- </button>
344
  <input type="file" id="fileInput" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp" hidden/>
345
- <div class="input-wrap">
346
- <textarea id="msgInput" rows="1" placeholder="Type your message…"></textarea>
347
- </div>
348
- <button class="btn-send" id="btnSend" disabled title="Send">
349
- <svg class="icon-send" width="17" height="17" 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>
350
- <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>
351
- </button>
352
  </div>
353
- <div class="chat-footer-note">No data is sent to a server. Everything runs locally in your browser. AI can make mistakes.</div>
354
  </div>
355
  </div>
356
-
357
  <div class="toast" id="toast"></div>
358
-
359
- <!-- Markdown parser -->
360
  <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script>
361
-
362
  <script type="module">
363
- import {
364
- AutoProcessor,
365
- Qwen3_5ForConditionalGeneration,
366
- RawImage,
367
- TextStreamer,
368
- InterruptableStoppingCriteria,
369
- } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.0.0-next.6";
370
-
371
- /* ─── State ─── */
372
- let processor = null;
373
- let model = null;
374
- let conversationImage = null;
375
- let attachedImage = null;
376
- let isGenerating = false;
377
- let pastKeyValues = null;
378
- let imageGridThw = null;
379
- let promptHistory = "";
380
- const stoppingCriteria = new InterruptableStoppingCriteria();
381
-
382
- let totalTokens = 0;
383
- let totalTime = 0;
384
- let sessionMsgCount = 0;
385
-
386
- /* ─── Config ─── */
387
- const getTemp = () => parseInt($("tempSlider").value) / 100;
388
- const getTopK = () => parseInt($("topkSlider").value);
389
- const getMaxTok = () => parseInt($("maxTokSelect").value);
390
- const getRepPen = () => parseInt($("repPenSlider").value) / 100;
391
- const getSysPrompt = () => $("sysPromptInput").value.trim();
392
-
393
- /* ─── Wait for fonts ─── */
394
- document.fonts.ready.then(() => document.body.classList.add("ready"));
395
-
396
- /* ─── DOM refs ─── */
397
- const $ = (id) => document.getElementById(id);
398
- const $loaderTx = $("loaderText");
399
- const $loaderSub = $("loaderSub");
400
- const $messages = $("chatMessages");
401
- const $input = $("msgInput");
402
- const $btnSend = $("btnSend");
403
- const $btnLoad = $("btnLoad");
404
- const $btnReset = $("btnReset");
405
- const $btnAttach = $("btnAttach");
406
- const $fileInput = $("fileInput");
407
- const $imgPrev = $("imagePreview");
408
- const $imgThumb = $("imageThumb");
409
- const $imgName = $("imageName");
410
- const $btnRemImg = $("btnRemoveImage");
411
- const $errBanner = $("errorBanner");
412
- const $reasoning = $("reasoningToggle");
413
- const $searchInput = $("modelSearchInput");
414
- const $searchWrap = $("modelSearchWrap");
415
- const $dropdown = $("modelDropdown");
416
- const $downloadBar = $("downloadBar");
417
- const $downloadText = $("downloadText");
418
- const $downloadFill = $("downloadFill");
419
- const $statsBar = $("statsBar");
420
- const $toast = $("toast");
421
-
422
- /* ─── Toast ─── */
423
- let toastTimer = null;
424
- function showToast(msg, type = "") {
425
- clearTimeout(toastTimer);
426
- $toast.textContent = msg;
427
- $toast.className = "toast " + type + " show";
428
- toastTimer = setTimeout(() => $toast.classList.remove("show"), 3000);
429
- }
430
-
431
- /* ─── Stats bar ─── */
432
- function updateStatsBar(tps = null, tokens = null) {
433
- if (!totalTokens && !tps) { $statsBar.innerHTML = ""; return; }
434
- let html = "";
435
- if (tps !== null) html += `<div class="stat"><span class="stat-label">Speed</span><span class="stat-value hl">${tps} tok/s</span></div>`;
436
- if (tokens !== null) html += `<div class="stat"><span class="stat-label">Tokens</span><span class="stat-value">${tokens}</span></div>`;
437
- if (totalTokens > 0) html += `<div class="stat"><span class="stat-label">Session</span><span class="stat-value">${totalTokens} tok</span></div>`;
438
- if (sessionMsgCount > 0) html += `<div class="stat"><span class="stat-label">Msgs</span><span class="stat-value">${sessionMsgCount}</span></div>`;
439
- $statsBar.innerHTML = html;
440
- }
441
-
442
- /* ─── Model search ─── */
443
- const PRESET_MODELS = [
444
- { id: "onnx-community/Qwen3.5-0.8B-ONNX", size: "0.8B" },
445
- { id: "onnx-community/Qwen3.5-2B-ONNX", size: "2B" },
446
- { id: "onnx-community/Qwen3.5-4B-ONNX", size: "4B" },
447
- ];
448
-
449
- let searchTimer = null;
450
-
451
- function renderDropdown(models) {
452
- $dropdown.innerHTML = models.map(m => {
453
- const meta = m.size ? `<span class="size-badge">${m.size}</span>` : "";
454
- const cached = m.cached ? `<span class="cached-badge">CACHED</span>` : "";
455
- return `<div class="model-item" data-id="${m.id}"><span class="model-item-name">${m.id}</span><span class="model-item-meta">${cached}${meta}</span></div>`;
456
- }).join("");
457
- $dropdown.querySelectorAll(".model-item").forEach(el => {
458
- el.addEventListener("click", () => {
459
- $searchInput.value = el.dataset.id;
460
- closeDropdown();
461
- });
462
- });
463
- }
464
-
465
- function openDropdown() {
466
- $searchWrap.classList.add("open");
467
- if (!$dropdown.innerHTML) renderDropdown(PRESET_MODELS);
468
- }
469
- function closeDropdown() { $searchWrap.classList.remove("open"); }
470
-
471
- $searchInput.addEventListener("focus", () => {
472
- openDropdown();
473
- renderDropdown(PRESET_MODELS);
474
- });
475
- $searchInput.addEventListener("input", () => {
476
- const q = $searchInput.value.trim().toLowerCase();
477
- if (q.length < 2) { renderDropdown(PRESET_MODELS); openDropdown(); return; }
478
- const local = PRESET_MODELS.filter(m => m.id.toLowerCase().includes(q));
479
- if (local.length) { renderDropdown(local); openDropdown(); }
480
- clearTimeout(searchTimer);
481
- searchTimer = setTimeout(async () => {
482
- try {
483
- $dropdown.innerHTML = '<div class="model-dropdown-loading">Searching HuggingFace…</div>';
484
- openDropdown();
485
- const resp = await fetch(`https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=onnx&limit=10&sort=downloads&direction=-1`);
486
- if (!resp.ok) return;
487
- const data = await resp.json();
488
- const results = data.filter(m => m.id.toLowerCase().includes("qwen") || m.id.toLowerCase().includes("onnx"))
489
- .map(m => ({ id: m.id, size: m.id.match(/(\d+\.?\d*B)/i)?.[1] || "" }));
490
- const combined = [...local, ...results.filter(r => !local.find(l => l.id === r.id))];
491
- if (combined.length) renderDropdown(combined);
492
- else $dropdown.innerHTML = '<div class="model-dropdown-loading">No models found</div>';
493
- } catch (e) { console.error(e); }
494
- }, 400);
495
- });
496
- document.addEventListener("click", (e) => { if (!e.target.closest(".model-search-wrap")) closeDropdown(); });
497
- $searchInput.addEventListener("keydown", (e) => {
498
- if (e.key === "Enter") { e.preventDefault(); closeDropdown(); }
499
- if (e.key === "Escape") closeDropdown();
500
- const items = $dropdown.querySelectorAll(".model-item");
501
- if (!items.length) return;
502
- const active = $dropdown.querySelector(".model-item.active");
503
- let idx = Array.from(items).indexOf(active);
504
- 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" }); }
505
- 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" }); }
506
- if (e.key === "Enter" && active) { $searchInput.value = active.dataset.id; closeDropdown(); }
507
- });
508
-
509
- /* ─── Settings panel toggles ─── */
510
- $("btnSettings").addEventListener("click", () => $("settingsPanel").classList.toggle("open"));
511
- $("btnSysPrompt").addEventListener("click", () => $("sysPromptWrap").classList.toggle("open"));
512
- $("tempSlider").addEventListener("input", () => $("tempVal").textContent = getTemp().toFixed(2));
513
- $("topkSlider").addEventListener("input", () => $("topkVal").textContent = getTopK());
514
- $("repPenSlider").addEventListener("input", () => $("repPenVal").textContent = getRepPen().toFixed(2));
515
-
516
- /* ─── Screen switching ─── */
517
- function showScreen(id) {
518
- document.querySelectorAll(".screen").forEach(s => s.classList.toggle("active", s.id === id));
519
- }
520
-
521
- /* ─── Model loading ─── */
522
- $btnLoad.addEventListener("click", async () => {
523
- const model_id = $searchInput.value.trim();
524
- if (!model_id) { showToast("Enter a model ID", "error"); return; }
525
- showScreen("loading");
526
- $downloadBar.style.display = "none";
527
- try {
528
- $loaderTx.textContent = "Loading processor…";
529
- processor = await AutoProcessor.from_pretrained(model_id, {
530
- progress_callback: (p) => {
531
- if (p.status === "download") {
532
- $downloadBar.style.display = "";
533
- const pct = p.total ? Math.round(p.loaded / p.total * 100) : 0;
534
- $downloadFill.style.width = pct + "%";
535
- $downloadText.textContent = `Processor: ${pct}%`;
536
- }
537
- }
538
- });
539
-
540
- $loaderTx.textContent = "Loading model weights…";
541
- $loaderSub.textContent = "This may take a minute on first visit.";
542
- model = await Qwen3_5ForConditionalGeneration.from_pretrained(model_id, {
543
- dtype: {
544
- embed_tokens: "q4",
545
- vision_encoder: "fp16",
546
- decoder_model_merged: "q4",
547
- },
548
- device: "webgpu",
549
- progress_callback: (p) => {
550
- if (p.status === "download" || p.status === "progress") {
551
- $downloadBar.style.display = "";
552
- const pct = p.total ? Math.round(p.loaded / p.total * 100) : 0;
553
- $downloadFill.style.width = pct + "%";
554
- const mb = (p.loaded / 1024 / 1024).toFixed(0);
555
- const totalMb = p.total ? (p.total / 1024 / 1024).toFixed(0) : "?";
556
- $downloadText.textContent = `Model: ${mb}MB / ${totalMb}MB (${pct}%)`;
557
- }
558
- if (p.status === "done") {
559
- $downloadFill.style.width = "100%";
560
- }
561
- }
562
- });
563
-
564
- $loaderTx.textContent = "Ready!";
565
- const sizeLabel = model_id.match(/(\d+\.?\d*B)/i)?.[1] || "";
566
- $("chatTitle").textContent = `Qwen 3.5 Vision${sizeLabel ? " · " + sizeLabel : ""}`;
567
- setTimeout(() => showScreen("chat"), 400);
568
- } catch (err) {
569
- console.error(err);
570
- $loaderTx.textContent = "Failed to load model";
571
- $loaderSub.textContent = err.message;
572
- $("loaderRing").style.borderTopColor = "var(--red)";
573
- }
574
- });
575
-
576
- /* ─── Image attachment ─── */
577
- $btnAttach.addEventListener("click", () => { if (!$btnAttach.disabled) $fileInput.click(); });
578
- $fileInput.addEventListener("change", async (e) => {
579
- const file = e.target.files?.[0];
580
- if (!file) return;
581
- const dataURL = URL.createObjectURL(file);
582
- const raw = await RawImage.read(dataURL);
583
- const resized = await raw.resize(448, 448);
584
- attachedImage = { raw: resized, dataURL, name: file.name };
585
- $imgThumb.src = dataURL;
586
- $imgName.textContent = file.name;
587
- $imgPrev.classList.add("visible");
588
- updateSendBtn();
589
- $fileInput.value = "";
590
- });
591
- $btnRemImg.addEventListener("click", clearAttachment);
592
- function clearAttachment() {
593
- if (attachedImage?.dataURL) URL.revokeObjectURL(attachedImage.dataURL);
594
- attachedImage = null;
595
- $imgPrev.classList.remove("visible");
596
- $imgThumb.src = "";
597
- $imgName.textContent = "";
598
- updateSendBtn();
599
- }
600
-
601
- /* ─── Input handling ─── */
602
- $input.addEventListener("input", () => {
603
- $input.style.height = "auto";
604
- $input.style.height = Math.min($input.scrollHeight, 140) + "px";
605
- updateSendBtn();
606
- });
607
- $input.addEventListener("keydown", (e) => {
608
- if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (!isGenerating) sendMessage(); }
609
- });
610
- $btnSend.addEventListener("click", () => {
611
- if (isGenerating) stoppingCriteria.interrupt();
612
- else sendMessage();
613
- });
614
- function updateSendBtn() {
615
- if (isGenerating) { $btnSend.disabled = false; $btnSend.classList.add("stopping"); }
616
- else { $btnSend.classList.remove("stopping"); $btnSend.disabled = !$input.value.trim() && !attachedImage; }
617
- }
618
- function disposePastKeyValues() {
619
- if (pastKeyValues) {
620
- for (const tensor of Object.values(pastKeyValues)) tensor.dispose();
621
- pastKeyValues = null;
622
- }
623
- }
624
-
625
- /* ─── Paste image support ─── */
626
- $input.addEventListener("paste", (e) => {
627
- const items = e.clipboardData?.items;
628
- if (!items) return;
629
- for (const item of items) {
630
- if (item.type.startsWith("image/")) {
631
- e.preventDefault();
632
- const file = item.getAsFile();
633
- const dt = new DataTransfer();
634
- dt.items.add(file);
635
- $fileInput.files = dt.files;
636
- $fileInput.dispatchEvent(new Event("change"));
637
- break;
638
- }
639
- }
640
- });
641
-
642
- /* ─── Reset ─── */
643
- $btnReset.addEventListener("click", () => {
644
- conversationImage = null;
645
- attachedImage = null;
646
- disposePastKeyValues();
647
- stoppingCriteria.reset();
648
- imageGridThw = null;
649
- promptHistory = "";
650
- totalTokens = 0; totalTime = 0; sessionMsgCount = 0;
651
- $imgPrev.classList.remove("visible");
652
- $btnAttach.disabled = false;
653
- $messages.innerHTML = `<div class="welcome-msg" id="welcomeMsg"><h3>Start a conversation</h3><p>Optionally attach an image, then type your message.<br/>The model runs entirely in your browser.</p></div>`;
654
- $errBanner.classList.remove("visible");
655
- $input.value = "";
656
- $input.style.height = "auto";
657
- updateStatsBar();
658
- updateSendBtn();
659
- });
660
-
661
- /* ─── Markdown render ─── */
662
- function renderMarkdown(text) {
663
- if (typeof marked === "undefined") return escapeHtml(text);
664
- try {
665
- marked.setOptions({ breaks: true, gfm: true });
666
- return marked.parse(text);
667
- } catch { return escapeHtml(text); }
668
- }
669
- function escapeHtml(s) {
670
- return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
671
- }
672
-
673
- /* ─── Chat logic ─── */
674
- async function sendMessage() {
675
- if (isGenerating) return;
676
- const text = $input.value.trim();
677
- if (!text && !attachedImage) return;
678
- $errBanner.classList.remove("visible");
679
- const welcome = $messages.querySelector(".welcome-msg");
680
- if (welcome) welcome.remove();
681
-
682
- const img = attachedImage;
683
- if (img) conversationImage = img.raw;
684
-
685
- appendMessage("user", text, img?.dataURL);
686
- sessionMsgCount++;
687
-
688
- $input.value = "";
689
- $input.style.height = "auto";
690
- clearAttachment();
691
- if (conversationImage) $btnAttach.disabled = true;
692
-
693
- isGenerating = true;
694
- updateSendBtn();
695
-
696
- const { groupEl, bubbleEl } = appendAssistantPlaceholder();
697
-
698
- try {
699
- const isFirstTurn = promptHistory === "";
700
- const enableThinking = $reasoning.checked;
701
- const sysPrompt = getSysPrompt();
702
- const maxTok = enableThinking ? Math.max(getMaxTok(), 2048) : getMaxTok();
703
-
704
- let userPrompt = "";
705
- if (isFirstTurn && sysPrompt) {
706
- userPrompt += `<|im_start|>system\n${sysPrompt}<|im_end|>\n`;
707
- }
708
- userPrompt += "<|im_start|>user\n";
709
- if (img?.raw) userPrompt += "<|vision_start|><|image_pad|><|vision_end|>";
710
- userPrompt += (text || "") + "<|im_end|>\n";
711
- userPrompt += enableThinking
712
- ? "<|im_start|>assistant\n<think>\n"
713
- : "<|im_start|>assistant\n<think>\n\n</think>\n\n";
714
-
715
- let inputs, generateArgs;
716
-
717
- if (img?.raw) {
718
- const fullPrompt = (isFirstTurn ? "" : promptHistory + "\n") + userPrompt;
719
- inputs = await processor(fullPrompt, img.raw);
720
- if (inputs.image_grid_thw) imageGridThw = inputs.image_grid_thw;
721
- disposePastKeyValues();
722
- generateArgs = { ...inputs };
723
- } else if (isFirstTurn) {
724
- inputs = await processor(userPrompt);
725
- generateArgs = { ...inputs };
726
- } else {
727
- const continuationPrompt = promptHistory + "\n" + userPrompt;
728
- inputs = await processor(continuationPrompt);
729
- generateArgs = { ...inputs, past_key_values: pastKeyValues };
730
- if (imageGridThw) generateArgs.image_grid_thw = imageGridThw;
731
- }
732
-
733
- let fullText = "";
734
- let thinkingDone = !enableThinking;
735
- let thinkBlock = null;
736
- let thinkContentEl = null;
737
- let thinkArrow = null;
738
- let tokenCount = 0;
739
- let startTime = null;
740
-
741
- if (enableThinking) {
742
- thinkBlock = document.createElement("div");
743
- thinkBlock.className = "think-block";
744
- const toggle = document.createElement("div");
745
- toggle.className = "think-toggle";
746
- thinkArrow = document.createElement("span");
747
- thinkArrow.className = "think-arrow open";
748
- thinkArrow.textContent = "▶";
749
- toggle.append(thinkArrow);
750
- toggle.append(document.createTextNode(" Thinking"));
751
- thinkContentEl = document.createElement("div");
752
- thinkContentEl.className = "think-content";
753
- thinkBlock.append(toggle, thinkContentEl);
754
- bubbleEl.prepend(thinkBlock);
755
-
756
- toggle.addEventListener("click", () => {
757
- thinkContentEl.classList.toggle("collapsed");
758
- thinkArrow.classList.toggle("open");
759
- });
760
- }
761
-
762
- const contentEl = bubbleEl.querySelector(".msg-text") || bubbleEl;
763
- let textNode = document.createElement("div");
764
- textNode.className = "msg-text";
765
- bubbleEl.appendChild(textNode);
766
-
767
- const streamer = new TextStreamer(processor.tokenizer, {
768
- skip_prompt: true,
769
- skip_special_tokens: !enableThinking,
770
- token_callback_function: () => {
771
- if (!startTime) startTime = performance.now();
772
- tokenCount++;
773
- },
774
- callback_function: (token) => {
775
- if (!thinkingDone) {
776
- const endIdx = (fullText + token).indexOf("</think>");
777
- if (endIdx !== -1) {
778
- thinkingDone = true;
779
- const thinkText = (fullText + token).slice(0, endIdx).trim();
780
- thinkContentEl.textContent = thinkText;
781
- fullText = (fullText + token).slice(endIdx + "</think>".length);
782
- textNode.innerHTML = renderMarkdown(fullText.replace(/^\n+/, "").replace(/<\|im_end\|>/g, ""));
783
- thinkContentEl.classList.add("collapsed");
784
- thinkArrow.classList.remove("open");
785
- } else {
786
- fullText += token;
787
- thinkContentEl.textContent = fullText;
788
- }
789
- } else {
790
- fullText += token;
791
- const cleaned = fullText.replace(/^\n+/, "").replace(/<\|im_end\|>/g, "");
792
- textNode.innerHTML = renderMarkdown(cleaned);
793
- }
794
- $messages.scrollTop = $messages.scrollHeight;
795
- },
796
- });
797
-
798
- const genConfig = {
799
- ...generateArgs,
800
- max_new_tokens: maxTok,
801
- do_sample: true,
802
- temperature: getTemp(),
803
- top_k: getTopK(),
804
- repetition_penalty: getRepPen(),
805
- streamer,
806
- stopping_criteria: stoppingCriteria,
807
- return_dict_in_generate: true,
808
- };
809
-
810
- const result = await model.generate(genConfig);
811
-
812
- pastKeyValues = result.past_key_values;
813
- const fullSequenceText = processor.batch_decode(result.sequences, { skip_special_tokens: false })[0];
814
- promptHistory = fullSequenceText;
815
-
816
- // Final markdown render
817
- if (thinkingDone) {
818
- const cleaned = fullText.replace(/^\n+/, "").replace(/<\|im_end\|>/g, "");
819
- textNode.innerHTML = renderMarkdown(cleaned);
820
- if (cleaned.includes("`") || cleaned.includes("#") || cleaned.includes("|") || cleaned.includes("*")) {
821
- bubbleEl.classList.add("md");
822
- }
823
- }
824
-
825
- // Stats
826
- if (tokenCount > 0 && startTime) {
827
- const elapsed = (performance.now() - startTime) / 1000;
828
- const tps = (tokenCount / elapsed).toFixed(1);
829
- const statsEl = document.createElement("div");
830
- statsEl.className = "msg-stats";
831
- statsEl.textContent = `${tokenCount} tokens · ${tps} tok/s · ${elapsed.toFixed(1)}s`;
832
- groupEl.appendChild(statsEl);
833
-
834
- totalTokens += tokenCount;
835
- totalTime += elapsed;
836
- updateStatsBar(tps, tokenCount);
837
- }
838
-
839
- sessionMsgCount++;
840
- bubbleEl.classList.remove("generating");
841
-
842
- } catch (err) {
843
- console.error(err);
844
- groupEl.remove();
845
- $errBanner.textContent = "Generation error: " + err.message;
846
- $errBanner.classList.add("visible");
847
- showToast("Generation failed", "error");
848
- }
849
-
850
- isGenerating = false;
851
- stoppingCriteria.reset();
852
- updateSendBtn();
853
- $messages.scrollTop = $messages.scrollHeight;
854
- }
855
-
856
- /* ─── Render helpers ─── */
857
- function appendMessage(role, text, imageDataURL) {
858
- const group = document.createElement("div");
859
- group.className = `msg-group ${role}`;
860
-
861
- const roleEl = document.createElement("div");
862
- roleEl.className = "msg-role";
863
- roleEl.textContent = role === "user" ? "You" : "Qwen 3.5";
864
- group.appendChild(roleEl);
865
-
866
- if (imageDataURL) {
867
- const img = document.createElement("img");
868
- img.className = "msg-image";
869
- img.src = imageDataURL;
870
- img.alt = "attached";
871
- group.appendChild(img);
872
- }
873
-
874
- const bubble = document.createElement("div");
875
- bubble.className = "msg-bubble";
876
- bubble.textContent = text;
877
- group.appendChild(bubble);
878
-
879
- $messages.appendChild(group);
880
- $messages.scrollTop = $messages.scrollHeight;
881
- return group;
882
- }
883
-
884
- function appendAssistantPlaceholder() {
885
- const group = document.createElement("div");
886
- group.className = "msg-group assistant";
887
-
888
- const roleEl = document.createElement("div");
889
- roleEl.className = "msg-role";
890
- roleEl.textContent = "Qwen 3.5";
891
- group.appendChild(roleEl);
892
-
893
- const bubble = document.createElement("div");
894
- bubble.className = "msg-bubble generating";
895
- const dots = document.createElement("span");
896
- dots.className = "thinking-dots";
897
- for (let i = 0; i < 3; i++) dots.appendChild(document.createElement("span"));
898
- bubble.appendChild(dots);
899
- group.appendChild(bubble);
900
-
901
- $messages.appendChild(group);
902
- $messages.scrollTop = $messages.scrollHeight;
903
-
904
- return { groupEl: group, bubbleEl: bubble };
905
- }
906
-
907
- /* ─── WebGPU check ─── */
908
- (async () => {
909
- if (!navigator.gpu) {
910
- showToast("WebGPU not available in this browser", "error");
911
- $btnLoad.disabled = true;
912
- return;
913
- }
914
- try {
915
- const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
916
- if (!adapter) {
917
- showToast("No WebGPU adapter found", "error");
918
- $btnLoad.disabled = true;
919
- }
920
- } catch (e) {
921
- showToast("WebGPU init failed: " + e.message, "error");
922
- }
923
- })();
924
  </script>
925
  </body>
926
  </html>
 
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no,viewport-fit=cover"/>
6
+ <meta name="apple-mobile-web-app-capable" content="yes"/>
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="default"/>
8
+ <meta name="mobile-web-app-capable" content="yes"/>
9
+ <meta name="theme-color" content="#ffffff"/>
10
+ <title>Qwen 3.5 Vision — Local AI Chat</title>
11
  <link rel="preconnect" href="https://fonts.googleapis.com"/>
12
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
13
+ <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"/>
14
+ <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>"/>
15
  <style>
16
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
17
  :root{
18
+ --bg:#ffffff;--bg-warm:#faf9f7;--bg-cool:#f4f2ef;
19
+ --surface:#ffffff;--surface-raised:#fefefe;
20
+ --border:#e8e5e0;--border-light:#f0ede8;
21
+ --text:#1a1815;--text-2:#5c5650;--text-3:#9c958d;--text-4:#c4beb6;
22
+ --accent:#4f6df5;--accent-hover:#3d5ae0;--accent-light:#eef1fe;--accent-text:#3b54c4;
23
+ --green:#2d9d5e;--green-bg:#edf7f0;
24
+ --red:#d44040;--red-bg:#fdf0f0;
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;
32
+ --font-mono:'IBM Plex Mono','SF Mono',monospace;
33
+ --safe-top:env(safe-area-inset-top,0px);
34
+ --safe-bottom:env(safe-area-inset-bottom,0px);
35
  }
36
+ html{font-size:16px;height:100%}
37
+ 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}
38
+ 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 */
44
+ #landing{flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:24px;position:relative;overflow-y:auto;background:var(--bg)}
45
+ .landing-deco{position:absolute;width:min(500px,90vw);height:min(500px,90vw);border-radius:50%;background:radial-gradient(circle,rgba(79,109,245,.07) 0%,transparent 70%);top:50%;left:50%;transform:translate(-50%,-55%);pointer-events:none}
46
+ .landing-tag{font-family:var(--font-mono);font-size:.68rem;letter-spacing:.12em;text-transform:uppercase;color:var(--accent-text);background:var(--accent-light);padding:6px 16px;border-radius:100px;margin-bottom:24px;font-weight:500}
47
+ .landing-title{font-family:var(--font-display);font-size:clamp(2.4rem,7vw,4.5rem);font-weight:400;line-height:1.1;letter-spacing:-.02em;color:var(--text);margin-bottom:12px}
 
 
 
48
  .landing-title em{font-style:italic;color:var(--accent)}
49
+ .landing-sub{font-size:.95rem;color:var(--text-2);max-width:460px;line-height:1.7;margin-bottom:32px;font-weight:400}
50
+ .landing-chips{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-bottom:32px}
51
+ .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}
52
+ .model-search-wrap{position:relative;width:100%;max-width:440px;margin-bottom:20px}
53
+ .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}
54
+ .model-search-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,109,245,.1)}
55
+ .model-search-input::placeholder{color:var(--text-4)}
56
+ .search-chevron{position:absolute;right:14px;top:50%;transform:translateY(-50%);color:var(--text-4);font-size:.65rem;pointer-events:none;transition:transform .2s}
 
 
57
  .model-search-wrap.open .search-chevron{transform:translateY(-50%) rotate(180deg)}
58
+ .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)}
59
  .model-search-wrap.open .model-dropdown{display:block}
60
+ .model-search-wrap.open .model-search-input{border-radius:var(--radius-sm) var(--radius-sm) 0 0;border-color:var(--accent)}
61
+ .model-item{padding:11px 14px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:8px;border-bottom:1px solid var(--border-light);transition:background .1s;min-height:44px}
62
  .model-item:last-child{border-bottom:none}
63
+ .model-item:hover,.model-item.active{background:var(--accent-light)}
64
+ .model-item-name{font-family:var(--font-mono);font-size:.78rem;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
65
  .model-item-meta{display:flex;gap:5px;align-items:center;flex-shrink:0}
66
+ .size-badge{background:var(--accent-light);color:var(--accent-text);padding:3px 8px;border-radius:6px;font-family:var(--font-mono);font-size:.62rem;font-weight:600}
67
+ .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}
68
+ .model-dropdown-loading{padding:12px 14px;font-size:.75rem;color:var(--text-3);text-align:center}
69
+ .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}
70
+ .btn-load:hover{background:var(--accent-hover);box-shadow:0 4px 20px rgba(79,109,245,.3);transform:translateY(-1px)}
71
+ .btn-load:active{transform:translateY(0)}
72
+ .btn-load:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}
73
+ .landing-footer{margin-top:auto;padding:20px 0;font-family:var(--font-mono);font-size:.65rem;color:var(--text-4)}
74
+ .landing-footer a{color:var(--text-3);text-decoration:none}.landing-footer a:hover{color:var(--accent)}
75
+
76
+ /* LOADING */
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)}}
80
+ .loader-text{font-size:.88rem;color:var(--text-2);font-weight:500;text-align:center}
81
+ .loader-sub{font-size:.75rem;color:var(--text-3);margin-top:4px;text-align:center;line-height:1.5}
82
+ .download-bar{width:min(360px,85vw)}
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
 
87
+ /* CHAT */
88
+ #chat{flex-direction:column;height:100%;background:var(--bg-warm)}
89
+ .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}
90
+ .chat-header-left{display:flex;align-items:center;gap:10px;min-width:0}
91
+ .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}
92
+ .chat-header-info{min-width:0}
93
+ .chat-header-title{font-size:.92rem;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
94
+ .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}
95
+ .chat-header-status::before{content:"";width:5px;height:5px;background:var(--green);border-radius:50%;flex-shrink:0}
96
+ .chat-header-controls{display:flex;align-items:center;gap:6px;flex-shrink:0}
97
+ .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}
98
+ .toggle-pill:has(input:checked){background:var(--accent-light);border-color:var(--accent)}
99
+ .toggle-pill input{display:none}
100
+ .toggle-dot{width:8px;height:8px;border-radius:50%;background:var(--text-4);transition:background .2s;flex-shrink:0}
101
+ .toggle-pill:has(input:checked) .toggle-dot{background:var(--accent)}
102
+ .toggle-lbl{font-size:.65rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);transition:color .2s;white-space:nowrap}
103
+ .toggle-pill:has(input:checked) .toggle-lbl{color:var(--accent-text)}
104
+ .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}
105
+ .btn-icon:hover{background:var(--bg-cool);color:var(--text-2)}
106
+ .btn-icon:active{transform:scale(.95)}
107
+ .btn-icon.active{background:var(--accent-light);color:var(--accent-text);border-color:var(--accent)}
108
+ .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}
109
+ .btn-reset:hover{color:var(--red);border-color:var(--red);background:var(--red-bg)}
110
+
111
+ .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}
112
+ .settings-panel.open{max-height:200px;padding:10px 16px}
113
+ .settings-row{display:flex;align-items:center;gap:5px}
114
+ .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}
115
+ .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}
 
 
 
 
 
 
 
116
  .settings-select:focus{border-color:var(--accent)}
117
+ .settings-slider{width:64px;accent-color:var(--accent);height:3px;cursor:pointer}
118
+ .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}
119
+ .system-prompt-wrap{padding:0 16px;max-height:0;overflow:hidden;transition:max-height .3s,padding .3s;background:var(--bg)}
120
+ .system-prompt-wrap.open{max-height:170px;padding:8px 16px 10px;border-bottom:1px solid var(--border-light)}
121
+ .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}
122
+ .system-prompt-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,109,245,.08)}
123
+ .system-prompt-input::placeholder{color:var(--text-4)}
124
+ .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}
125
+ .stats-bar:empty{display:none}
126
+ .stat{display:flex;align-items:center;gap:4px}
127
+ .stat-label{color:var(--text-4);text-transform:uppercase;letter-spacing:.06em;font-weight:600;font-family:var(--font-mono);font-size:.55rem}
128
+ .stat-value{color:var(--text-2);font-weight:600;font-variant-numeric:tabular-nums;font-family:var(--font-mono);font-size:.68rem}
129
+ .stat-value.hl{color:var(--accent-text)}
130
+ .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}
131
+ .error-banner.visible{display:block}
132
 
133
+ .chat-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:16px;scroll-behavior:smooth;-webkit-overflow-scrolling:touch}
134
+ .msg-group{display:flex;flex-direction:column;gap:3px;max-width:85%;animation:msgIn .25s ease}
 
135
  .msg-group.user{align-self:flex-end}
136
  .msg-group.assistant{align-self:flex-start}
137
+ @keyframes msgIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
138
+ .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}
139
+ .msg-group.assistant .msg-role{color:var(--accent-text)}
140
+ .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)}
141
+ .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)}
142
+ .msg-group.assistant .msg-bubble{background:var(--surface);border:1px solid var(--border-light);border-bottom-left-radius:4px}
143
+ .msg-group.assistant .msg-bubble.generating{border-color:var(--accent);box-shadow:0 0 0 2px rgba(79,109,245,.08)}
144
+ .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)}
145
+
146
+ .msg-bubble.md{white-space:normal}
147
  .msg-bubble.md p{margin:0 0 .5em}.msg-bubble.md p:last-child{margin:0}
148
+ .msg-bubble.md h1,.msg-bubble.md h2,.msg-bubble.md h3{margin:.7em 0 .35em;font-weight:600;line-height:1.3}
149
+ .msg-bubble.md h1{font-size:1.2em}.msg-bubble.md h2{font-size:1.1em}.msg-bubble.md h3{font-size:1em}
150
  .msg-bubble.md h1:first-child,.msg-bubble.md h2:first-child,.msg-bubble.md h3:first-child{margin-top:0}
151
+ .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)}
152
+ .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}
153
+ .msg-bubble.md pre code{padding:0;background:none;color:var(--text);font-size:.78rem}
154
+ .msg-bubble.md ul,.msg-bubble.md ol{margin:.4em 0;padding-left:1.4em}
155
+ .msg-bubble.md li{margin:.15em 0}
156
+ .msg-bubble.md blockquote{margin:.5em 0;padding:8px 12px;border-left:3px solid var(--accent);color:var(--text-2);background:var(--accent-light);border-radius:0 var(--radius-xs) var(--radius-xs) 0}
157
  .msg-bubble.md table{margin:.5em 0;border-collapse:collapse;width:100%;font-size:.82rem}
158
+ .msg-bubble.md th,.msg-bubble.md td{padding:6px 8px;border:1px solid var(--border);text-align:left}
159
+ .msg-bubble.md th{background:var(--bg-cool);font-weight:600;color:var(--text-2);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
160
+ .msg-bubble.md strong{font-weight:600}
161
+ .msg-bubble.md a{color:var(--accent)}
162
+ .msg-bubble.md hr{margin:.7em 0;border:none;border-top:1px solid var(--border-light)}
163
+ .msg-group.user .msg-bubble.md code{background:rgba(255,255,255,.2);color:#fff}
164
+ .msg-group.user .msg-bubble.md pre{background:rgba(0,0,0,.1);border-color:rgba(255,255,255,.15)}
165
+ .msg-group.user .msg-bubble.md pre code{color:#fff}
166
+ .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)}
167
+
168
+ .think-block{margin-bottom:8px;border-left:2.5px solid var(--accent);padding:8px 12px;background:var(--accent-light);border-radius:0 var(--radius-xs) var(--radius-xs) 0}
169
+ .think-toggle{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-family:var(--font-mono);font-size:.6rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--accent-text);min-height:28px}
170
+ .think-toggle:hover{opacity:.75}
171
+ .think-arrow{font-size:.5rem;transition:transform .2s;display:inline-block}
172
  .think-arrow.open{transform:rotate(90deg)}
173
+ .think-content{font-size:.8rem;color:var(--text-2);line-height:1.65;white-space:pre-wrap;word-wrap:break-word;margin-top:6px;overflow:hidden;max-height:50vh;transition:max-height .3s,margin .3s,opacity .2s}
174
  .think-content.collapsed{max-height:0;margin-top:0;opacity:0}
175
+ .msg-stats{font-family:var(--font-mono);font-size:.58rem;color:var(--text-3);margin-top:5px;letter-spacing:.03em}
 
 
 
 
176
  .thinking-dots span{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-right:4px;animation:dot 1.2s ease-in-out infinite}
177
  .thinking-dots span:nth-child(2){animation-delay:.2s}
178
  .thinking-dots span:nth-child(3){animation-delay:.4s}
179
+ @keyframes dot{0%,80%,100%{opacity:.2;transform:scale(.8)}40%{opacity:1;transform:scale(1)}}
180
 
181
+ .chat-input-area{padding:10px 12px;padding-bottom:calc(10px + var(--safe-bottom));border-top:1px solid var(--border-light);background:var(--bg);flex-shrink:0}
182
+ .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)}
 
183
  .image-preview-bar.visible{display:flex}
184
+ .image-preview-thumb{width:44px;height:44px;border-radius:var(--radius-xs);object-fit:cover;border:1px solid var(--border)}
185
+ .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}
186
+ .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}
187
  .btn-remove-image:hover{color:var(--red)}
188
+ .chat-input-row{display:flex;align-items:flex-end;gap:6px}
189
+ .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}
190
+ .btn-attach:hover:not(:disabled){border-color:var(--accent);color:var(--accent);background:var(--accent-light)}
191
+ .btn-attach:active:not(:disabled){transform:scale(.95)}
192
+ .btn-attach:disabled{opacity:.3;cursor:not-allowed}
193
  .input-wrap{flex:1;position:relative}
194
+ .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}
195
+ .input-wrap textarea::placeholder{color:var(--text-4)}
196
+ .input-wrap textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,109,245,.08)}
197
+ .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}
198
+ .btn-send:disabled{opacity:.3;cursor:not-allowed}
199
+ .btn-send:not(:disabled):hover{background:var(--accent-hover);box-shadow:0 2px 8px rgba(79,109,245,.25)}
200
+ .btn-send:not(:disabled):active{transform:scale(.95)}
201
  .btn-send .icon-stop{display:none}
202
  .btn-send.stopping{background:var(--red)}
203
  .btn-send.stopping .icon-send{display:none}
204
  .btn-send.stopping .icon-stop{display:block}
205
+ .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}
206
 
207
+ .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}
208
+ .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}
209
+ .welcome-msg p{font-size:.85rem;line-height:1.65}
210
+
211
+ .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}
212
  .toast.show{transform:translateX(-50%) translateY(0);opacity:1}
213
  .toast.error{border-color:var(--red);color:var(--red)}
214
  .toast.success{border-color:var(--green);color:var(--green)}
215
 
216
+ @media(max-width:640px){
217
+ .landing-specs{gap:16px;flex-wrap:wrap}.chip{font-size:.62rem;padding:4px 10px}
218
+ .landing-sub{font-size:.88rem;padding:0 8px}
219
+ .chat-header{padding:8px 12px;padding-top:calc(8px + var(--safe-top));gap:6px}
220
+ .chat-header-title{font-size:.84rem}
221
+ .chat-header-controls{gap:4px}
222
+ .toggle-pill{padding:4px 8px;min-height:28px}
223
+ .toggle-lbl{font-size:.58rem}
224
+ .btn-icon{width:28px;height:28px;font-size:.7rem}
225
+ .btn-reset{font-size:.58rem;padding:5px 10px;min-height:28px}
226
+ .settings-panel.open{gap:6px 12px;padding:8px 12px}
227
+ .settings-slider{width:56px}
228
+ .chat-messages{padding:12px}
229
+ .msg-group{max-width:90%}
230
+ .msg-bubble{padding:10px 14px;font-size:.85rem}
231
+ .chat-input-area{padding:8px 10px;padding-bottom:calc(8px + var(--safe-bottom))}
232
+ .btn-attach,.btn-send{width:42px;height:42px}
233
+ .input-wrap textarea{min-height:42px;padding:10px 12px;font-size:.86rem}
234
+ .stats-bar{gap:10px;padding:4px 12px}
235
+ .system-prompt-wrap.open{padding:6px 12px 8px}
236
+ .msg-image{max-width:min(180px,55vw)}
237
+ }
238
+ @media(max-width:380px){
239
+ .toggle-lbl{display:none}
240
+ .landing-title{font-size:2rem}
241
+ .chat-header-title{font-size:.78rem;max-width:120px}
242
+ }
243
+ @media(max-height:500px) and (orientation:landscape){
244
+ .chat-header{padding:6px 12px;min-height:44px}
245
+ .chat-avatar{width:28px;height:28px;font-size:.8rem}
246
+ .chat-messages{padding:8px 12px;gap:10px}
247
+ }
248
  </style>
249
  </head>
250
  <body>
 
 
251
  <div id="landing" class="screen active">
252
+ <div class="landing-deco"></div>
253
+ <div class="landing-tag">Local AI · No Server · WebGPU</div>
254
  <h1 class="landing-title">Qwen 3.5 <em>Vision</em></h1>
255
+ <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>
256
+ <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>
 
 
 
 
 
 
 
 
257
  <div class="model-search-wrap" id="modelSearchWrap">
258
+ <input class="model-search-input" id="modelSearchInput" type="text" value="onnx-community/Qwen3.5-0.8B-ONNX" placeholder="Search Qwen3.5 models…" autocomplete="off"/>
 
259
  <span class="search-chevron">▾</span>
260
  <div class="model-dropdown" id="modelDropdown"></div>
261
  </div>
262
+ <button class="btn-load" id="btnLoad">Load Model</button>
263
+ <div class="landing-footer">Built with <a href="https://huggingface.co/docs/transformers.js" target="_blank">Transformers.js</a></div>
 
 
 
 
 
 
 
264
  </div>
 
 
265
  <div id="loading" class="screen">
266
  <div class="loader-ring" id="loaderRing"></div>
267
+ <div><div class="loader-text" id="loaderText">Initializing…</div><div class="loader-sub" id="loaderSub">Weights are cached for future visits.</div></div>
268
+ <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>
 
 
 
 
 
 
269
  </div>
 
 
270
  <div id="chat" class="screen">
271
  <div class="chat-header">
272
+ <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>
 
 
 
 
 
 
273
  <div class="chat-header-controls">
274
+ <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>
 
 
 
 
275
  <button class="btn-icon" id="btnSettings" title="Settings">⚙</button>
276
  <button class="btn-icon" id="btnSysPrompt" title="System prompt">S</button>
277
  <button class="btn-reset" id="btnReset">Reset</button>
278
  </div>
279
  </div>
 
 
280
  <div class="settings-panel" id="settingsPanel">
281
+ <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>
282
+ <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>
283
+ <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>
284
+ <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>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  </div>
286
+ <div class="system-prompt-wrap" id="sysPromptWrap"><textarea class="system-prompt-input" id="sysPromptInput" placeholder="System prompt (optional)…"></textarea></div>
 
 
 
 
 
 
287
  <div class="stats-bar" id="statsBar"></div>
 
288
  <div class="error-banner" id="errorBanner"></div>
289
+ <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>
 
 
 
 
 
 
 
290
  <div class="chat-input-area">
291
+ <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>
 
 
 
 
292
  <div class="chat-input-row">
293
+ <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>
 
 
294
  <input type="file" id="fileInput" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp" hidden/>
295
+ <div class="input-wrap"><textarea id="msgInput" rows="1" placeholder="Type a message…"></textarea></div>
296
+ <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>
 
 
 
 
 
297
  </div>
298
+ <div class="chat-footer-note">100% local no data leaves your device. AI can make mistakes.</div>
299
  </div>
300
  </div>
 
301
  <div class="toast" id="toast"></div>
 
 
302
  <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script>
 
303
  <script type="module">
304
+ import{AutoProcessor,Qwen3_5ForConditionalGeneration,RawImage,TextStreamer,InterruptableStoppingCriteria}from"https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.0.0-next.6";
305
+ let processor=null,model=null,conversationImage=null,attachedImage=null,isGenerating=false;
306
+ let pastKeyValues=null,imageGridThw=null,promptHistory="";
307
+ const stoppingCriteria=new InterruptableStoppingCriteria();
308
+ let totalTokens=0,totalTime=0,sessionMsgCount=0;
309
+ const getTemp=()=>parseInt($("tempSlider").value)/100;
310
+ const getTopK=()=>parseInt($("topkSlider").value);
311
+ const getMaxTok=()=>parseInt($("maxTokSelect").value);
312
+ const getRepPen=()=>parseInt($("repPenSlider").value)/100;
313
+ const getSysPrompt=()=>$("sysPromptInput").value.trim();
314
+ document.fonts.ready.then(()=>document.body.classList.add("ready"));
315
+ const $=id=>document.getElementById(id);
316
+ 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($);
317
+ let toastTimer=null;
318
+ function showToast(m,t=""){clearTimeout(toastTimer);$toast.textContent=m;$toast.className="toast "+t+" show";toastTimer=setTimeout(()=>$toast.classList.remove("show"),3000)}
319
+ 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}
320
+ 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"}];
321
+ let searchTimer=null;
322
+ 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()})})}
323
+ function openDropdown(){$searchWrap.classList.add("open");if(!$dropdown.innerHTML)renderDropdown(PRESET_MODELS)}
324
+ function closeDropdown(){$searchWrap.classList.remove("open")}
325
+ $searchInput.addEventListener("focus",()=>{openDropdown();renderDropdown(PRESET_MODELS)});
326
+ $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)});
327
+ document.addEventListener("click",e=>{if(!e.target.closest(".model-search-wrap"))closeDropdown()});
328
+ $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()}});
329
+ $("btnSettings").addEventListener("click",()=>{$("settingsPanel").classList.toggle("open");$("btnSettings").classList.toggle("active")});
330
+ $("btnSysPrompt").addEventListener("click",()=>{$("sysPromptWrap").classList.toggle("open");$("btnSysPrompt").classList.toggle("active")});
331
+ $("tempSlider").addEventListener("input",()=>$("tempVal").textContent=getTemp().toFixed(2));
332
+ $("topkSlider").addEventListener("input",()=>$("topkVal").textContent=getTopK());
333
+ $("repPenSlider").addEventListener("input",()=>$("repPenVal").textContent=getRepPen().toFixed(2));
334
+ function showScreen(id){document.querySelectorAll(".screen").forEach(s=>s.classList.toggle("active",s.id===id))}
335
+ $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)"}});
336
+ $btnAttach.addEventListener("click",()=>{if(!$btnAttach.disabled)$fileInput.click()});
337
+ $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=""});
338
+ $btnRemImg.addEventListener("click",clearAttachment);
339
+ function clearAttachment(){if(attachedImage?.dataURL)URL.revokeObjectURL(attachedImage.dataURL);attachedImage=null;$imgPrev.classList.remove("visible");$imgThumb.src="";$imgName.textContent="";updateSendBtn()}
340
+ $input.addEventListener("input",()=>{$input.style.height="auto";$input.style.height=Math.min($input.scrollHeight,120)+"px";updateSendBtn()});
341
+ $input.addEventListener("keydown",e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();if(!isGenerating)sendMessage()}});
342
+ $btnSend.addEventListener("click",()=>{if(isGenerating)stoppingCriteria.interrupt();else sendMessage()});
343
+ function updateSendBtn(){if(isGenerating){$btnSend.disabled=false;$btnSend.classList.add("stopping")}else{$btnSend.classList.remove("stopping");$btnSend.disabled=!$input.value.trim()&&!attachedImage}}
344
+ function disposePastKeyValues(){if(pastKeyValues){for(const t of Object.values(pastKeyValues))t.dispose();pastKeyValues=null}}
345
+ $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}}});
346
+ $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()});
347
+ 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)}}
348
+ function escapeHtml(s){return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}
349
+ 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}
350
+ 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}
351
+ 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}}
352
+ window.visualViewport?.addEventListener("resize",()=>{$messages.scrollTop=$messages.scrollHeight});
353
+ (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")}})();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  </script>
355
  </body>
356
  </html>