| <!DOCTYPE html> |
| <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> |
| |
| *, *::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; |
| --accent-2: #06b6d4; |
| --accent-3: #f472b6; |
| --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; |
| } |
| |
| |
| #app { |
| display: grid; |
| grid-template-rows: auto 1fr auto; |
| height: 100vh; |
| max-width: 900px; |
| margin: 0 auto; |
| padding: 0 16px; |
| } |
| |
| |
| 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 { |
| 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 { |
| 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 { |
| 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-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); } |
| } |
| |
| |
| .msg-time { |
| font-size: 10px; |
| color: var(--text-muted); |
| margin-top: 5px; |
| padding: 0 4px; |
| } |
| |
| .message.user .msg-time { text-align: right; } |
| |
| |
| #input-area { |
| padding: 14px 0 20px; |
| border-top: 1px solid var(--border); |
| } |
| |
| |
| #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; |
| } |
| |
| |
| |
| #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-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; } |
| |
| |
| .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-width: thin; |
| scrollbar-color: var(--border) transparent; |
| } |
| |
| |
| @media (max-width: 600px) { |
| header { padding: 14px 0 10px; } |
| .badge { display: none; } |
| .bubble { max-width: 88%; } |
| } |
| </style> |
| </head> |
| <body> |
|
|
| |
| <div id="attach-modal-overlay"> |
| <div id="attach-modal"> |
| <h3>📎 Attach Image</h3> |
|
|
| |
| <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> |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
| |
| const history = []; |
| const pendingImages = []; |
| let isStreaming = false; |
| let abortController = null; |
| |
| |
| 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'); |
| |
| |
| promptInput.addEventListener('input', () => { |
| promptInput.style.height = 'auto'; |
| promptInput.style.height = Math.min(promptInput.scrollHeight, 160) + 'px'; |
| }); |
| |
| |
| promptInput.addEventListener('keydown', e => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| sendMessage(); |
| } |
| }); |
| |
| |
| sendBtn.addEventListener('click', () => { |
| if (isStreaming) { |
| stopStreaming(); |
| } else { |
| sendMessage(); |
| } |
| }); |
| |
| |
| clearBtn.addEventListener('click', () => { |
| history.length = 0; |
| pendingImages.length = 0; |
| messagesEl.innerHTML = ''; |
| messagesEl.appendChild(emptyState); |
| emptyState.classList.remove('hidden'); |
| imageStrip.innerHTML = ''; |
| imageStrip.classList.remove('visible'); |
| }); |
| |
| |
| attachBtn.addEventListener('click', () => { |
| urlField.value = ''; |
| attachModal.classList.add('open'); |
| }); |
| |
| |
| 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); |
| } |
| |
| |
| 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(); |
| }); |
| }); |
| |
| |
| 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'); |
| |
| |
| const aiEl = renderAITyping(); |
| |
| |
| 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 = ''; |
| |
| |
| 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 (_) {} |
| } |
| } |
| |
| |
| 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(); |
| } |
| } |
| |
| |
| 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'; |
| } |
| } |
| |
| |
| 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> |
| `; |
| |
| |
| const dots = div.querySelector('.typing-dots'); |
| const content = div.querySelector('.text-content'); |
| |
| |
| div._activate = () => { |
| dots.style.display = 'none'; |
| content.style.display = ''; |
| }; |
| |
| messagesEl.appendChild(div); |
| scrollToBottom(); |
| return div.querySelector('.bubble'); |
| } |
| |
| |
| 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, '"'); |
| } |
| |
| |
| function formatMarkdown(text) { |
| |
| text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => |
| `<pre><code class="lang-${lang}">${escapeHtml(code.trim())}</code></pre>` |
| ); |
| |
| text = text.replace(/`([^`]+)`/g, '<code>$1</code>'); |
| |
| text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); |
| |
| text = text.replace(/\*(.+?)\*/g, '<em>$1</em>'); |
| |
| 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>'); |
| |
| 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>'); |
| |
| text = text.replace(/\n\n/g, '</p><p>'); |
| |
| text = text.replace(/\n/g, '<br>'); |
| |
| return `<p>${text}</p>`; |
| } |
| |
| |
| |
| |
| |
| |
| const _origSend = sendMessage; |
| |
| |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| |
| }); |
| |
| </script> |
|
|
| |
| <script> |
| |
| 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'); |
| |
| |
| 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 }); |
| |
| |
| 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> |
|
|