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

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +926 -19
index.html CHANGED
@@ -1,19 +1,926 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
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>