MiniMax-M3 / index.html
akhaliq's picture
akhaliq HF Staff
feat: replace boilerplate with real MiniMax-M3 model info (428B, 1M ctx, MSA, MoE)
a01d681
Raw
History Blame Contribute Delete
39.6 kB
<!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 &amp; 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 &amp; 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 &amp; 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// 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>