lalopenguin commited on
Commit
c8137c7
Β·
verified Β·
1 Parent(s): ef4d736

Kimi K3 browser console: streaming, preserved reasoning, vision

Browse files
Files changed (2) hide show
  1. README.md +60 -5
  2. index.html +678 -17
README.md CHANGED
@@ -1,10 +1,65 @@
1
  ---
2
- title: Kimi K3 Chat
3
- emoji: πŸ“ˆ
4
- colorFrom: red
5
- colorTo: green
6
  sdk: static
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Kimi K3 Console
3
+ emoji: πŸŒ™
4
+ colorFrom: indigo
5
+ colorTo: blue
6
  sdk: static
7
+ app_file: index.html
8
  pinned: false
9
+ short_description: Chat with Kimi K3 β€” 2.8T MoE, vision, 1M context
10
  ---
11
 
12
+ # πŸŒ™ Kimi K3 Console
13
+
14
+ A browser-only chat client for [moonshotai/Kimi-K3](https://huggingface.co/moonshotai/Kimi-K3).
15
+
16
+ K3 is a 2.8T-parameter MoE (104B active per token) with native vision and a
17
+ 1M-token context window. Serving those weights takes a multi-node cluster, so
18
+ this Space doesn't try β€” it calls a hosted API straight from your browser.
19
+ No backend, no GPU, and your API key never leaves your machine.
20
+
21
+ ## Getting a key
22
+
23
+ Pick either provider in the settings panel. Both are OpenAI-compatible and both
24
+ send CORS headers, which is what makes the browser-direct approach work.
25
+
26
+ | Provider | Endpoint | Model id | Cost |
27
+ |---|---|---|---|
28
+ | Moonshot (official) | `https://api.moonshot.ai/v1` | `kimi-k3` | $1 minimum top-up |
29
+ | OpenRouter | `https://openrouter.ai/api/v1` | `moonshotai/kimi-k3` | ~$3 / M in, $15 / M out |
30
+
31
+ Neither has a free tier. Keys are held in memory unless you tick
32
+ **Remember on this device**, which stores it in `localStorage` β€” your browser
33
+ only, never transmitted anywhere except to the provider you chose.
34
+
35
+ ## Two K3-specific details this client handles
36
+
37
+ **Preserved thinking history.** K3 always reasons, and it was trained expecting
38
+ the complete assistant message β€” `reasoning_content` included β€” to be passed
39
+ back verbatim on the next turn. A wrapper that keeps only `content` quietly
40
+ degrades multi-turn quality. This client keeps the wire history separate from
41
+ what it renders, so the trace survives the round trip.
42
+
43
+ To watch it work, send *"Tell me three random numbers"*, then ask which ones it
44
+ held back. The follow-up only resolves if the model can still see its own
45
+ scratchpad.
46
+
47
+ **Base64 vision input.** K3 rejects public image URLs, so attachments are
48
+ inlined as `data:` URLs. Paste or drop images straight into the composer.
49
+
50
+ Thinking effort is exposed as `low` / `high` / `max`. There is no "off".
51
+
52
+ ## The ribbon
53
+
54
+ The band under the header is 896 ticks with exactly 16 lit β€” K3's real routing
55
+ sparsity β€” reshuffling while tokens stream. It's the generating indicator.
56
+
57
+ ## Running locally
58
+
59
+ It's one file with no build step and no dependencies:
60
+
61
+ ```bash
62
+ python3 -m http.server 8000
63
+ ```
64
+
65
+ Then open <http://localhost:8000>.
index.html CHANGED
@@ -1,19 +1,680 @@
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">
6
+ <title>Kimi K3 β€” console</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=Fraunces:opsz,wght@9..144,400;9..144,600&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root{
12
+ --void:#0B0E17; /* night sky */
13
+ --panel:#131826; /* raised surface */
14
+ --line:#232B40; /* hairline */
15
+ --silver:#C8D2E4; /* moonlight β€” primary text */
16
+ --dim:#6B7793; /* secondary */
17
+ --lamp:#E8A33D; /* observatory lamp β€” reasoning only */
18
+ --ice:#7FB2D9; /* cool accent β€” the human's voice */
19
+ --sans:"IBM Plex Sans",system-ui,sans-serif;
20
+ --mono:"IBM Plex Mono",ui-monospace,monospace;
21
+ --display:"Fraunces",Georgia,serif;
22
+ }
23
+ *{box-sizing:border-box}
24
+ html,body{height:100%}
25
+ body{
26
+ margin:0;background:var(--void);color:var(--silver);
27
+ font:400 15px/1.6 var(--sans);
28
+ display:flex;flex-direction:column;
29
+ -webkit-font-smoothing:antialiased;
30
+ }
31
+
32
+ /* ── header ─────────────────────────────────────────── */
33
+ header{border-bottom:1px solid var(--line);flex:none}
34
+ .bar{
35
+ display:flex;align-items:baseline;gap:18px;flex-wrap:wrap;
36
+ padding:14px 22px 10px;
37
+ }
38
+ .mark{
39
+ font:400 25px/1 var(--display);letter-spacing:.01em;
40
+ font-variation-settings:"opsz" 120;
41
+ }
42
+ .mark b{font-weight:600}
43
+ .moon{color:var(--lamp)}
44
+ .spec{
45
+ font:400 11.5px/1 var(--mono);color:var(--dim);
46
+ letter-spacing:.08em;text-transform:uppercase;
47
+ }
48
+ .spec i{color:var(--silver);font-style:normal}
49
+ .spec span{margin-right:14px;white-space:nowrap}
50
+ header a{color:var(--dim);text-decoration:none;border-bottom:1px solid var(--line)}
51
+ header a:hover{color:var(--ice);border-color:var(--ice)}
52
+ .grow{flex:1}
53
+
54
+ /* signature: 896 experts, 16 lit */
55
+ .ribbon{position:relative;height:26px}
56
+ #experts{display:block;width:100%;height:26px}
57
+ .ribbon-key{
58
+ position:absolute;right:22px;top:50%;transform:translateY(-50%);
59
+ font:400 10px/1 var(--mono);color:var(--dim);letter-spacing:.09em;
60
+ background:linear-gradient(90deg,transparent,var(--void) 22%);
61
+ padding-left:26px;pointer-events:none;
62
+ }
63
+
64
+ /* ── layout ─────────────────────────────────────────── */
65
+ main{flex:1;display:flex;min-height:0}
66
+ .thread{flex:1;display:flex;flex-direction:column;min-width:0}
67
+ .scroll{flex:1;overflow-y:auto;padding:26px 22px 8px;scroll-behavior:smooth}
68
+ .inner{max-width:760px;margin:0 auto}
69
+
70
+ /* ── empty state ────────────────────────────────────── */
71
+ .empty{padding:34px 0 10px}
72
+ .empty h1{
73
+ font:400 30px/1.25 var(--display);margin:0 0 10px;
74
+ font-variation-settings:"opsz" 144;
75
+ }
76
+ .empty p{color:var(--dim);margin:0 0 26px;max-width:52ch}
77
+ .seeds{display:flex;flex-direction:column;gap:8px}
78
+ .seed{
79
+ text-align:left;background:transparent;color:var(--silver);
80
+ border:1px solid var(--line);border-radius:2px;
81
+ padding:11px 14px;font:400 14px/1.45 var(--sans);cursor:pointer;
82
+ display:flex;gap:12px;align-items:baseline;transition:border-color .15s,background .15s;
83
+ }
84
+ .seed:hover{border-color:var(--ice);background:var(--panel)}
85
+ .seed em{
86
+ font:400 10px/1 var(--mono);color:var(--dim);font-style:normal;
87
+ letter-spacing:.09em;text-transform:uppercase;flex:none;padding-top:3px;
88
+ }
89
+
90
+ /* ── messages ───────────────────────────────────────── */
91
+ .msg{margin-bottom:26px}
92
+ .who{
93
+ font:500 10px/1 var(--mono);letter-spacing:.13em;text-transform:uppercase;
94
+ color:var(--dim);margin-bottom:9px;
95
+ }
96
+ .msg.user .who{color:var(--ice)}
97
+ .msg.user .body{border-left:2px solid var(--ice);padding-left:14px}
98
+ .body{overflow-wrap:anywhere}
99
+ .body p{margin:0 0 .85em}
100
+ .body p:last-child{margin-bottom:0}
101
+ .body pre{
102
+ background:var(--panel);border:1px solid var(--line);border-radius:2px;
103
+ padding:13px 15px;overflow-x:auto;margin:.85em 0;
104
+ }
105
+ .body code{font:400 13px/1.6 var(--mono)}
106
+ .body :not(pre)>code{
107
+ background:var(--panel);border:1px solid var(--line);
108
+ padding:1px 5px;border-radius:2px;font-size:12.5px;
109
+ }
110
+ .body img{max-width:260px;border:1px solid var(--line);border-radius:2px;display:block;margin:.5em 0}
111
+ .body a{color:var(--ice)}
112
+
113
+ /* thinking trace β€” the only amber on the page */
114
+ details.think{margin-bottom:12px;border-left:2px solid var(--lamp);padding-left:14px}
115
+ details.think>summary{
116
+ font:500 10px/1 var(--mono);letter-spacing:.13em;text-transform:uppercase;
117
+ color:var(--lamp);cursor:pointer;list-style:none;padding:2px 0;
118
+ }
119
+ details.think>summary::-webkit-details-marker{display:none}
120
+ details.think>summary::before{content:"β–Έ ";display:inline-block;transition:transform .15s}
121
+ details.think[open]>summary::before{content:"β–Ύ "}
122
+ .trace{
123
+ font:400 13px/1.65 var(--mono);color:#A89170;
124
+ white-space:pre-wrap;padding:10px 0 2px;
125
+ }
126
+ .err{border-left:2px solid #C4574C;padding-left:14px;color:#E09A93}
127
+ .err code{font-size:12.5px}
128
+
129
+ /* ── composer ───────────────────────────────────────── */
130
+ .composer{flex:none;border-top:1px solid var(--line);padding:14px 22px 18px}
131
+ .cwrap{max-width:760px;margin:0 auto}
132
+ .tray{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:9px}
133
+ .chip{
134
+ display:flex;align-items:center;gap:8px;background:var(--panel);
135
+ border:1px solid var(--line);border-radius:2px;padding:5px 9px;
136
+ font:400 11px/1 var(--mono);color:var(--dim);
137
+ }
138
+ .chip img{height:24px;width:24px;object-fit:cover;border-radius:1px}
139
+ .chip button{background:none;border:0;color:var(--dim);cursor:pointer;font-size:14px;line-height:1;padding:0}
140
+ .chip button:hover{color:var(--lamp)}
141
+ .row{display:flex;gap:9px;align-items:flex-end}
142
+ textarea{
143
+ flex:1;resize:none;background:var(--panel);color:var(--silver);
144
+ border:1px solid var(--line);border-radius:2px;padding:11px 13px;
145
+ font:400 15px/1.5 var(--sans);min-height:46px;max-height:190px;
146
+ }
147
+ textarea:focus,input:focus,select:focus,button:focus-visible{
148
+ outline:2px solid var(--ice);outline-offset:1px;border-color:transparent;
149
+ }
150
+ .icon{
151
+ flex:none;width:46px;height:46px;background:var(--panel);border:1px solid var(--line);
152
+ border-radius:2px;color:var(--dim);cursor:pointer;font-size:19px;
153
+ }
154
+ .icon:hover{color:var(--silver);border-color:var(--dim)}
155
+ #send{
156
+ flex:none;height:46px;padding:0 22px;background:var(--silver);color:var(--void);
157
+ border:0;border-radius:2px;cursor:pointer;
158
+ font:600 13px/1 var(--sans);letter-spacing:.03em;
159
+ }
160
+ #send:hover{background:#fff}
161
+ #send[disabled]{background:var(--line);color:var(--dim);cursor:default}
162
+ .hint{font:400 11px/1 var(--mono);color:var(--dim);margin-top:9px;letter-spacing:.04em}
163
+
164
+ /* ── settings rail ──────────────────────────────────── */
165
+ aside{
166
+ flex:none;width:290px;border-left:1px solid var(--line);
167
+ padding:22px;overflow-y:auto;background:#0E1220;
168
+ }
169
+ aside h2{
170
+ font:500 10px/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;
171
+ color:var(--dim);margin:0 0 18px;
172
+ }
173
+ label{display:block;margin-bottom:17px}
174
+ .lab{
175
+ display:block;font:500 10px/1 var(--mono);letter-spacing:.11em;
176
+ text-transform:uppercase;color:var(--dim);margin-bottom:7px;
177
+ }
178
+ input[type=text],input[type=password],select,aside textarea{
179
+ width:100%;background:var(--void);color:var(--silver);
180
+ border:1px solid var(--line);border-radius:2px;padding:8px 10px;
181
+ font:400 13px/1.4 var(--sans);
182
+ }
183
+ aside textarea{min-height:62px;max-height:62px;resize:vertical}
184
+ .note{font:400 11px/1.5 var(--sans);color:var(--dim);margin-top:6px}
185
+ .note a{color:var(--ice)}
186
+ .segs{display:flex;border:1px solid var(--line);border-radius:2px;overflow:hidden}
187
+ .segs button{
188
+ flex:1;background:var(--void);color:var(--dim);border:0;padding:8px 0;cursor:pointer;
189
+ font:500 11px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;
190
+ }
191
+ .segs button[aria-pressed=true]{background:var(--panel);color:var(--lamp)}
192
+ .slide{display:flex;align-items:center;gap:10px}
193
+ input[type=range]{flex:1;accent-color:var(--ice);background:transparent}
194
+ .val{font:400 12px/1 var(--mono);color:var(--silver);min-width:44px;text-align:right}
195
+ .remember{display:flex;align-items:center;gap:8px;margin-top:-9px;margin-bottom:17px}
196
+ .remember input{accent-color:var(--ice)}
197
+ .remember span{font:400 11.5px/1.4 var(--sans);color:var(--dim)}
198
+ .railfoot{border-top:1px solid var(--line);padding-top:15px;margin-top:4px}
199
+ #reset{
200
+ width:100%;background:transparent;border:1px solid var(--line);color:var(--dim);
201
+ border-radius:2px;padding:9px 0;cursor:pointer;font:500 11px/1 var(--mono);
202
+ letter-spacing:.09em;text-transform:uppercase;
203
+ }
204
+ #reset:hover{border-color:#C4574C;color:#C4574C}
205
+
206
+ /* ── responsive ─────────────────────────────────────── */
207
+ .rail-toggle{display:none}
208
+ @media (max-width:860px){
209
+ main{flex-direction:column}
210
+ aside{
211
+ width:auto;border-left:0;border-top:1px solid var(--line);
212
+ max-height:56vh;
213
+ }
214
+ aside[hidden]{display:none}
215
+ .rail-toggle{
216
+ display:block;background:none;border:1px solid var(--line);color:var(--dim);
217
+ border-radius:2px;padding:6px 11px;cursor:pointer;
218
+ font:500 10px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;
219
+ }
220
+ .bar{padding:12px 16px 9px}
221
+ .scroll,.composer{padding-left:16px;padding-right:16px}
222
+ .ribbon-key{display:none}
223
+ }
224
+ @media (prefers-reduced-motion:reduce){
225
+ *{animation:none!important;transition:none!important}
226
+ .scroll{scroll-behavior:auto}
227
+ }
228
+ </style>
229
+ </head>
230
+ <body>
231
+
232
+ <header>
233
+ <div class="bar">
234
+ <div class="mark"><span class="moon">☾</span> Kimi <b>K3</b></div>
235
+ <div class="spec">
236
+ <span><i>2.8T</i> total</span><span><i>104B</i> active</span><span><i>896</i> experts</span><span><i>1M</i> context</span>
237
+ </div>
238
+ <div class="grow"></div>
239
+ <button class="rail-toggle" id="railToggle" aria-expanded="true">Settings</button>
240
+ <div class="spec"><a href="https://huggingface.co/moonshotai/Kimi-K3" target="_blank" rel="noopener">Model card β†—</a></div>
241
+ </div>
242
+ <div class="ribbon">
243
+ <canvas id="experts" aria-hidden="true"></canvas>
244
+ <div class="ribbon-key" id="ribbonKey">idle</div>
245
+ </div>
246
+ </header>
247
+
248
+ <main>
249
+ <div class="thread">
250
+ <div class="scroll" id="scroll">
251
+ <div class="inner" id="inner">
252
+ <div class="empty" id="empty">
253
+ <h1>A frontier model, minus the cluster.</h1>
254
+ <p>K3 activates 16 of its 896 experts per token and thinks before every answer β€” you'll see the trace. Add an API key on the right to start.</p>
255
+ <div class="seeds">
256
+ <button class="seed" data-seed="Explain Kimi Delta Attention to someone who has read the original Transformer paper but nothing since."><em>explain</em><span>Explain Kimi Delta Attention to someone who read the Transformer paper and nothing since.</span></button>
257
+ <button class="seed" data-seed="Tell me three random numbers."><em>memory</em><span>β€œTell me three random numbers.” Then ask which ones it held back β€” that only works if its reasoning survived the turn.</span></button>
258
+ <button class="seed" data-seed="Write a Python script that finds the largest file in a directory tree. Then critique your own solution and fix what you find."><em>code</em><span>Write a script to find the largest file in a tree, then critique and fix it.</span></button>
259
+ </div>
260
+ </div>
261
+ </div>
262
+ </div>
263
+
264
+ <div class="composer">
265
+ <div class="cwrap">
266
+ <div class="tray" id="tray"></div>
267
+ <div class="row">
268
+ <button class="icon" id="attach" title="Attach an image" aria-label="Attach an image">οΌ‹</button>
269
+ <input type="file" id="file" accept="image/*" multiple hidden>
270
+ <textarea id="input" rows="1" placeholder="Ask K3 something…" aria-label="Message"></textarea>
271
+ <button id="send">Send</button>
272
+ </div>
273
+ <div class="hint">Enter sends Β· Shift+Enter for a new line</div>
274
+ </div>
275
+ </div>
276
+ </div>
277
+
278
+ <aside id="rail">
279
+ <h2>Connection</h2>
280
+
281
+ <label>
282
+ <span class="lab">Provider</span>
283
+ <select id="provider">
284
+ <option value="moonshot">Moonshot β€” official</option>
285
+ <option value="openrouter">OpenRouter</option>
286
+ </select>
287
+ </label>
288
+
289
+ <label>
290
+ <span class="lab">API key</span>
291
+ <input type="password" id="key" placeholder="sk-…" autocomplete="off" spellcheck="false">
292
+ <span class="note" id="keyNote"></span>
293
+ </label>
294
+ <div class="remember">
295
+ <input type="checkbox" id="remember">
296
+ <span>Remember on this device</span>
297
+ </div>
298
+
299
+ <h2>Generation</h2>
300
+
301
+ <label>
302
+ <span class="lab">Thinking effort</span>
303
+ <div class="segs" id="effort" role="group" aria-label="Thinking effort">
304
+ <button data-v="low" aria-pressed="false">Low</button>
305
+ <button data-v="high" aria-pressed="true">High</button>
306
+ <button data-v="max" aria-pressed="false">Max</button>
307
+ </div>
308
+ <span class="note">K3 always reasons. There is no off.</span>
309
+ </label>
310
+
311
+ <label>
312
+ <span class="lab">System prompt</span>
313
+ <textarea id="system" placeholder="Optional"></textarea>
314
+ </label>
315
+
316
+ <label>
317
+ <span class="lab">Max tokens</span>
318
+ <div class="slide">
319
+ <input type="range" id="maxTok" min="256" max="32768" step="256" value="4096">
320
+ <span class="val" id="maxTokVal">4096</span>
321
+ </div>
322
+ </label>
323
+
324
+ <label>
325
+ <span class="lab">Temperature</span>
326
+ <div class="slide">
327
+ <input type="range" id="temp" min="0" max="2" step="0.1" value="0.6">
328
+ <span class="val" id="tempVal">0.6</span>
329
+ </div>
330
+ </label>
331
+
332
+ <div class="railfoot">
333
+ <button id="reset">Clear conversation</button>
334
+ </div>
335
+ </aside>
336
+ </main>
337
+
338
+ <script>
339
+ "use strict";
340
+
341
+ /* ── providers ──────────────────────────────────────────────────────────
342
+ Both are OpenAI-compatible and both send CORS headers, so the browser
343
+ can call them directly. No server, no key ever leaving this machine. */
344
+ const PROVIDERS = {
345
+ moonshot:{
346
+ label:"Moonshot", url:"https://api.moonshot.ai/v1/chat/completions",
347
+ model:"kimi-k3", console:"https://platform.kimi.ai",
348
+ note:'Needs a $1 minimum top-up. Get a key at <a href="https://platform.kimi.ai" target="_blank" rel="noopener">platform.kimi.ai</a>.'
349
+ },
350
+ openrouter:{
351
+ label:"OpenRouter", url:"https://openrouter.ai/api/v1/chat/completions",
352
+ model:"moonshotai/kimi-k3", console:"https://openrouter.ai/keys",
353
+ note:'About $3 / M in, $15 / M out. Get a key at <a href="https://openrouter.ai/keys" target="_blank" rel="noopener">openrouter.ai/keys</a>.'
354
+ }
355
+ };
356
+
357
+ const $ = id => document.getElementById(id);
358
+ const els = {
359
+ scroll:$("scroll"), inner:$("inner"), empty:$("empty"), tray:$("tray"),
360
+ input:$("input"), send:$("send"), file:$("file"), key:$("key"),
361
+ provider:$("provider"), system:$("system"), keyNote:$("keyNote"),
362
+ ribbonKey:$("ribbonKey"), rail:$("rail")
363
+ };
364
+
365
+ /* Wire-format history. Assistant turns keep `reasoning_content`, because K3
366
+ was trained in preserved-thinking mode and expects its own scratchpad
367
+ handed back verbatim on the following turn. */
368
+ let history = [];
369
+ let pending = []; // staged image attachments, as data URLs
370
+ let busy = false;
371
+ let effort = "high";
372
+
373
+ /* ── signature: the expert-activation ribbon ────────────────────────────
374
+ 896 ticks, 16 lit. That ratio is the model's actual routing sparsity,
375
+ and the reshuffle doubles as the generating indicator. */
376
+ const EXPERTS = 896, ACTIVE = 16;
377
+ const cv = $("experts"), cx = cv.getContext("2d");
378
+ const calm = matchMedia("(prefers-reduced-motion: reduce)").matches;
379
+ let lit = new Set(), ribbonOn = false, lastShuffle = 0;
380
+
381
+ function reseed(){
382
+ lit = new Set();
383
+ while(lit.size < ACTIVE) lit.add(Math.floor(Math.random()*EXPERTS));
384
+ }
385
+ function sizeRibbon(){
386
+ const dpr = devicePixelRatio || 1;
387
+ cv.width = cv.clientWidth*dpr; cv.height = 26*dpr;
388
+ cx.setTransform(dpr,0,0,dpr,0,0);
389
+ drawRibbon();
390
+ }
391
+ function drawRibbon(){
392
+ const w = cv.clientWidth, h = 26, step = w/EXPERTS;
393
+ cx.clearRect(0,0,w,h);
394
+ for(let i=0;i<EXPERTS;i++){
395
+ const on = ribbonOn && lit.has(i);
396
+ cx.fillStyle = on ? "#E8A33D" : "#1C2336";
397
+ const bh = on ? 14 : 6;
398
+ cx.fillRect(i*step, (h-bh)/2, Math.max(step*0.55,0.6), bh);
399
+ }
400
+ }
401
+ function tick(now){
402
+ if(ribbonOn && now-lastShuffle > 110){ reseed(); drawRibbon(); lastShuffle = now; }
403
+ requestAnimationFrame(tick);
404
+ }
405
+ function setRibbon(on){
406
+ ribbonOn = on;
407
+ els.ribbonKey.textContent = on ? "16 / 896 routing" : "idle";
408
+ if(on && calm) reseed();
409
+ drawRibbon();
410
+ }
411
+ reseed(); sizeRibbon(); setRibbon(false);
412
+ addEventListener("resize", sizeRibbon);
413
+ if(!calm) requestAnimationFrame(tick);
414
+
415
+ /* ── tiny markdown renderer ─────────────────────────────────────────────
416
+ Everything is escaped first, so this is XSS-safe by construction. */
417
+ const esc = s => s.replace(/[&<>"']/g, c => (
418
+ {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]
419
+ ));
420
+ function md(src){
421
+ const blocks = [];
422
+ let t = esc(src).replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
423
+ blocks.push(`<pre><code>${code.replace(/\n$/,"")}</code></pre>`);
424
+ return `${blocks.length-1}`;
425
+ });
426
+ t = t.replace(/`([^`\n]+)`/g, "<code>$1</code>")
427
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
428
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
429
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
430
+ '<a href="$2" target="_blank" rel="noopener">$1</a>');
431
+ t = t.split(/\n{2,}/)
432
+ .filter(p => p.trim())
433
+ .map(p => `<p>${p.replace(/\n/g,"<br>")}</p>`).join("");
434
+ return t.replace(/(\d+)/g, (_, i) => blocks[i])
435
+ // <pre> is not valid inside <p>; unwrap the ones standing alone.
436
+ .replace(/<p>(<pre>[\s\S]*?<\/pre>)<\/p>/g, "$1");
437
+ }
438
+
439
+ /* ── transcript ─────────────────────────────────────────────────────── */
440
+ function stick(){ els.scroll.scrollTop = els.scroll.scrollHeight; }
441
+
442
+ function addUser(text, images){
443
+ els.empty?.remove();
444
+ const el = document.createElement("div");
445
+ el.className = "msg user";
446
+ el.innerHTML = `<div class="who">You</div><div class="body">
447
+ ${images.map(s=>`<img src="${s}" alt="Attached image">`).join("")}
448
+ ${text ? md(text) : ""}</div>`;
449
+ els.inner.appendChild(el); stick();
450
+ }
451
+
452
+ function addAssistant(){
453
+ els.empty?.remove();
454
+ const el = document.createElement("div");
455
+ el.className = "msg";
456
+ el.innerHTML = `<div class="who">Kimi K3</div>
457
+ <details class="think" hidden open><summary>Thinking</summary><div class="trace"></div></details>
458
+ <div class="body"></div>`;
459
+ els.inner.appendChild(el); stick();
460
+ return {
461
+ root:el,
462
+ think:el.querySelector("details"),
463
+ trace:el.querySelector(".trace"),
464
+ body:el.querySelector(".body")
465
+ };
466
+ }
467
+
468
+ function fail(node, title, detail){
469
+ node.think.hidden = true;
470
+ node.body.className = "body err";
471
+ node.body.innerHTML = `<p><strong>${esc(title)}</strong></p><pre><code>${esc(detail)}</code></pre>`;
472
+ stick();
473
+ }
474
+
475
+ /* ── request ────────────────────────────────────────────────────────── */
476
+ function buildUserMessage(text, images){
477
+ if(!images.length) return {role:"user", content:text};
478
+ return {
479
+ role:"user",
480
+ content:[
481
+ // The card puts images ahead of the text; K3 rejects public URLs, so
482
+ // these are inlined as data URLs.
483
+ ...images.map(url => ({type:"image_url", image_url:{url}})),
484
+ {type:"text", text: text || "Describe this image."}
485
+ ]
486
+ };
487
+ }
488
+
489
+ async function send(){
490
+ if(busy) return;
491
+ const text = els.input.value.trim();
492
+ const images = pending.slice();
493
+ if(!text && !images.length) return;
494
+
495
+ const pid = els.provider.value, P = PROVIDERS[pid];
496
+ const key = els.key.value.trim();
497
+
498
+ addUser(text, images);
499
+ els.input.value = ""; els.input.style.height = "auto";
500
+ pending = []; renderTray();
501
+
502
+ const node = addAssistant();
503
+ if(!key){
504
+ fail(node, "No API key", `Add a ${P.label} key in the settings panel, then send again.`);
505
+ els.key.focus();
506
+ return;
507
+ }
508
+
509
+ history.push(buildUserMessage(text, images));
510
+
511
+ const messages = history.slice();
512
+ const sys = els.system.value.trim();
513
+ if(sys) messages.unshift({role:"system", content:sys});
514
+
515
+ const payload = {
516
+ model:P.model, messages, stream:true,
517
+ max_tokens:+$("maxTok").value, temperature:+$("temp").value
518
+ };
519
+ // Moonshot takes the OpenAI-style flat field; OpenRouter takes an object.
520
+ if(pid === "openrouter") payload.reasoning = {effort};
521
+ else payload.reasoning_effort = effort;
522
+
523
+ const headers = {"Content-Type":"application/json", "Authorization":`Bearer ${key}`};
524
+ if(pid === "openrouter"){
525
+ headers["HTTP-Referer"] = location.origin;
526
+ headers["X-Title"] = "Kimi K3 console";
527
+ }
528
+
529
+ busy = true; els.send.disabled = true; els.send.textContent = "…"; setRibbon(true);
530
+
531
+ let reasoning = "", answer = "";
532
+ try{
533
+ const res = await fetch(P.url, {method:"POST", headers, body:JSON.stringify(payload)});
534
+ if(!res.ok){
535
+ const raw = await res.text();
536
+ let msg = raw;
537
+ try{ msg = JSON.stringify(JSON.parse(raw).error ?? JSON.parse(raw), null, 2); }catch{}
538
+ history.pop();
539
+ fail(node, `${P.label} returned ${res.status}`, msg.slice(0,1200));
540
+ return;
541
+ }
542
+
543
+ const reader = res.body.getReader(), dec = new TextDecoder();
544
+ let buf = "";
545
+ for(;;){
546
+ const {done, value} = await reader.read();
547
+ if(done) break;
548
+ buf += dec.decode(value, {stream:true});
549
+ const lines = buf.split("\n");
550
+ buf = lines.pop();
551
+ for(const line of lines){
552
+ if(!line.startsWith("data:")) continue;
553
+ const data = line.slice(5).trim();
554
+ if(!data || data === "[DONE]") continue;
555
+ let j; try{ j = JSON.parse(data); }catch{ continue; }
556
+ const d = j.choices?.[0]?.delta; if(!d) continue;
557
+
558
+ // Moonshot streams `reasoning_content`; OpenRouter normalises to `reasoning`.
559
+ const r = d.reasoning_content ?? d.reasoning;
560
+ if(r){
561
+ reasoning += r;
562
+ node.think.hidden = false;
563
+ node.trace.textContent = reasoning;
564
+ stick();
565
+ }
566
+ if(d.content){
567
+ if(!answer) node.think.open = false; // collapse once the answer starts
568
+ answer += d.content;
569
+ node.body.innerHTML = md(answer);
570
+ stick();
571
+ }
572
+ }
573
+ }
574
+
575
+ if(!answer && !reasoning){
576
+ history.pop();
577
+ fail(node, "Empty response", "The stream closed without returning any content.");
578
+ return;
579
+ }
580
+
581
+ // The part a generic wrapper drops β€” and the reason multi-turn works.
582
+ const turn = {role:"assistant", content:answer};
583
+ if(reasoning){ turn.reasoning_content = reasoning; turn.reasoning = reasoning; }
584
+ history.push(turn);
585
+
586
+ }catch(err){
587
+ history.pop();
588
+ fail(node, "Request failed", String(err?.message || err));
589
+ }finally{
590
+ busy = false; els.send.disabled = false; els.send.textContent = "Send"; setRibbon(false);
591
+ if(!node.body.textContent && !node.think.hidden) node.think.open = true;
592
+ }
593
+ }
594
+
595
+ /* ── attachments ────────────────────────────────────────────────────── */
596
+ function renderTray(){
597
+ els.tray.innerHTML = "";
598
+ pending.forEach((src, i) => {
599
+ const c = document.createElement("div");
600
+ c.className = "chip";
601
+ c.innerHTML = `<img src="${src}" alt=""><span>image ${i+1}</span><button aria-label="Remove image ${i+1}">βœ•</button>`;
602
+ c.querySelector("button").onclick = () => { pending.splice(i,1); renderTray(); };
603
+ els.tray.appendChild(c);
604
+ });
605
+ }
606
+ function stage(files){
607
+ [...files].filter(f => f.type.startsWith("image/")).forEach(f => {
608
+ const r = new FileReader();
609
+ r.onload = () => { pending.push(r.result); renderTray(); };
610
+ r.readAsDataURL(f);
611
+ });
612
+ }
613
+
614
+ /* ── wiring ─────────────────────────────────────────────────────────── */
615
+ els.send.onclick = send;
616
+ $("attach").onclick = () => els.file.click();
617
+ els.file.onchange = e => { stage(e.target.files); e.target.value = ""; };
618
+
619
+ els.input.addEventListener("keydown", e => {
620
+ if(e.key === "Enter" && !e.shiftKey){ e.preventDefault(); send(); }
621
+ });
622
+ els.input.addEventListener("input", () => {
623
+ els.input.style.height = "auto";
624
+ els.input.style.height = Math.min(els.input.scrollHeight, 190) + "px";
625
+ });
626
+ els.input.addEventListener("paste", e => {
627
+ const imgs = [...(e.clipboardData?.files || [])].filter(f => f.type.startsWith("image/"));
628
+ if(imgs.length){ e.preventDefault(); stage(imgs); }
629
+ });
630
+
631
+ document.querySelectorAll(".seed").forEach(b => b.onclick = () => {
632
+ els.input.value = b.dataset.seed;
633
+ els.input.dispatchEvent(new Event("input"));
634
+ els.input.focus();
635
+ });
636
+
637
+ $("effort").addEventListener("click", e => {
638
+ const b = e.target.closest("button"); if(!b) return;
639
+ effort = b.dataset.v;
640
+ [...e.currentTarget.children].forEach(x => x.setAttribute("aria-pressed", String(x === b)));
641
+ });
642
+
643
+ for(const [id, out, fmt] of [["maxTok","maxTokVal",v=>v],["temp","tempVal",v=>(+v).toFixed(1)]]){
644
+ $(id).addEventListener("input", e => $(out).textContent = fmt(e.target.value));
645
+ }
646
+
647
+ $("reset").onclick = () => {
648
+ history = []; pending = []; renderTray();
649
+ els.inner.querySelectorAll(".msg").forEach(n => n.remove());
650
+ if(!$("empty")) location.reload();
651
+ };
652
+
653
+ $("railToggle").onclick = e => {
654
+ const hidden = els.rail.hasAttribute("hidden");
655
+ els.rail.toggleAttribute("hidden", !hidden);
656
+ e.target.setAttribute("aria-expanded", String(hidden));
657
+ };
658
+
659
+ /* provider note + optional key persistence (opt-in, this device only) */
660
+ function syncProvider(){
661
+ const pid = els.provider.value;
662
+ els.keyNote.innerHTML = PROVIDERS[pid].note;
663
+ const saved = localStorage.getItem("k3key:" + pid);
664
+ if(saved){ els.key.value = saved; $("remember").checked = true; }
665
+ else if($("remember").checked) els.key.value = "";
666
+ }
667
+ function persist(){
668
+ const pid = els.provider.value;
669
+ if($("remember").checked && els.key.value.trim()) localStorage.setItem("k3key:"+pid, els.key.value.trim());
670
+ else localStorage.removeItem("k3key:"+pid);
671
+ }
672
+ els.provider.onchange = syncProvider;
673
+ els.key.oninput = persist;
674
+ $("remember").onchange = persist;
675
+ syncProvider();
676
+
677
+ if(innerWidth <= 860) els.rail.setAttribute("hidden","");
678
+ </script>
679
+ </body>
680
  </html>