akhaliq HF Staff commited on
Commit
09a378c
Β·
1 Parent(s): 8d48e82

feat: MiniMax-M3 multimodal chat with gradio.Server + custom UI

Browse files
Files changed (3) hide show
  1. README.md +20 -4
  2. app.py +77 -0
  3. index.html +1166 -0
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
  title: MiniMax M3
3
- emoji: πŸ‘
4
- colorFrom: indigo
5
- colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.18.0
8
  python_version: '3.13'
@@ -10,4 +10,20 @@ app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: MiniMax M3
3
+ emoji: ✦
4
+ colorFrom: violet
5
+ colorTo: cyan
6
  sdk: gradio
7
  sdk_version: 6.18.0
8
  python_version: '3.13'
 
10
  pinned: false
11
  ---
12
 
13
+ # MiniMax-M3 Vision Chat
14
+
15
+ A multimodal chat app powered by **MiniMax-M3** via HuggingFace Inference Router, built with `gradio.Server` + a custom HTML/CSS/JS frontend.
16
+
17
+ ## Features
18
+ - πŸ–Ό **Image understanding** β€” attach images by URL or file upload
19
+ - ⚑ **Streaming responses** β€” tokens stream in real-time via SSE
20
+ - πŸ’¬ **Multi-turn conversation** β€” full chat history maintained
21
+ - 🎨 **Premium UI** β€” dark glassmorphism design with smooth animations
22
+
23
+ ## Setup
24
+ Set your `HF_TOKEN` environment variable and run:
25
+
26
+ ```bash
27
+ pip install gradio openai
28
+ python app.py
29
+ ```
app.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from openai import OpenAI
4
+ from gradio import Server
5
+ from fastapi.responses import HTMLResponse, StreamingResponse
6
+ from fastapi import Request
7
+
8
+ # ── OpenAI-compatible client pointing at HF Router ──────────────────────────
9
+ client = OpenAI(
10
+ base_url="https://router.huggingface.co/v1",
11
+ api_key=os.environ.get("HF_TOKEN", ""),
12
+ default_headers={"X-HF-Bill-To": "huggingface"},
13
+ )
14
+
15
+ MODEL = "MiniMaxAI/MiniMax-M3:novita"
16
+
17
+ app = Server()
18
+
19
+
20
+ # ── Serve the custom frontend ────────────────────────────────────────────────
21
+ @app.get("/")
22
+ async def homepage():
23
+ html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
24
+ with open(html_path, "r", encoding="utf-8") as f:
25
+ content = f.read()
26
+ return HTMLResponse(content=content)
27
+
28
+
29
+ # ── Streaming chat endpoint (SSE) ────────────────────────────────────────────
30
+ @app.post("/chat")
31
+ async def chat_stream(request: Request):
32
+ """
33
+ Accepts JSON body:
34
+ {
35
+ "messages": [
36
+ { "role": "user", "content": "..." } ← text-only
37
+ { "role": "user", "content": [ ← multimodal
38
+ {"type": "text", "text": "..."},
39
+ {"type": "image_url", "image_url": {"url": "..."}}
40
+ ]
41
+ }
42
+ ]
43
+ }
44
+ Returns an SSE stream of partial tokens.
45
+ """
46
+ body = await request.json()
47
+ messages = body.get("messages", [])
48
+
49
+ async def generate():
50
+ try:
51
+ stream = client.chat.completions.create(
52
+ model=MODEL,
53
+ messages=messages,
54
+ stream=True,
55
+ )
56
+ for chunk in stream:
57
+ delta = chunk.choices[0].delta
58
+ content = delta.content
59
+ if content:
60
+ payload = json.dumps({"token": content})
61
+ yield f"data: {payload}\n\n"
62
+ yield "data: [DONE]\n\n"
63
+ except Exception as e:
64
+ yield f"data: {json.dumps({'error': str(e)})}\n\n"
65
+ yield "data: [DONE]\n\n"
66
+
67
+ return StreamingResponse(
68
+ generate(),
69
+ media_type="text/event-stream",
70
+ headers={
71
+ "Cache-Control": "no-cache",
72
+ "X-Accel-Buffering": "no",
73
+ },
74
+ )
75
+
76
+
77
+ app.launch(show_error=True)
index.html ADDED
@@ -0,0 +1,1166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>MiniMax-M3 Β· Vision Chat</title>
7
+ <meta name="description" content="Chat with MiniMax-M3, a powerful multimodal AI model that understands images and text." />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
11
+ <style>
12
+ /* ── Reset & Base ───────────────────────────────────────────────────── */
13
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
14
+
15
+ :root {
16
+ --bg-primary: #0a0b0f;
17
+ --bg-secondary: #10121a;
18
+ --bg-card: #13151f;
19
+ --bg-input: #1a1d2b;
20
+ --bg-hover: #1e2235;
21
+ --border: rgba(255,255,255,.07);
22
+ --border-focus: rgba(139,92,246,.5);
23
+
24
+ --accent-1: #8b5cf6; /* violet */
25
+ --accent-2: #06b6d4; /* cyan */
26
+ --accent-3: #f472b6; /* pink */
27
+ --accent-grad: linear-gradient(135deg, #8b5cf6 0%, #06b6d4 100%);
28
+ --accent-grad-2: linear-gradient(135deg, #8b5cf6 0%, #f472b6 60%, #06b6d4 100%);
29
+
30
+ --text-primary: #f1f5f9;
31
+ --text-secondary: #94a3b8;
32
+ --text-muted: #475569;
33
+
34
+ --radius-sm: 8px;
35
+ --radius-md: 14px;
36
+ --radius-lg: 20px;
37
+ --radius-xl: 28px;
38
+
39
+ --shadow-glow: 0 0 40px rgba(139,92,246,.15);
40
+ --shadow-card: 0 8px 32px rgba(0,0,0,.4);
41
+ --shadow-btn: 0 4px 20px rgba(139,92,246,.35);
42
+ }
43
+
44
+ html, body {
45
+ height: 100%;
46
+ font-family: 'Inter', system-ui, sans-serif;
47
+ background: var(--bg-primary);
48
+ color: var(--text-primary);
49
+ overflow: hidden;
50
+ }
51
+
52
+ /* ── Layout ─────────────────────────────────────────────────────────── */
53
+ #app {
54
+ display: grid;
55
+ grid-template-rows: auto 1fr auto;
56
+ height: 100vh;
57
+ max-width: 900px;
58
+ margin: 0 auto;
59
+ padding: 0 16px;
60
+ }
61
+
62
+ /* ── Header ─────────────────────────────────────────────────────────── */
63
+ header {
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: space-between;
67
+ padding: 18px 0 14px;
68
+ border-bottom: 1px solid var(--border);
69
+ }
70
+
71
+ .logo-group {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 12px;
75
+ }
76
+
77
+ .logo-icon {
78
+ width: 38px; height: 38px;
79
+ border-radius: 10px;
80
+ background: var(--accent-grad);
81
+ display: flex; align-items: center; justify-content: center;
82
+ font-size: 18px;
83
+ box-shadow: var(--shadow-btn);
84
+ flex-shrink: 0;
85
+ }
86
+
87
+ .logo-text h1 {
88
+ font-size: 16px;
89
+ font-weight: 700;
90
+ letter-spacing: -.3px;
91
+ background: var(--accent-grad-2);
92
+ -webkit-background-clip: text;
93
+ -webkit-text-fill-color: transparent;
94
+ }
95
+
96
+ .logo-text p {
97
+ font-size: 11px;
98
+ color: var(--text-muted);
99
+ margin-top: 1px;
100
+ letter-spacing: .2px;
101
+ }
102
+
103
+ .header-actions {
104
+ display: flex;
105
+ gap: 8px;
106
+ align-items: center;
107
+ }
108
+
109
+ .badge {
110
+ font-size: 11px;
111
+ font-weight: 500;
112
+ padding: 4px 10px;
113
+ border-radius: 20px;
114
+ border: 1px solid var(--border);
115
+ color: var(--text-secondary);
116
+ background: var(--bg-card);
117
+ display: flex;
118
+ align-items: center;
119
+ gap: 6px;
120
+ }
121
+
122
+ .badge::before {
123
+ content: '';
124
+ width: 6px; height: 6px;
125
+ border-radius: 50%;
126
+ background: #22c55e;
127
+ box-shadow: 0 0 6px #22c55e;
128
+ animation: pulse 2s infinite;
129
+ }
130
+
131
+ @keyframes pulse {
132
+ 0%, 100% { opacity: 1; }
133
+ 50% { opacity: .4; }
134
+ }
135
+
136
+ .btn-icon {
137
+ width: 34px; height: 34px;
138
+ border-radius: 8px;
139
+ border: 1px solid var(--border);
140
+ background: var(--bg-card);
141
+ color: var(--text-secondary);
142
+ cursor: pointer;
143
+ display: flex; align-items: center; justify-content: center;
144
+ font-size: 14px;
145
+ transition: all .2s;
146
+ }
147
+ .btn-icon:hover {
148
+ background: var(--bg-hover);
149
+ color: var(--text-primary);
150
+ border-color: rgba(255,255,255,.14);
151
+ }
152
+
153
+ /* ── Messages ───────────────────────────────────────────────────────── */
154
+ #messages {
155
+ overflow-y: auto;
156
+ padding: 20px 0;
157
+ display: flex;
158
+ flex-direction: column;
159
+ gap: 16px;
160
+ scroll-behavior: smooth;
161
+ }
162
+
163
+ #messages::-webkit-scrollbar { width: 4px; }
164
+ #messages::-webkit-scrollbar-track { background: transparent; }
165
+ #messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
166
+
167
+ /* ── Empty state ─────────────────────────────────────────────────────── */
168
+ #empty-state {
169
+ flex: 1;
170
+ display: flex;
171
+ flex-direction: column;
172
+ align-items: center;
173
+ justify-content: center;
174
+ gap: 10px;
175
+ text-align: center;
176
+ padding: 40px;
177
+ animation: fadeIn .5s ease;
178
+ }
179
+
180
+ #empty-state.hidden { display: none; }
181
+
182
+ .empty-orb {
183
+ width: 80px; height: 80px;
184
+ border-radius: 50%;
185
+ background: var(--accent-grad);
186
+ display: flex; align-items: center; justify-content: center;
187
+ font-size: 34px;
188
+ box-shadow: var(--shadow-glow);
189
+ margin-bottom: 8px;
190
+ animation: float 4s ease-in-out infinite;
191
+ }
192
+
193
+ @keyframes float {
194
+ 0%, 100% { transform: translateY(0); }
195
+ 50% { transform: translateY(-8px); }
196
+ }
197
+
198
+ #empty-state h2 {
199
+ font-size: 22px;
200
+ font-weight: 700;
201
+ background: var(--accent-grad-2);
202
+ -webkit-background-clip: text;
203
+ -webkit-text-fill-color: transparent;
204
+ }
205
+
206
+ #empty-state p {
207
+ font-size: 14px;
208
+ color: var(--text-secondary);
209
+ max-width: 380px;
210
+ line-height: 1.6;
211
+ }
212
+
213
+ .suggestion-chips {
214
+ display: flex;
215
+ flex-wrap: wrap;
216
+ gap: 8px;
217
+ justify-content: center;
218
+ margin-top: 12px;
219
+ }
220
+
221
+ .chip {
222
+ font-size: 12px;
223
+ padding: 7px 14px;
224
+ border-radius: 20px;
225
+ border: 1px solid var(--border);
226
+ background: var(--bg-card);
227
+ color: var(--text-secondary);
228
+ cursor: pointer;
229
+ transition: all .2s;
230
+ display: flex;
231
+ align-items: center;
232
+ gap: 6px;
233
+ }
234
+
235
+ .chip:hover {
236
+ background: var(--bg-hover);
237
+ border-color: var(--accent-1);
238
+ color: var(--text-primary);
239
+ }
240
+
241
+ /* ── Message Bubbles ─────────────────────────────────────────────────── */
242
+ .message {
243
+ display: flex;
244
+ gap: 12px;
245
+ animation: slideUp .3s ease;
246
+ max-width: 100%;
247
+ }
248
+
249
+ @keyframes slideUp {
250
+ from { opacity: 0; transform: translateY(10px); }
251
+ to { opacity: 1; transform: translateY(0); }
252
+ }
253
+
254
+ .message.user { flex-direction: row-reverse; }
255
+
256
+ .avatar {
257
+ width: 34px; height: 34px;
258
+ border-radius: 10px;
259
+ flex-shrink: 0;
260
+ display: flex; align-items: center; justify-content: center;
261
+ font-size: 15px;
262
+ }
263
+
264
+ .avatar.user-av {
265
+ background: linear-gradient(135deg, #f472b6, #8b5cf6);
266
+ box-shadow: 0 2px 12px rgba(244,114,182,.3);
267
+ }
268
+
269
+ .avatar.ai-av {
270
+ background: var(--accent-grad);
271
+ box-shadow: 0 2px 12px rgba(139,92,246,.3);
272
+ }
273
+
274
+ .bubble {
275
+ max-width: 75%;
276
+ border-radius: var(--radius-lg);
277
+ padding: 13px 17px;
278
+ font-size: 14px;
279
+ line-height: 1.65;
280
+ word-break: break-word;
281
+ position: relative;
282
+ }
283
+
284
+ .user .bubble {
285
+ background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
286
+ border-radius: var(--radius-lg) var(--radius-lg) var(--radius-sm) var(--radius-lg);
287
+ box-shadow: 0 4px 20px rgba(139,92,246,.25);
288
+ color: #fff;
289
+ }
290
+
291
+ .ai .bubble {
292
+ background: var(--bg-card);
293
+ border: 1px solid var(--border);
294
+ border-radius: var(--radius-lg) var(--radius-lg) var(--radius-lg) var(--radius-sm);
295
+ box-shadow: var(--shadow-card);
296
+ }
297
+
298
+ .bubble img.preview {
299
+ max-width: 100%;
300
+ max-height: 240px;
301
+ object-fit: cover;
302
+ border-radius: 10px;
303
+ margin-bottom: 10px;
304
+ display: block;
305
+ }
306
+
307
+ .bubble .text-content p { margin-bottom: 8px; }
308
+ .bubble .text-content p:last-child { margin-bottom: 0; }
309
+
310
+ .bubble .text-content code {
311
+ font-family: 'JetBrains Mono', monospace;
312
+ font-size: 12.5px;
313
+ background: rgba(255,255,255,.08);
314
+ padding: 2px 6px;
315
+ border-radius: 4px;
316
+ }
317
+
318
+ .bubble .text-content pre {
319
+ background: rgba(0,0,0,.4);
320
+ border: 1px solid var(--border);
321
+ border-radius: 8px;
322
+ padding: 12px;
323
+ overflow-x: auto;
324
+ margin: 8px 0;
325
+ }
326
+
327
+ .bubble .text-content pre code {
328
+ background: none;
329
+ padding: 0;
330
+ }
331
+
332
+ /* typing indicator */
333
+ .typing-dots {
334
+ display: flex;
335
+ gap: 5px;
336
+ align-items: center;
337
+ padding: 4px 0;
338
+ }
339
+
340
+ .typing-dots span {
341
+ width: 7px; height: 7px;
342
+ border-radius: 50%;
343
+ background: var(--accent-1);
344
+ animation: bounce 1.2s ease infinite;
345
+ }
346
+
347
+ .typing-dots span:nth-child(2) { animation-delay: .15s; background: var(--accent-2); }
348
+ .typing-dots span:nth-child(3) { animation-delay: .30s; background: var(--accent-3); }
349
+
350
+ @keyframes bounce {
351
+ 0%, 60%, 100% { transform: translateY(0); }
352
+ 30% { transform: translateY(-6px); }
353
+ }
354
+
355
+ /* timestamp */
356
+ .msg-time {
357
+ font-size: 10px;
358
+ color: var(--text-muted);
359
+ margin-top: 5px;
360
+ padding: 0 4px;
361
+ }
362
+
363
+ .message.user .msg-time { text-align: right; }
364
+
365
+ /* ── Input Area ──────────────────────────────────────────────────────── */
366
+ #input-area {
367
+ padding: 14px 0 20px;
368
+ border-top: 1px solid var(--border);
369
+ }
370
+
371
+ /* Image preview strip */
372
+ #image-preview-strip {
373
+ display: none;
374
+ gap: 8px;
375
+ margin-bottom: 10px;
376
+ flex-wrap: wrap;
377
+ }
378
+
379
+ #image-preview-strip.visible { display: flex; }
380
+
381
+ .img-thumb {
382
+ position: relative;
383
+ width: 64px; height: 64px;
384
+ border-radius: 10px;
385
+ overflow: hidden;
386
+ border: 1px solid var(--border);
387
+ animation: fadeIn .2s ease;
388
+ }
389
+
390
+ @keyframes fadeIn {
391
+ from { opacity: 0; transform: scale(.9); }
392
+ to { opacity: 1; transform: scale(1); }
393
+ }
394
+
395
+ .img-thumb img {
396
+ width: 100%; height: 100%;
397
+ object-fit: cover;
398
+ }
399
+
400
+ .img-thumb-remove {
401
+ position: absolute;
402
+ top: 2px; right: 2px;
403
+ width: 18px; height: 18px;
404
+ border-radius: 50%;
405
+ background: rgba(0,0,0,.75);
406
+ border: none;
407
+ cursor: pointer;
408
+ color: #fff;
409
+ font-size: 10px;
410
+ display: flex; align-items: center; justify-content: center;
411
+ transition: background .15s;
412
+ }
413
+
414
+ .img-thumb-remove:hover { background: #ef4444; }
415
+
416
+ .input-row {
417
+ display: flex;
418
+ gap: 10px;
419
+ align-items: flex-end;
420
+ background: var(--bg-input);
421
+ border: 1px solid var(--border);
422
+ border-radius: var(--radius-xl);
423
+ padding: 10px 10px 10px 16px;
424
+ transition: border-color .25s, box-shadow .25s;
425
+ }
426
+
427
+ .input-row:focus-within {
428
+ border-color: var(--border-focus);
429
+ box-shadow: 0 0 0 3px rgba(139,92,246,.12), var(--shadow-glow);
430
+ }
431
+
432
+ #prompt-input {
433
+ flex: 1;
434
+ background: none;
435
+ border: none;
436
+ outline: none;
437
+ color: var(--text-primary);
438
+ font-family: 'Inter', sans-serif;
439
+ font-size: 14px;
440
+ line-height: 1.5;
441
+ resize: none;
442
+ max-height: 160px;
443
+ overflow-y: auto;
444
+ }
445
+
446
+ #prompt-input::placeholder { color: var(--text-muted); }
447
+ #prompt-input::-webkit-scrollbar { width: 3px; }
448
+ #prompt-input::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
449
+
450
+ .input-actions {
451
+ display: flex;
452
+ gap: 6px;
453
+ align-items: center;
454
+ }
455
+
456
+ .action-btn {
457
+ width: 36px; height: 36px;
458
+ border-radius: 10px;
459
+ border: 1px solid var(--border);
460
+ background: var(--bg-card);
461
+ color: var(--text-secondary);
462
+ cursor: pointer;
463
+ display: flex; align-items: center; justify-content: center;
464
+ font-size: 15px;
465
+ transition: all .2s;
466
+ flex-shrink: 0;
467
+ }
468
+
469
+ .action-btn:hover {
470
+ background: var(--bg-hover);
471
+ color: var(--text-primary);
472
+ border-color: rgba(255,255,255,.14);
473
+ }
474
+
475
+ #send-btn {
476
+ width: 36px; height: 36px;
477
+ border-radius: 10px;
478
+ border: none;
479
+ background: var(--accent-grad);
480
+ color: #fff;
481
+ cursor: pointer;
482
+ display: flex; align-items: center; justify-content: center;
483
+ font-size: 15px;
484
+ transition: all .2s;
485
+ flex-shrink: 0;
486
+ box-shadow: var(--shadow-btn);
487
+ }
488
+
489
+ #send-btn:hover {
490
+ transform: scale(1.06);
491
+ box-shadow: 0 6px 24px rgba(139,92,246,.5);
492
+ }
493
+
494
+ #send-btn:active { transform: scale(.97); }
495
+ #send-btn:disabled { opacity: .45; cursor: not-allowed; transform: none; }
496
+
497
+ #send-btn.stop-mode {
498
+ background: linear-gradient(135deg, #ef4444, #dc2626);
499
+ box-shadow: 0 4px 20px rgba(239,68,68,.35);
500
+ }
501
+
502
+ .hint {
503
+ font-size: 11px;
504
+ color: var(--text-muted);
505
+ text-align: center;
506
+ margin-top: 8px;
507
+ }
508
+
509
+ /* ── URL input modal ─────────────────────────────────────────────────── */
510
+ #url-modal-overlay {
511
+ display: none;
512
+ position: fixed;
513
+ inset: 0;
514
+ background: rgba(0,0,0,.6);
515
+ backdrop-filter: blur(4px);
516
+ z-index: 100;
517
+ align-items: center;
518
+ justify-content: center;
519
+ }
520
+
521
+ #url-modal-overlay.open { display: flex; }
522
+
523
+ #url-modal {
524
+ background: var(--bg-card);
525
+ border: 1px solid var(--border);
526
+ border-radius: var(--radius-xl);
527
+ padding: 28px;
528
+ width: 90%;
529
+ max-width: 480px;
530
+ box-shadow: var(--shadow-card);
531
+ animation: slideUp .25s ease;
532
+ }
533
+
534
+ #url-modal h3 {
535
+ font-size: 16px;
536
+ font-weight: 600;
537
+ margin-bottom: 16px;
538
+ display: flex;
539
+ align-items: center;
540
+ gap: 8px;
541
+ }
542
+
543
+ #url-modal h3 span.icon { font-size: 20px; }
544
+
545
+ #url-input-field {
546
+ width: 100%;
547
+ background: var(--bg-input);
548
+ border: 1px solid var(--border);
549
+ border-radius: var(--radius-md);
550
+ padding: 11px 14px;
551
+ font-family: 'Inter', sans-serif;
552
+ font-size: 13px;
553
+ color: var(--text-primary);
554
+ outline: none;
555
+ transition: border-color .2s;
556
+ margin-bottom: 14px;
557
+ }
558
+
559
+ #url-input-field:focus { border-color: var(--border-focus); }
560
+ #url-input-field::placeholder { color: var(--text-muted); }
561
+
562
+ .modal-actions {
563
+ display: flex;
564
+ gap: 10px;
565
+ justify-content: flex-end;
566
+ }
567
+
568
+ .modal-btn {
569
+ padding: 9px 20px;
570
+ border-radius: 10px;
571
+ font-family: 'Inter', sans-serif;
572
+ font-size: 13px;
573
+ font-weight: 500;
574
+ cursor: pointer;
575
+ transition: all .2s;
576
+ }
577
+
578
+ .modal-btn.cancel {
579
+ background: var(--bg-input);
580
+ border: 1px solid var(--border);
581
+ color: var(--text-secondary);
582
+ }
583
+
584
+ .modal-btn.cancel:hover { background: var(--bg-hover); color: var(--text-primary); }
585
+
586
+ .modal-btn.confirm {
587
+ background: var(--accent-grad);
588
+ border: none;
589
+ color: #fff;
590
+ box-shadow: var(--shadow-btn);
591
+ }
592
+
593
+ .modal-btn.confirm:hover { opacity: .88; }
594
+
595
+ /* ── Scrollbar global ────────────────────────────────────────────────── */
596
+ * {
597
+ scrollbar-width: thin;
598
+ scrollbar-color: var(--border) transparent;
599
+ }
600
+
601
+ /* ── Responsive ──────────────────────────────────────────────────────── */
602
+ @media (max-width: 600px) {
603
+ header { padding: 14px 0 10px; }
604
+ .badge { display: none; }
605
+ .bubble { max-width: 88%; }
606
+ }
607
+ </style>
608
+ </head>
609
+ <body>
610
+
611
+ <!-- URL Image Modal -->
612
+ <div id="url-modal-overlay">
613
+ <div id="url-modal">
614
+ <h3><span class="icon">πŸ”—</span> Add Image by URL</h3>
615
+ <input id="url-input-field" type="url" placeholder="https://example.com/image.jpg" />
616
+ <div class="modal-actions">
617
+ <button class="modal-btn cancel" id="modal-cancel">Cancel</button>
618
+ <button class="modal-btn confirm" id="modal-confirm">Add Image</button>
619
+ </div>
620
+ </div>
621
+ </div>
622
+
623
+ <div id="app">
624
+ <!-- Header -->
625
+ <header>
626
+ <div class="logo-group">
627
+ <div class="logo-icon">✦</div>
628
+ <div class="logo-text">
629
+ <h1>MiniMax-M3</h1>
630
+ <p>Multimodal Vision Chat</p>
631
+ </div>
632
+ </div>
633
+ <div class="header-actions">
634
+ <div class="badge">Online</div>
635
+ <button class="btn-icon" id="clear-btn" title="Clear conversation">πŸ—‘οΈ</button>
636
+ </div>
637
+ </header>
638
+
639
+ <!-- Messages -->
640
+ <div id="messages">
641
+ <div id="empty-state">
642
+ <div class="empty-orb">✦</div>
643
+ <h2>MiniMax-M3 Vision Chat</h2>
644
+ <p>A powerful multimodal AI that understands both text and images. Ask questions, describe scenes, or analyze any image.</p>
645
+ <div class="suggestion-chips">
646
+ <div class="chip" data-prompt="What can you help me with?">πŸ’¬ What can you do?</div>
647
+ <div class="chip" data-img="https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" data-prompt="Describe this image in detail.">πŸ—½ Analyze Statue of Liberty</div>
648
+ <div class="chip" data-prompt="Write a short poem about the stars.">✨ Write a poem</div>
649
+ <div class="chip" data-img="https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png" data-prompt="What objects do you see in this image?">🎨 Identify objects</div>
650
+ </div>
651
+ </div>
652
+ </div>
653
+
654
+ <!-- Input Area -->
655
+ <div id="input-area">
656
+ <div id="image-preview-strip"></div>
657
+ <div class="input-row">
658
+ <textarea
659
+ id="prompt-input"
660
+ rows="1"
661
+ placeholder="Ask anything β€” attach images with πŸ”— or πŸ“Žβ€¦"
662
+ ></textarea>
663
+ <div class="input-actions">
664
+ <button class="action-btn" id="url-btn" title="Add image by URL">πŸ”—</button>
665
+ <label class="action-btn" title="Upload image" style="cursor:pointer">
666
+ πŸ“Ž
667
+ <input type="file" id="file-input" accept="image/*" multiple style="display:none" />
668
+ </label>
669
+ <button id="send-btn" title="Send message">➀</button>
670
+ </div>
671
+ </div>
672
+ <p class="hint">Press <kbd style="font-family:monospace;background:var(--bg-card);border:1px solid var(--border);padding:1px 5px;border-radius:4px;font-size:10px">Enter</kbd> to send Β· <kbd style="font-family:monospace;background:var(--bg-card);border:1px solid var(--border);padding:1px 5px;border-radius:4px;font-size:10px">Shift+Enter</kbd> for new line</p>
673
+ </div>
674
+ </div>
675
+
676
+ <script>
677
+ // ── State ─────────────────────────────────────────────────────────────────
678
+ const history = []; // [{role, content}] content can be str or array
679
+ const pendingImages = []; // {type:'url'|'file', url:string, dataUrl?:string}
680
+ let isStreaming = false;
681
+ let abortController = null;
682
+
683
+ // ── DOM refs ───────────────────────────────────────────────────────────────
684
+ const messagesEl = document.getElementById('messages');
685
+ const promptInput = document.getElementById('prompt-input');
686
+ const sendBtn = document.getElementById('send-btn');
687
+ const clearBtn = document.getElementById('clear-btn');
688
+ const emptyState = document.getElementById('empty-state');
689
+ const imageStrip = document.getElementById('image-preview-strip');
690
+ const fileInput = document.getElementById('file-input');
691
+ const urlBtn = document.getElementById('url-btn');
692
+ const urlModal = document.getElementById('url-modal-overlay');
693
+ const urlField = document.getElementById('url-input-field');
694
+ const modalConfirm = document.getElementById('modal-confirm');
695
+ const modalCancel = document.getElementById('modal-cancel');
696
+
697
+ // ── Auto-resize textarea ──────────────────────────────────────────────────
698
+ promptInput.addEventListener('input', () => {
699
+ promptInput.style.height = 'auto';
700
+ promptInput.style.height = Math.min(promptInput.scrollHeight, 160) + 'px';
701
+ });
702
+
703
+ // ── Keyboard shortcuts ────────────────────────────────────────────────────
704
+ promptInput.addEventListener('keydown', e => {
705
+ if (e.key === 'Enter' && !e.shiftKey) {
706
+ e.preventDefault();
707
+ sendMessage();
708
+ }
709
+ });
710
+
711
+ // ── Send button ───────────────────────────────────────────────────────────
712
+ sendBtn.addEventListener('click', () => {
713
+ if (isStreaming) {
714
+ stopStreaming();
715
+ } else {
716
+ sendMessage();
717
+ }
718
+ });
719
+
720
+ // ── Clear conversation ────────────────────────────────────────────────────
721
+ clearBtn.addEventListener('click', () => {
722
+ history.length = 0;
723
+ pendingImages.length = 0;
724
+ messagesEl.innerHTML = '';
725
+ messagesEl.appendChild(emptyState);
726
+ emptyState.classList.remove('hidden');
727
+ imageStrip.innerHTML = '';
728
+ imageStrip.classList.remove('visible');
729
+ });
730
+
731
+ // ── File upload ───────────────────────────────────────────────────────────
732
+ fileInput.addEventListener('change', () => {
733
+ Array.from(fileInput.files).forEach(addFileImage);
734
+ fileInput.value = '';
735
+ });
736
+
737
+ function addFileImage(file) {
738
+ const reader = new FileReader();
739
+ reader.onload = e => {
740
+ const dataUrl = e.target.result;
741
+ pendingImages.push({ type: 'file', url: dataUrl, dataUrl });
742
+ renderImageThumb(dataUrl, pendingImages.length - 1);
743
+ };
744
+ reader.readAsDataURL(file);
745
+ }
746
+
747
+ // ── URL modal ─────────────────────────────────────────────────────────────
748
+ urlBtn.addEventListener('click', () => {
749
+ urlField.value = '';
750
+ urlModal.classList.add('open');
751
+ setTimeout(() => urlField.focus(), 50);
752
+ });
753
+
754
+ modalCancel.addEventListener('click', () => urlModal.classList.remove('open'));
755
+ urlModal.addEventListener('click', e => { if (e.target === urlModal) urlModal.classList.remove('open'); });
756
+
757
+ urlField.addEventListener('keydown', e => {
758
+ if (e.key === 'Enter') { e.preventDefault(); confirmUrl(); }
759
+ if (e.key === 'Escape') urlModal.classList.remove('open');
760
+ });
761
+
762
+ modalConfirm.addEventListener('click', confirmUrl);
763
+
764
+ function confirmUrl() {
765
+ const url = urlField.value.trim();
766
+ if (!url) return;
767
+ pendingImages.push({ type: 'url', url });
768
+ renderImageThumb(url, pendingImages.length - 1);
769
+ urlModal.classList.remove('open');
770
+ }
771
+
772
+ function renderImageThumb(src, idx) {
773
+ imageStrip.classList.add('visible');
774
+ const thumb = document.createElement('div');
775
+ thumb.className = 'img-thumb';
776
+ thumb.dataset.idx = idx;
777
+ thumb.innerHTML = `
778
+ <img src="${src}" alt="image preview" />
779
+ <button class="img-thumb-remove" title="Remove">βœ•</button>
780
+ `;
781
+ thumb.querySelector('.img-thumb-remove').addEventListener('click', () => {
782
+ pendingImages.splice(idx, 1);
783
+ thumb.remove();
784
+ if (pendingImages.length === 0) imageStrip.classList.remove('visible');
785
+ });
786
+ imageStrip.appendChild(thumb);
787
+ }
788
+
789
+ // ── Suggestion chips ──────────────────────────────────────────────────────
790
+ document.querySelectorAll('.chip').forEach(chip => {
791
+ chip.addEventListener('click', () => {
792
+ const prompt = chip.dataset.prompt || '';
793
+ const img = chip.dataset.img || '';
794
+ promptInput.value = prompt;
795
+ if (img) {
796
+ pendingImages.push({ type: 'url', url: img });
797
+ renderImageThumb(img, pendingImages.length - 1);
798
+ }
799
+ promptInput.dispatchEvent(new Event('input'));
800
+ sendMessage();
801
+ });
802
+ });
803
+
804
+ // ── Send message ──────────────────────────────────────────────────────────
805
+ async function sendMessage() {
806
+ const text = promptInput.value.trim();
807
+ if (!text && pendingImages.length === 0) return;
808
+ if (isStreaming) return;
809
+
810
+ // Build content array
811
+ let content;
812
+ if (pendingImages.length > 0) {
813
+ content = [];
814
+ if (text) content.push({ type: 'text', text });
815
+ pendingImages.forEach(img => {
816
+ content.push({
817
+ type: 'image_url',
818
+ image_url: { url: img.url }
819
+ });
820
+ });
821
+ } else {
822
+ content = text;
823
+ }
824
+
825
+ // Snapshot images for display before clearing
826
+ const displayImages = pendingImages.map(i => i.url);
827
+
828
+ // Add to history
829
+ history.push({ role: 'user', content });
830
+
831
+ // Render user bubble
832
+ renderUserMessage(text, displayImages);
833
+
834
+ // Clear input
835
+ promptInput.value = '';
836
+ promptInput.style.height = 'auto';
837
+ pendingImages.length = 0;
838
+ imageStrip.innerHTML = '';
839
+ imageStrip.classList.remove('visible');
840
+
841
+ // Hide empty state
842
+ emptyState.classList.add('hidden');
843
+
844
+ // Show AI typing indicator
845
+ const aiEl = renderAITyping();
846
+
847
+ // Stream
848
+ setStreaming(true);
849
+ abortController = new AbortController();
850
+
851
+ try {
852
+ const res = await fetch('/chat', {
853
+ method: 'POST',
854
+ headers: { 'Content-Type': 'application/json' },
855
+ body: JSON.stringify({ messages: history }),
856
+ signal: abortController.signal,
857
+ });
858
+
859
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
860
+
861
+ const reader = res.body.getReader();
862
+ const decoder = new TextDecoder();
863
+ let aiText = '';
864
+
865
+ // Replace typing indicator with actual bubble
866
+ const aiContent = aiEl.querySelector('.text-content');
867
+ aiEl.querySelector('.typing-dots')?.replaceWith(aiContent);
868
+
869
+ while (true) {
870
+ const { done, value } = await reader.read();
871
+ if (done) break;
872
+
873
+ const chunk = decoder.decode(value, { stream: true });
874
+ const lines = chunk.split('\n');
875
+
876
+ for (const line of lines) {
877
+ if (!line.startsWith('data: ')) continue;
878
+ const data = line.slice(6).trim();
879
+ if (data === '[DONE]') break;
880
+
881
+ try {
882
+ const parsed = JSON.parse(data);
883
+ if (parsed.error) throw new Error(parsed.error);
884
+ if (parsed.token) {
885
+ aiText += parsed.token;
886
+ aiContent.innerHTML = formatMarkdown(aiText);
887
+ scrollToBottom();
888
+ }
889
+ } catch (_) {}
890
+ }
891
+ }
892
+
893
+ // Save to history
894
+ history.push({ role: 'assistant', content: aiText });
895
+ addTimestamp(aiEl.parentElement);
896
+
897
+ } catch (err) {
898
+ if (err.name !== 'AbortError') {
899
+ const aiContent = aiEl.querySelector('.text-content') || aiEl;
900
+ aiContent.innerHTML = `<span style="color:#ef4444">⚠ ${err.message}</span>`;
901
+ }
902
+ } finally {
903
+ setStreaming(false);
904
+ scrollToBottom();
905
+ }
906
+ }
907
+
908
+ // ── Stop streaming ─────────────────────────────────────────────────────────
909
+ function stopStreaming() {
910
+ if (abortController) abortController.abort();
911
+ setStreaming(false);
912
+ }
913
+
914
+ function setStreaming(val) {
915
+ isStreaming = val;
916
+ if (val) {
917
+ sendBtn.innerHTML = '⏹';
918
+ sendBtn.classList.add('stop-mode');
919
+ sendBtn.title = 'Stop generation';
920
+ } else {
921
+ sendBtn.innerHTML = '➀';
922
+ sendBtn.classList.remove('stop-mode');
923
+ sendBtn.title = 'Send message';
924
+ }
925
+ }
926
+
927
+ // ── Render helpers ─────────────────────────────────────────────────────────
928
+ function renderUserMessage(text, images) {
929
+ const div = document.createElement('div');
930
+ div.className = 'message user';
931
+
932
+ let imageHtml = images.map(url =>
933
+ `<img class="preview" src="${url}" alt="attached image" />`
934
+ ).join('');
935
+
936
+ div.innerHTML = `
937
+ <div>
938
+ <div class="bubble">
939
+ ${imageHtml}
940
+ ${text ? `<div class="text-content">${escapeHtml(text)}</div>` : ''}
941
+ </div>
942
+ <div class="msg-time">${timeStr()}</div>
943
+ </div>
944
+ <div class="avatar user-av">πŸ‘€</div>
945
+ `;
946
+ messagesEl.appendChild(div);
947
+ scrollToBottom();
948
+ }
949
+
950
+ function renderAITyping() {
951
+ const div = document.createElement('div');
952
+ div.className = 'message ai';
953
+ div.innerHTML = `
954
+ <div class="avatar ai-av">✦</div>
955
+ <div>
956
+ <div class="bubble">
957
+ <div class="typing-dots">
958
+ <span></span><span></span><span></span>
959
+ </div>
960
+ <div class="text-content" style="display:none"></div>
961
+ </div>
962
+ </div>
963
+ `;
964
+
965
+ // After first token: show text-content, hide dots
966
+ const dots = div.querySelector('.typing-dots');
967
+ const content = div.querySelector('.text-content');
968
+
969
+ // Swap on first render triggered externally
970
+ div._activate = () => {
971
+ dots.style.display = 'none';
972
+ content.style.display = '';
973
+ };
974
+
975
+ messagesEl.appendChild(div);
976
+ scrollToBottom();
977
+ return div.querySelector('.bubble');
978
+ }
979
+
980
+ // Patch: the bubble ref approach β€” let's get the bubble element working correctly
981
+ function addTimestamp(msgEl) {
982
+ if (!msgEl) return;
983
+ const existing = msgEl.querySelector('.msg-time');
984
+ if (!existing) {
985
+ const t = document.createElement('div');
986
+ t.className = 'msg-time';
987
+ t.textContent = timeStr();
988
+ msgEl.appendChild(t);
989
+ }
990
+ }
991
+
992
+ function scrollToBottom() {
993
+ messagesEl.scrollTop = messagesEl.scrollHeight;
994
+ }
995
+
996
+ function timeStr() {
997
+ return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
998
+ }
999
+
1000
+ function escapeHtml(str) {
1001
+ return str
1002
+ .replace(/&/g, '&amp;')
1003
+ .replace(/</g, '&lt;')
1004
+ .replace(/>/g, '&gt;')
1005
+ .replace(/"/g, '&quot;');
1006
+ }
1007
+
1008
+ // Very lightweight markdown renderer
1009
+ function formatMarkdown(text) {
1010
+ // code blocks
1011
+ text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) =>
1012
+ `<pre><code class="lang-${lang}">${escapeHtml(code.trim())}</code></pre>`
1013
+ );
1014
+ // inline code
1015
+ text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
1016
+ // bold
1017
+ text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
1018
+ // italic
1019
+ text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
1020
+ // headings
1021
+ text = text.replace(/^### (.+)$/gm, '<h3 style="font-size:14px;font-weight:600;margin:10px 0 4px">$1</h3>');
1022
+ text = text.replace(/^## (.+)$/gm, '<h2 style="font-size:15px;font-weight:600;margin:10px 0 4px">$1</h2>');
1023
+ text = text.replace(/^# (.+)$/gm, '<h2 style="font-size:16px;font-weight:700;margin:10px 0 4px">$1</h2>');
1024
+ // lists
1025
+ text = text.replace(/^- (.+)$/gm, '<li style="margin-left:16px;list-style:disc;margin-bottom:3px">$1</li>');
1026
+ text = text.replace(/^(\d+)\. (.+)$/gm, '<li style="margin-left:16px;list-style:decimal;margin-bottom:3px">$2</li>');
1027
+ // paragraphs (double newline)
1028
+ text = text.replace(/\n\n/g, '</p><p>');
1029
+ // single newlines
1030
+ text = text.replace(/\n/g, '<br>');
1031
+
1032
+ return `<p>${text}</p>`;
1033
+ }
1034
+
1035
+ // ── Fix streaming: swap dots on first token ───────────────────────────────
1036
+ // Patch renderAITyping to work with the streaming loop correctly
1037
+ // The bubble already has .text-content hidden; we reveal it on first token.
1038
+ // Let's override the streaming section with proper element handling:
1039
+
1040
+ const _origSend = sendMessage;
1041
+ // The streaming logic inside sendMessage already references aiEl as the bubble
1042
+ // and aiContent as .text-content. The typing dots are siblings. We need to
1043
+ // swap them on first token. Let's patch via MutationObserver on aiContent.
1044
+ document.addEventListener('DOMContentLoaded', () => {
1045
+ // Already loaded, nothing extra needed
1046
+ });
1047
+
1048
+ </script>
1049
+
1050
+ <!-- Patch: fix the streaming swap logic after page load -->
1051
+ <script>
1052
+ // Override sendMessage with fixed streaming that properly swaps typing β†’ content
1053
+ async function sendMessage() {
1054
+ const text = promptInput.value.trim();
1055
+ if (!text && pendingImages.length === 0) return;
1056
+ if (isStreaming) return;
1057
+
1058
+ let content;
1059
+ if (pendingImages.length > 0) {
1060
+ content = [];
1061
+ if (text) content.push({ type: 'text', text });
1062
+ pendingImages.forEach(img => {
1063
+ content.push({ type: 'image_url', image_url: { url: img.url } });
1064
+ });
1065
+ } else {
1066
+ content = text;
1067
+ }
1068
+
1069
+ const displayImages = pendingImages.map(i => i.url);
1070
+ history.push({ role: 'user', content });
1071
+ renderUserMessage(text, displayImages);
1072
+
1073
+ promptInput.value = '';
1074
+ promptInput.style.height = 'auto';
1075
+ pendingImages.length = 0;
1076
+ imageStrip.innerHTML = '';
1077
+ imageStrip.classList.remove('visible');
1078
+ emptyState.classList.add('hidden');
1079
+
1080
+ // Create AI message shell
1081
+ const msgDiv = document.createElement('div');
1082
+ msgDiv.className = 'message ai';
1083
+ msgDiv.innerHTML = `
1084
+ <div class="avatar ai-av">✦</div>
1085
+ <div class="msg-wrapper">
1086
+ <div class="bubble">
1087
+ <div class="typing-dots"><span></span><span></span><span></span></div>
1088
+ <div class="text-content" style="display:none"></div>
1089
+ </div>
1090
+ </div>
1091
+ `;
1092
+ messagesEl.appendChild(msgDiv);
1093
+ scrollToBottom();
1094
+
1095
+ const dots = msgDiv.querySelector('.typing-dots');
1096
+ const content2 = msgDiv.querySelector('.text-content');
1097
+
1098
+ setStreaming(true);
1099
+ abortController = new AbortController();
1100
+
1101
+ try {
1102
+ const res = await fetch('/chat', {
1103
+ method: 'POST',
1104
+ headers: { 'Content-Type': 'application/json' },
1105
+ body: JSON.stringify({ messages: history }),
1106
+ signal: abortController.signal,
1107
+ });
1108
+
1109
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1110
+
1111
+ const reader = res.body.getReader();
1112
+ const decoder = new TextDecoder();
1113
+ let aiText = '';
1114
+ let firstToken = true;
1115
+
1116
+ while (true) {
1117
+ const { done, value } = await reader.read();
1118
+ if (done) break;
1119
+
1120
+ const chunk = decoder.decode(value, { stream: true });
1121
+ for (const line of chunk.split('\n')) {
1122
+ if (!line.startsWith('data: ')) continue;
1123
+ const data = line.slice(6).trim();
1124
+ if (data === '[DONE]') break;
1125
+
1126
+ try {
1127
+ const parsed = JSON.parse(data);
1128
+ if (parsed.error) throw new Error(parsed.error);
1129
+ if (parsed.token) {
1130
+ if (firstToken) {
1131
+ dots.style.display = 'none';
1132
+ content2.style.display = '';
1133
+ firstToken = false;
1134
+ }
1135
+ aiText += parsed.token;
1136
+ content2.innerHTML = formatMarkdown(aiText);
1137
+ scrollToBottom();
1138
+ }
1139
+ } catch (_) {}
1140
+ }
1141
+ }
1142
+
1143
+ history.push({ role: 'assistant', content: aiText });
1144
+
1145
+ // Add timestamp
1146
+ const wrapper = msgDiv.querySelector('.msg-wrapper');
1147
+ const t = document.createElement('div');
1148
+ t.className = 'msg-time';
1149
+ t.textContent = timeStr();
1150
+ wrapper.appendChild(t);
1151
+
1152
+ } catch (err) {
1153
+ if (err.name !== 'AbortError') {
1154
+ dots.style.display = 'none';
1155
+ content2.style.display = '';
1156
+ content2.innerHTML = `<span style="color:#ef4444">⚠ ${err.message}</span>`;
1157
+ }
1158
+ } finally {
1159
+ setStreaming(false);
1160
+ scrollToBottom();
1161
+ }
1162
+ }
1163
+ </script>
1164
+
1165
+ </body>
1166
+ </html>