Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>MiniMax-M3 · Native Multimodal Chat</title> | |
| <meta name="description" content="Chat with MiniMax-M3 — a 428B-parameter native multimodal model with 1M context, MiniMax Sparse Attention, and frontier coding & agentic capabilities." /> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> | |
| <style> | |
| /* ── Reset & Base ───────────────────────────────────────────────────── */ | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg-primary: #0a0b0f; | |
| --bg-secondary: #10121a; | |
| --bg-card: #13151f; | |
| --bg-input: #1a1d2b; | |
| --bg-hover: #1e2235; | |
| --border: rgba(255,255,255,.07); | |
| --border-focus: rgba(139,92,246,.5); | |
| --accent-1: #8b5cf6; /* violet */ | |
| --accent-2: #06b6d4; /* cyan */ | |
| --accent-3: #f472b6; /* pink */ | |
| --accent-grad: linear-gradient(135deg, #8b5cf6 0%, #06b6d4 100%); | |
| --accent-grad-2: linear-gradient(135deg, #8b5cf6 0%, #f472b6 60%, #06b6d4 100%); | |
| --text-primary: #f1f5f9; | |
| --text-secondary: #94a3b8; | |
| --text-muted: #475569; | |
| --radius-sm: 8px; | |
| --radius-md: 14px; | |
| --radius-lg: 20px; | |
| --radius-xl: 28px; | |
| --shadow-glow: 0 0 40px rgba(139,92,246,.15); | |
| --shadow-card: 0 8px 32px rgba(0,0,0,.4); | |
| --shadow-btn: 0 4px 20px rgba(139,92,246,.35); | |
| } | |
| html, body { | |
| height: 100%; | |
| font-family: 'Inter', system-ui, sans-serif; | |
| background: var(--bg-primary); | |
| color: var(--text-primary); | |
| overflow: hidden; | |
| } | |
| /* ── Layout ─────────────────────────────────────────────────────────── */ | |
| #app { | |
| display: grid; | |
| grid-template-rows: auto 1fr auto; | |
| height: 100vh; | |
| max-width: 900px; | |
| margin: 0 auto; | |
| padding: 0 16px; | |
| } | |
| /* ── Header ─────────────────────────────────────────────────────────── */ | |
| header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 18px 0 14px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .logo-group { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .logo-icon { | |
| width: 38px; height: 38px; | |
| border-radius: 10px; | |
| overflow: hidden; | |
| flex-shrink: 0; | |
| box-shadow: var(--shadow-btn); | |
| } | |
| .logo-icon img { | |
| width: 100%; height: 100%; | |
| object-fit: cover; | |
| display: block; | |
| } | |
| .logo-text h1 { | |
| font-size: 16px; | |
| font-weight: 700; | |
| letter-spacing: -.3px; | |
| background: var(--accent-grad-2); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .logo-text p { | |
| font-size: 11px; | |
| color: var(--text-muted); | |
| margin-top: 1px; | |
| letter-spacing: .2px; | |
| } | |
| .header-actions { | |
| display: flex; | |
| gap: 8px; | |
| align-items: center; | |
| } | |
| .badge { | |
| font-size: 11px; | |
| font-weight: 500; | |
| padding: 4px 10px; | |
| border-radius: 20px; | |
| border: 1px solid var(--border); | |
| color: var(--text-secondary); | |
| background: var(--bg-card); | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .badge::before { | |
| content: ''; | |
| width: 6px; height: 6px; | |
| border-radius: 50%; | |
| background: #22c55e; | |
| box-shadow: 0 0 6px #22c55e; | |
| animation: pulse 2s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: .4; } | |
| } | |
| .btn-icon { | |
| width: 34px; height: 34px; | |
| border-radius: 8px; | |
| border: 1px solid var(--border); | |
| background: var(--bg-card); | |
| color: var(--text-secondary); | |
| cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 14px; | |
| transition: all .2s; | |
| } | |
| .btn-icon:hover { | |
| background: var(--bg-hover); | |
| color: var(--text-primary); | |
| border-color: rgba(255,255,255,.14); | |
| } | |
| /* ── Messages ───────────────────────────────────────────────────────── */ | |
| #messages { | |
| overflow-y: auto; | |
| padding: 20px 0; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| scroll-behavior: smooth; | |
| } | |
| #messages::-webkit-scrollbar { width: 4px; } | |
| #messages::-webkit-scrollbar-track { background: transparent; } | |
| #messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } | |
| /* ── Empty state ─────────────────────────────────────────────────────── */ | |
| #empty-state { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 10px; | |
| text-align: center; | |
| padding: 40px; | |
| animation: fadeIn .5s ease; | |
| } | |
| #empty-state.hidden { display: none; } | |
| .empty-orb { | |
| width: 80px; height: 80px; | |
| border-radius: 50%; | |
| background: var(--accent-grad); | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 34px; | |
| box-shadow: var(--shadow-glow); | |
| margin-bottom: 8px; | |
| animation: float 4s ease-in-out infinite; | |
| } | |
| @keyframes float { | |
| 0%, 100% { transform: translateY(0); } | |
| 50% { transform: translateY(-8px); } | |
| } | |
| #empty-state h2 { | |
| font-size: 22px; | |
| font-weight: 700; | |
| background: var(--accent-grad-2); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| #empty-state p { | |
| font-size: 14px; | |
| color: var(--text-secondary); | |
| max-width: 420px; | |
| line-height: 1.6; | |
| } | |
| #empty-state p strong { | |
| color: var(--text-primary); | |
| font-weight: 600; | |
| } | |
| .suggestion-chips { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| justify-content: center; | |
| margin-top: 12px; | |
| } | |
| .chip { | |
| font-size: 12px; | |
| padding: 7px 14px; | |
| border-radius: 20px; | |
| border: 1px solid var(--border); | |
| background: var(--bg-card); | |
| color: var(--text-secondary); | |
| cursor: pointer; | |
| transition: all .2s; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .chip:hover { | |
| background: var(--bg-hover); | |
| border-color: var(--accent-1); | |
| color: var(--text-primary); | |
| } | |
| /* ── Message Bubbles ─────────────────────────────────────────────────── */ | |
| .message { | |
| display: flex; | |
| gap: 12px; | |
| animation: slideUp .3s ease; | |
| max-width: 100%; | |
| } | |
| @keyframes slideUp { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .message.user { flex-direction: row-reverse; } | |
| .avatar { | |
| width: 34px; height: 34px; | |
| border-radius: 10px; | |
| flex-shrink: 0; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 15px; | |
| } | |
| .avatar.user-av { | |
| background: linear-gradient(135deg, #f472b6, #8b5cf6); | |
| box-shadow: 0 2px 12px rgba(244,114,182,.3); | |
| } | |
| .avatar.ai-av { | |
| background: var(--accent-grad); | |
| box-shadow: 0 2px 12px rgba(139,92,246,.3); | |
| } | |
| .bubble { | |
| max-width: 75%; | |
| border-radius: var(--radius-lg); | |
| padding: 13px 17px; | |
| font-size: 14px; | |
| line-height: 1.65; | |
| word-break: break-word; | |
| position: relative; | |
| } | |
| .user .bubble { | |
| background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); | |
| border-radius: var(--radius-lg) var(--radius-lg) var(--radius-sm) var(--radius-lg); | |
| box-shadow: 0 4px 20px rgba(139,92,246,.25); | |
| color: #fff; | |
| } | |
| .ai .bubble { | |
| background: var(--bg-card); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-lg) var(--radius-lg) var(--radius-lg) var(--radius-sm); | |
| box-shadow: var(--shadow-card); | |
| } | |
| .bubble img.preview { | |
| max-width: 100%; | |
| max-height: 240px; | |
| object-fit: cover; | |
| border-radius: 10px; | |
| margin-bottom: 10px; | |
| display: block; | |
| } | |
| .bubble .text-content p { margin-bottom: 8px; } | |
| .bubble .text-content p:last-child { margin-bottom: 0; } | |
| .bubble .text-content code { | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 12.5px; | |
| background: rgba(255,255,255,.08); | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| } | |
| .bubble .text-content pre { | |
| background: rgba(0,0,0,.4); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 12px; | |
| overflow-x: auto; | |
| margin: 8px 0; | |
| } | |
| .bubble .text-content pre code { | |
| background: none; | |
| padding: 0; | |
| } | |
| /* typing indicator */ | |
| .typing-dots { | |
| display: flex; | |
| gap: 5px; | |
| align-items: center; | |
| padding: 4px 0; | |
| } | |
| .typing-dots span { | |
| width: 7px; height: 7px; | |
| border-radius: 50%; | |
| background: var(--accent-1); | |
| animation: bounce 1.2s ease infinite; | |
| } | |
| .typing-dots span:nth-child(2) { animation-delay: .15s; background: var(--accent-2); } | |
| .typing-dots span:nth-child(3) { animation-delay: .30s; background: var(--accent-3); } | |
| @keyframes bounce { | |
| 0%, 60%, 100% { transform: translateY(0); } | |
| 30% { transform: translateY(-6px); } | |
| } | |
| /* timestamp */ | |
| .msg-time { | |
| font-size: 10px; | |
| color: var(--text-muted); | |
| margin-top: 5px; | |
| padding: 0 4px; | |
| } | |
| .message.user .msg-time { text-align: right; } | |
| /* ── Input Area ──────────────────────────────────────────────────────── */ | |
| #input-area { | |
| padding: 14px 0 20px; | |
| border-top: 1px solid var(--border); | |
| } | |
| /* Image preview strip */ | |
| #image-preview-strip { | |
| display: none; | |
| gap: 8px; | |
| margin-bottom: 10px; | |
| flex-wrap: wrap; | |
| } | |
| #image-preview-strip.visible { display: flex; } | |
| .img-thumb { | |
| position: relative; | |
| width: 64px; height: 64px; | |
| border-radius: 10px; | |
| overflow: hidden; | |
| border: 1px solid var(--border); | |
| animation: fadeIn .2s ease; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: scale(.9); } | |
| to { opacity: 1; transform: scale(1); } | |
| } | |
| .img-thumb img { | |
| width: 100%; height: 100%; | |
| object-fit: cover; | |
| } | |
| .img-thumb-remove { | |
| position: absolute; | |
| top: 2px; right: 2px; | |
| width: 18px; height: 18px; | |
| border-radius: 50%; | |
| background: rgba(0,0,0,.75); | |
| border: none; | |
| cursor: pointer; | |
| color: #fff; | |
| font-size: 10px; | |
| display: flex; align-items: center; justify-content: center; | |
| transition: background .15s; | |
| } | |
| .img-thumb-remove:hover { background: #ef4444; } | |
| .input-row { | |
| display: flex; | |
| gap: 10px; | |
| align-items: flex-end; | |
| background: var(--bg-input); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-xl); | |
| padding: 10px 10px 10px 16px; | |
| transition: border-color .25s, box-shadow .25s; | |
| } | |
| .input-row:focus-within { | |
| border-color: var(--border-focus); | |
| box-shadow: 0 0 0 3px rgba(139,92,246,.12), var(--shadow-glow); | |
| } | |
| #prompt-input { | |
| flex: 1; | |
| background: none; | |
| border: none; | |
| outline: none; | |
| color: var(--text-primary); | |
| font-family: 'Inter', sans-serif; | |
| font-size: 14px; | |
| line-height: 1.5; | |
| resize: none; | |
| max-height: 160px; | |
| overflow-y: auto; | |
| } | |
| #prompt-input::placeholder { color: var(--text-muted); } | |
| #prompt-input::-webkit-scrollbar { width: 3px; } | |
| #prompt-input::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } | |
| .input-actions { | |
| display: flex; | |
| gap: 6px; | |
| align-items: center; | |
| } | |
| .action-btn { | |
| width: 36px; height: 36px; | |
| border-radius: 10px; | |
| border: 1px solid var(--border); | |
| background: var(--bg-card); | |
| color: var(--text-secondary); | |
| cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 15px; | |
| transition: all .2s; | |
| flex-shrink: 0; | |
| } | |
| .action-btn:hover { | |
| background: var(--bg-hover); | |
| color: var(--text-primary); | |
| border-color: rgba(255,255,255,.14); | |
| } | |
| #send-btn { | |
| width: 36px; height: 36px; | |
| border-radius: 10px; | |
| border: none; | |
| background: var(--accent-grad); | |
| color: #fff; | |
| cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 15px; | |
| transition: all .2s; | |
| flex-shrink: 0; | |
| box-shadow: var(--shadow-btn); | |
| } | |
| #send-btn:hover { | |
| transform: scale(1.06); | |
| box-shadow: 0 6px 24px rgba(139,92,246,.5); | |
| } | |
| #send-btn:active { transform: scale(.97); } | |
| #send-btn:disabled { opacity: .45; cursor: not-allowed; transform: none; } | |
| #send-btn.stop-mode { | |
| background: linear-gradient(135deg, #ef4444, #dc2626); | |
| box-shadow: 0 4px 20px rgba(239,68,68,.35); | |
| } | |
| .hint { | |
| font-size: 11px; | |
| color: var(--text-muted); | |
| text-align: center; | |
| margin-top: 8px; | |
| } | |
| /* ── URL input modal ─────────────────────────────────────────────────── */ | |
| /* ── Attach modal ────────────────────────────────────────────────────── */ | |
| #attach-modal-overlay { | |
| display: none; | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(0,0,0,.6); | |
| backdrop-filter: blur(4px); | |
| z-index: 100; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| #attach-modal-overlay.open { display: flex; } | |
| #attach-modal { | |
| background: var(--bg-card); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-xl); | |
| padding: 28px; | |
| width: 90%; | |
| max-width: 460px; | |
| box-shadow: var(--shadow-card); | |
| animation: slideUp .25s ease; | |
| } | |
| #attach-modal h3 { | |
| font-size: 16px; | |
| font-weight: 600; | |
| margin-bottom: 20px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| /* Upload drop zone */ | |
| .upload-zone { | |
| border: 1.5px dashed var(--border); | |
| border-radius: var(--radius-md); | |
| padding: 22px; | |
| text-align: center; | |
| cursor: pointer; | |
| transition: all .2s; | |
| margin-bottom: 16px; | |
| } | |
| .upload-zone:hover { | |
| border-color: var(--accent-1); | |
| background: rgba(139,92,246,.06); | |
| } | |
| .upload-zone .zone-icon { font-size: 28px; margin-bottom: 6px; } | |
| .upload-zone p { font-size: 13px; color: var(--text-secondary); line-height: 1.5; } | |
| .upload-zone span { color: var(--accent-1); font-weight: 500; } | |
| /* Divider */ | |
| .modal-divider { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-bottom: 16px; | |
| color: var(--text-muted); | |
| font-size: 12px; | |
| } | |
| .modal-divider::before, | |
| .modal-divider::after { | |
| content: ''; | |
| flex: 1; | |
| height: 1px; | |
| background: var(--border); | |
| } | |
| #url-input-field { | |
| width: 100%; | |
| background: var(--bg-input); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-md); | |
| padding: 11px 14px; | |
| font-family: 'Inter', sans-serif; | |
| font-size: 13px; | |
| color: var(--text-primary); | |
| outline: none; | |
| transition: border-color .2s; | |
| margin-bottom: 14px; | |
| } | |
| #url-input-field:focus { border-color: var(--border-focus); } | |
| #url-input-field::placeholder { color: var(--text-muted); } | |
| .modal-actions { | |
| display: flex; | |
| gap: 10px; | |
| justify-content: flex-end; | |
| } | |
| .modal-btn { | |
| padding: 9px 20px; | |
| border-radius: 10px; | |
| font-family: 'Inter', sans-serif; | |
| font-size: 13px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all .2s; | |
| } | |
| .modal-btn.cancel { | |
| background: var(--bg-input); | |
| border: 1px solid var(--border); | |
| color: var(--text-secondary); | |
| } | |
| .modal-btn.cancel:hover { background: var(--bg-hover); color: var(--text-primary); } | |
| .modal-btn.confirm { | |
| background: var(--accent-grad); | |
| border: none; | |
| color: #fff; | |
| box-shadow: var(--shadow-btn); | |
| } | |
| .modal-btn.confirm:hover { opacity: .88; } | |
| /* ── Scrollbar global ────────────────────────────────────────────────── */ | |
| * { | |
| scrollbar-width: thin; | |
| scrollbar-color: var(--border) transparent; | |
| } | |
| /* ── Responsive ──────────────────────────────────────────────────────── */ | |
| @media (max-width: 600px) { | |
| header { padding: 14px 0 10px; } | |
| .badge { display: none; } | |
| .bubble { max-width: 88%; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Unified Attach Modal --> | |
| <div id="attach-modal-overlay"> | |
| <div id="attach-modal"> | |
| <h3>📎 Attach Image</h3> | |
| <!-- File upload drop zone --> | |
| <label class="upload-zone" for="file-input"> | |
| <div class="zone-icon">🖼</div> | |
| <p><span>Click to upload</span> or drag & drop<br>PNG, JPG, GIF, WebP supported</p> | |
| </label> | |
| <input type="file" id="file-input" accept="image/*" multiple style="display:none" /> | |
| <div class="modal-divider">or paste a URL</div> | |
| <input id="url-input-field" type="url" placeholder="https://example.com/image.jpg" /> | |
| <div class="modal-actions"> | |
| <button class="modal-btn cancel" id="modal-cancel">Cancel</button> | |
| <button class="modal-btn confirm" id="modal-confirm">Add URL</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="app"> | |
| <!-- Header --> | |
| <header> | |
| <div class="logo-group"> | |
| <div class="logo-icon"><img src="https://cdn-avatars.huggingface.co/v1/production/uploads/676e38ad04af5bec20bc9faf/dUd-LsZEX0H_d4qefO_g6.jpeg" alt="MiniMax" /></div> | |
| <div class="logo-text"> | |
| <h1>MiniMax-M3</h1> | |
| <p>428B params · 1M context · MoE</p> | |
| </div> | |
| </div> | |
| <div class="header-actions"> | |
| <div class="badge">Online</div> | |
| <button class="btn-icon" id="clear-btn" title="Clear conversation">🗑️</button> | |
| </div> | |
| </header> | |
| <!-- Messages --> | |
| <div id="messages"> | |
| <div id="empty-state"> | |
| <div class="empty-orb"><img src="https://cdn-avatars.huggingface.co/v1/production/uploads/676e38ad04af5bec20bc9faf/dUd-LsZEX0H_d4qefO_g6.jpeg" alt="MiniMax" style="width:100%;height:100%;object-fit:cover;border-radius:50%;" /></div> | |
| <h2>MiniMax-M3</h2> | |
| <p>A native multimodal model with <strong>1M token context</strong>, ~<strong>428B parameters</strong> (~23B activated), and MiniMax Sparse Attention — delivering 9× prefill & 15× decode speedups at 1M context. Supports text, images, and video.</p> | |
| <div class="suggestion-chips"> | |
| <div class="chip" data-prompt="What are your key capabilities and how do you compare to other frontier models?">🧠 Key capabilities</div> | |
| <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, including the landmark, its location, and any notable features you can see.">🗽 Analyze an image</div> | |
| <div class="chip" data-prompt="Explain MiniMax Sparse Attention (MSA) and how it achieves 9× prefill and 15× decode speedups compared to standard attention at 1M context.">⚡ Explain MSA</div> | |
| <div class="chip" data-prompt="Write a Python function that uses binary search to find the index of a target value in a sorted list. Include type hints and a docstring.">💻 Write code</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Input Area --> | |
| <div id="input-area"> | |
| <div id="image-preview-strip"></div> | |
| <div class="input-row"> | |
| <textarea | |
| id="prompt-input" | |
| rows="1" | |
| placeholder="Ask anything — attach images with 📎…" | |
| ></textarea> | |
| <div class="input-actions"> | |
| <button class="action-btn" id="attach-btn" title="Attach image">📎</button> | |
| <button id="send-btn" title="Send message">➤</button> | |
| </div> | |
| </div> | |
| <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> | |
| </div> | |
| </div> | |
| <script> | |
| // ── State ───────────────────────────────────────────────────────────────── | |
| const history = []; // [{role, content}] content can be str or array | |
| const pendingImages = []; // {type:'url'|'file', url:string, dataUrl?:string} | |
| let isStreaming = false; | |
| let abortController = null; | |
| // ── DOM refs ─────────────────────────────────────────────────────────────── | |
| const messagesEl = document.getElementById('messages'); | |
| const promptInput = document.getElementById('prompt-input'); | |
| const sendBtn = document.getElementById('send-btn'); | |
| const clearBtn = document.getElementById('clear-btn'); | |
| const emptyState = document.getElementById('empty-state'); | |
| const imageStrip = document.getElementById('image-preview-strip'); | |
| const fileInput = document.getElementById('file-input'); | |
| const attachBtn = document.getElementById('attach-btn'); | |
| const attachModal = document.getElementById('attach-modal-overlay'); | |
| const urlField = document.getElementById('url-input-field'); | |
| const modalConfirm = document.getElementById('modal-confirm'); | |
| const modalCancel = document.getElementById('modal-cancel'); | |
| // ── Auto-resize textarea ────────────────────────────────────────────────── | |
| promptInput.addEventListener('input', () => { | |
| promptInput.style.height = 'auto'; | |
| promptInput.style.height = Math.min(promptInput.scrollHeight, 160) + 'px'; | |
| }); | |
| // ── Keyboard shortcuts ──────────────────────────────────────────────────── | |
| promptInput.addEventListener('keydown', e => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }); | |
| // ── Send button ─────────────────────────────────────────────────────────── | |
| sendBtn.addEventListener('click', () => { | |
| if (isStreaming) { | |
| stopStreaming(); | |
| } else { | |
| sendMessage(); | |
| } | |
| }); | |
| // ── Clear conversation ──────────────────────────────────────────────────── | |
| clearBtn.addEventListener('click', () => { | |
| history.length = 0; | |
| pendingImages.length = 0; | |
| messagesEl.innerHTML = ''; | |
| messagesEl.appendChild(emptyState); | |
| emptyState.classList.remove('hidden'); | |
| imageStrip.innerHTML = ''; | |
| imageStrip.classList.remove('visible'); | |
| }); | |
| // ── Attach modal (unified: file upload + URL) ───────────────────────────── | |
| attachBtn.addEventListener('click', () => { | |
| urlField.value = ''; | |
| attachModal.classList.add('open'); | |
| }); | |
| // File chosen from drop-zone closes modal automatically | |
| fileInput.addEventListener('change', () => { | |
| Array.from(fileInput.files).forEach(addFileImage); | |
| fileInput.value = ''; | |
| attachModal.classList.remove('open'); | |
| }); | |
| function addFileImage(file) { | |
| const reader = new FileReader(); | |
| reader.onload = e => { | |
| const dataUrl = e.target.result; | |
| pendingImages.push({ type: 'file', url: dataUrl, dataUrl }); | |
| renderImageThumb(dataUrl, pendingImages.length - 1); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| modalCancel.addEventListener('click', () => attachModal.classList.remove('open')); | |
| attachModal.addEventListener('click', e => { if (e.target === attachModal) attachModal.classList.remove('open'); }); | |
| urlField.addEventListener('keydown', e => { | |
| if (e.key === 'Enter') { e.preventDefault(); confirmUrl(); } | |
| if (e.key === 'Escape') attachModal.classList.remove('open'); | |
| }); | |
| modalConfirm.addEventListener('click', confirmUrl); | |
| function confirmUrl() { | |
| const url = urlField.value.trim(); | |
| if (!url) return; | |
| pendingImages.push({ type: 'url', url }); | |
| renderImageThumb(url, pendingImages.length - 1); | |
| attachModal.classList.remove('open'); | |
| } | |
| function renderImageThumb(src, idx) { | |
| imageStrip.classList.add('visible'); | |
| const thumb = document.createElement('div'); | |
| thumb.className = 'img-thumb'; | |
| thumb.dataset.idx = idx; | |
| thumb.innerHTML = ` | |
| <img src="${src}" alt="image preview" /> | |
| <button class="img-thumb-remove" title="Remove">✕</button> | |
| `; | |
| thumb.querySelector('.img-thumb-remove').addEventListener('click', () => { | |
| pendingImages.splice(idx, 1); | |
| thumb.remove(); | |
| if (pendingImages.length === 0) imageStrip.classList.remove('visible'); | |
| }); | |
| imageStrip.appendChild(thumb); | |
| } | |
| // ── Suggestion chips ────────────────────────────────────────────────────── | |
| document.querySelectorAll('.chip').forEach(chip => { | |
| chip.addEventListener('click', () => { | |
| const prompt = chip.dataset.prompt || ''; | |
| const img = chip.dataset.img || ''; | |
| promptInput.value = prompt; | |
| if (img) { | |
| pendingImages.push({ type: 'url', url: img }); | |
| renderImageThumb(img, pendingImages.length - 1); | |
| } | |
| promptInput.dispatchEvent(new Event('input')); | |
| sendMessage(); | |
| }); | |
| }); | |
| // ── Send message ────────────────────────────────────────────────────────── | |
| async function sendMessage() { | |
| const text = promptInput.value.trim(); | |
| if (!text && pendingImages.length === 0) return; | |
| if (isStreaming) return; | |
| // Build content array | |
| let content; | |
| if (pendingImages.length > 0) { | |
| content = []; | |
| if (text) content.push({ type: 'text', text }); | |
| pendingImages.forEach(img => { | |
| content.push({ | |
| type: 'image_url', | |
| image_url: { url: img.url } | |
| }); | |
| }); | |
| } else { | |
| content = text; | |
| } | |
| // Snapshot images for display before clearing | |
| const displayImages = pendingImages.map(i => i.url); | |
| // Add to history | |
| history.push({ role: 'user', content }); | |
| // Render user bubble | |
| renderUserMessage(text, displayImages); | |
| // Clear input | |
| promptInput.value = ''; | |
| promptInput.style.height = 'auto'; | |
| pendingImages.length = 0; | |
| imageStrip.innerHTML = ''; | |
| imageStrip.classList.remove('visible'); | |
| // Hide empty state | |
| emptyState.classList.add('hidden'); | |
| // Show AI typing indicator | |
| const aiEl = renderAITyping(); | |
| // Stream | |
| setStreaming(true); | |
| abortController = new AbortController(); | |
| try { | |
| const res = await fetch('/chat', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ messages: history }), | |
| signal: abortController.signal, | |
| }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let aiText = ''; | |
| // Replace typing indicator with actual bubble | |
| const aiContent = aiEl.querySelector('.text-content'); | |
| aiEl.querySelector('.typing-dots')?.replaceWith(aiContent); | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const chunk = decoder.decode(value, { stream: true }); | |
| const lines = chunk.split('\n'); | |
| for (const line of lines) { | |
| if (!line.startsWith('data: ')) continue; | |
| const data = line.slice(6).trim(); | |
| if (data === '[DONE]') break; | |
| try { | |
| const parsed = JSON.parse(data); | |
| if (parsed.error) throw new Error(parsed.error); | |
| if (parsed.token) { | |
| aiText += parsed.token; | |
| aiContent.innerHTML = formatMarkdown(aiText); | |
| scrollToBottom(); | |
| } | |
| } catch (_) {} | |
| } | |
| } | |
| // Save to history | |
| history.push({ role: 'assistant', content: aiText }); | |
| addTimestamp(aiEl.parentElement); | |
| } catch (err) { | |
| if (err.name !== 'AbortError') { | |
| const aiContent = aiEl.querySelector('.text-content') || aiEl; | |
| aiContent.innerHTML = `<span style="color:#ef4444">⚠ ${err.message}</span>`; | |
| } | |
| } finally { | |
| setStreaming(false); | |
| scrollToBottom(); | |
| } | |
| } | |
| // ── Stop streaming ───────────────────────────────────────────────────────── | |
| function stopStreaming() { | |
| if (abortController) abortController.abort(); | |
| setStreaming(false); | |
| } | |
| function setStreaming(val) { | |
| isStreaming = val; | |
| if (val) { | |
| sendBtn.innerHTML = '⏹'; | |
| sendBtn.classList.add('stop-mode'); | |
| sendBtn.title = 'Stop generation'; | |
| } else { | |
| sendBtn.innerHTML = '➤'; | |
| sendBtn.classList.remove('stop-mode'); | |
| sendBtn.title = 'Send message'; | |
| } | |
| } | |
| // ── Render helpers ───────────────────────────────────────────────────────── | |
| function renderUserMessage(text, images) { | |
| const div = document.createElement('div'); | |
| div.className = 'message user'; | |
| let imageHtml = images.map(url => | |
| `<img class="preview" src="${url}" alt="attached image" />` | |
| ).join(''); | |
| div.innerHTML = ` | |
| <div> | |
| <div class="bubble"> | |
| ${imageHtml} | |
| ${text ? `<div class="text-content">${escapeHtml(text)}</div>` : ''} | |
| </div> | |
| <div class="msg-time">${timeStr()}</div> | |
| </div> | |
| <div class="avatar user-av">👤</div> | |
| `; | |
| messagesEl.appendChild(div); | |
| scrollToBottom(); | |
| } | |
| function renderAITyping() { | |
| const div = document.createElement('div'); | |
| div.className = 'message ai'; | |
| div.innerHTML = ` | |
| <div class="avatar ai-av"><img src="https://cdn-avatars.huggingface.co/v1/production/uploads/676e38ad04af5bec20bc9faf/dUd-LsZEX0H_d4qefO_g6.jpeg" alt="MiniMax" style="width:100%;height:100%;object-fit:cover;border-radius:10px;" /></div> | |
| <div> | |
| <div class="bubble"> | |
| <div class="typing-dots"> | |
| <span></span><span></span><span></span> | |
| </div> | |
| <div class="text-content" style="display:none"></div> | |
| </div> | |
| </div> | |
| `; | |
| // After first token: show text-content, hide dots | |
| const dots = div.querySelector('.typing-dots'); | |
| const content = div.querySelector('.text-content'); | |
| // Swap on first render triggered externally | |
| div._activate = () => { | |
| dots.style.display = 'none'; | |
| content.style.display = ''; | |
| }; | |
| messagesEl.appendChild(div); | |
| scrollToBottom(); | |
| return div.querySelector('.bubble'); | |
| } | |
| // Patch: the bubble ref approach — let's get the bubble element working correctly | |
| function addTimestamp(msgEl) { | |
| if (!msgEl) return; | |
| const existing = msgEl.querySelector('.msg-time'); | |
| if (!existing) { | |
| const t = document.createElement('div'); | |
| t.className = 'msg-time'; | |
| t.textContent = timeStr(); | |
| msgEl.appendChild(t); | |
| } | |
| } | |
| function scrollToBottom() { | |
| messagesEl.scrollTop = messagesEl.scrollHeight; | |
| } | |
| function timeStr() { | |
| return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); | |
| } | |
| function escapeHtml(str) { | |
| return str | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"'); | |
| } | |
| // Very lightweight markdown renderer | |
| function formatMarkdown(text) { | |
| // code blocks | |
| text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => | |
| `<pre><code class="lang-${lang}">${escapeHtml(code.trim())}</code></pre>` | |
| ); | |
| // inline code | |
| text = text.replace(/`([^`]+)`/g, '<code>$1</code>'); | |
| // bold | |
| text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); | |
| // italic | |
| text = text.replace(/\*(.+?)\*/g, '<em>$1</em>'); | |
| // headings | |
| text = text.replace(/^### (.+)$/gm, '<h3 style="font-size:14px;font-weight:600;margin:10px 0 4px">$1</h3>'); | |
| text = text.replace(/^## (.+)$/gm, '<h2 style="font-size:15px;font-weight:600;margin:10px 0 4px">$1</h2>'); | |
| text = text.replace(/^# (.+)$/gm, '<h2 style="font-size:16px;font-weight:700;margin:10px 0 4px">$1</h2>'); | |
| // lists | |
| text = text.replace(/^- (.+)$/gm, '<li style="margin-left:16px;list-style:disc;margin-bottom:3px">$1</li>'); | |
| text = text.replace(/^(\d+)\. (.+)$/gm, '<li style="margin-left:16px;list-style:decimal;margin-bottom:3px">$2</li>'); | |
| // paragraphs (double newline) | |
| text = text.replace(/\n\n/g, '</p><p>'); | |
| // single newlines | |
| text = text.replace(/\n/g, '<br>'); | |
| return `<p>${text}</p>`; | |
| } | |
| // ── Fix streaming: swap dots on first token ─────────────────────────────── | |
| // Patch renderAITyping to work with the streaming loop correctly | |
| // The bubble already has .text-content hidden; we reveal it on first token. | |
| // Let's override the streaming section with proper element handling: | |
| const _origSend = sendMessage; | |
| // The streaming logic inside sendMessage already references aiEl as the bubble | |
| // and aiContent as .text-content. The typing dots are siblings. We need to | |
| // swap them on first token. Let's patch via MutationObserver on aiContent. | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Already loaded, nothing extra needed | |
| }); | |
| </script> | |
| <!-- Patch: fix the streaming swap logic after page load --> | |
| <script> | |
| // Override sendMessage with fixed streaming that properly swaps typing → content | |
| async function sendMessage() { | |
| const text = promptInput.value.trim(); | |
| if (!text && pendingImages.length === 0) return; | |
| if (isStreaming) return; | |
| let content; | |
| if (pendingImages.length > 0) { | |
| content = []; | |
| if (text) content.push({ type: 'text', text }); | |
| pendingImages.forEach(img => { | |
| content.push({ type: 'image_url', image_url: { url: img.url } }); | |
| }); | |
| } else { | |
| content = text; | |
| } | |
| const displayImages = pendingImages.map(i => i.url); | |
| history.push({ role: 'user', content }); | |
| renderUserMessage(text, displayImages); | |
| promptInput.value = ''; | |
| promptInput.style.height = 'auto'; | |
| pendingImages.length = 0; | |
| imageStrip.innerHTML = ''; | |
| imageStrip.classList.remove('visible'); | |
| emptyState.classList.add('hidden'); | |
| // Create AI message shell | |
| const msgDiv = document.createElement('div'); | |
| msgDiv.className = 'message ai'; | |
| msgDiv.innerHTML = ` | |
| <div class="avatar ai-av"><img src="https://cdn-avatars.huggingface.co/v1/production/uploads/676e38ad04af5bec20bc9faf/dUd-LsZEX0H_d4qefO_g6.jpeg" alt="MiniMax" style="width:100%;height:100%;object-fit:cover;border-radius:10px;" /></div> | |
| <div class="msg-wrapper"> | |
| <div class="bubble"> | |
| <div class="typing-dots"><span></span><span></span><span></span></div> | |
| <div class="text-content" style="display:none"></div> | |
| </div> | |
| </div> | |
| `; | |
| messagesEl.appendChild(msgDiv); | |
| scrollToBottom(); | |
| const dots = msgDiv.querySelector('.typing-dots'); | |
| const content2 = msgDiv.querySelector('.text-content'); | |
| setStreaming(true); | |
| abortController = new AbortController(); | |
| try { | |
| const res = await fetch('/chat', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ messages: history }), | |
| signal: abortController.signal, | |
| }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let aiText = ''; | |
| let firstToken = true; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const chunk = decoder.decode(value, { stream: true }); | |
| for (const line of chunk.split('\n')) { | |
| if (!line.startsWith('data: ')) continue; | |
| const data = line.slice(6).trim(); | |
| if (data === '[DONE]') break; | |
| try { | |
| const parsed = JSON.parse(data); | |
| if (parsed.error) throw new Error(parsed.error); | |
| if (parsed.token) { | |
| if (firstToken) { | |
| dots.style.display = 'none'; | |
| content2.style.display = ''; | |
| firstToken = false; | |
| } | |
| aiText += parsed.token; | |
| content2.innerHTML = formatMarkdown(aiText); | |
| scrollToBottom(); | |
| } | |
| } catch (_) {} | |
| } | |
| } | |
| history.push({ role: 'assistant', content: aiText }); | |
| // Add timestamp | |
| const wrapper = msgDiv.querySelector('.msg-wrapper'); | |
| const t = document.createElement('div'); | |
| t.className = 'msg-time'; | |
| t.textContent = timeStr(); | |
| wrapper.appendChild(t); | |
| } catch (err) { | |
| if (err.name !== 'AbortError') { | |
| dots.style.display = 'none'; | |
| content2.style.display = ''; | |
| content2.innerHTML = `<span style="color:#ef4444">⚠ ${err.message}</span>`; | |
| } | |
| } finally { | |
| setStreaming(false); | |
| scrollToBottom(); | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |