MiniCPM / index.html
Chris4K's picture
Upload 3 files
2888fc4 verified
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>πŸ”₯ MiniCPM Forge</title>
<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=Orbitron:wght@400;700;900&family=JetBrains+Mono:ital,wght@0,300;0,400;0,600;1,300&family=Outfit:wght@300;400;500;600&display=swap" rel="stylesheet" />
<style>
/* ─── Tokens ──────────────────────────────────────────────────────── */
:root {
--bg: #07070e;
--bg1: #0f0f1e;
--bg2: #16162c;
--bg3: #1e1e3a;
--border: #252545;
--border-l: #32325a;
--forge: #f97316;
--forge-glow: rgba(249,115,22,.18);
--forge-dim: rgba(249,115,22,.07);
--cool: #06b6d4;
--cool-glow: rgba(6,182,212,.18);
--cool-dim: rgba(6,182,212,.07);
--text: #e2e8f0;
--text2: #94a3b8;
--text3: #475569;
--green: #10b981;
--purple: #a855f7;
--pink: #ec4899;
--amber: #f59e0b;
--red: #ef4444;
--font-head: 'Orbitron', monospace;
--font-mono: 'JetBrains Mono', monospace;
--font-body: 'Outfit', sans-serif;
--radius: 8px;
--sidebar-w: 240px;
--panel-w: 300px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--font-body); overflow: hidden; }
/* ─── Animated bg grain ─────────────────────────────────────────────── */
body::before {
content: '';
position: fixed; inset: 0; z-index: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='.03'/%3E%3C/svg%3E");
pointer-events: none;
}
/* ─── Layout ─────────────────────────────────────────────────────────── */
#app {
position: relative; z-index: 1;
display: grid;
grid-template-rows: 52px 1fr 36px;
grid-template-columns: var(--sidebar-w) 1fr var(--panel-w);
grid-template-areas:
"header header header"
"sidebar chat panel"
"status status status";
height: 100vh;
}
/* ─── Header ──────────────────────────────────────────────────────────── */
#header {
grid-area: header;
display: flex; align-items: center; gap: 14px;
padding: 0 20px;
border-bottom: 1px solid var(--border);
background: linear-gradient(90deg, rgba(249,115,22,.06) 0%, transparent 60%);
}
.logo {
font-family: var(--font-head);
font-size: 15px; font-weight: 900;
letter-spacing: 3px;
color: var(--forge);
text-shadow: 0 0 20px var(--forge);
flex-shrink: 0;
}
.logo span { color: var(--cool); }
#header-model-badge {
font-family: var(--font-mono); font-size: 11px;
color: var(--text3);
padding: 3px 10px; border: 1px solid var(--border);
border-radius: 20px; letter-spacing: 1px;
}
.header-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
#speed-badge {
font-family: var(--font-mono); font-size: 11px;
color: var(--green); letter-spacing: 1px;
min-width: 90px;
}
.hdr-btn {
background: var(--bg3); border: 1px solid var(--border);
color: var(--text2); font-family: var(--font-mono); font-size: 11px;
padding: 4px 12px; border-radius: 4px; cursor: pointer;
transition: all .15s;
}
.hdr-btn:hover { border-color: var(--forge); color: var(--forge); }
/* ─── Sidebar ────────────────────────────────────────────────────────── */
#sidebar {
grid-area: sidebar;
border-right: 1px solid var(--border);
background: var(--bg1);
display: flex; flex-direction: column;
overflow-y: auto;
}
.sidebar-section {
padding: 14px 14px 8px;
font-family: var(--font-mono); font-size: 9px; letter-spacing: 2px;
color: var(--text3); text-transform: uppercase;
}
.model-card {
margin: 3px 10px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all .18s;
position: relative; overflow: hidden;
}
.model-card::before {
content: '';
position: absolute; inset: 0;
opacity: 0; transition: opacity .18s;
background: linear-gradient(135deg, var(--model-color, #ffffff11) 0%, transparent 70%);
}
.model-card:hover::before, .model-card.active::before { opacity: 1; }
.model-card:hover { border-color: var(--border-l); }
.model-card.active {
border-color: var(--model-color, var(--forge));
box-shadow: 0 0 14px -4px var(--model-color, var(--forge));
}
.model-card.loading { opacity: .65; pointer-events: none; }
.mc-name {
font-family: var(--font-mono); font-size: 12px; font-weight: 600;
color: var(--text); letter-spacing: .5px;
display: flex; align-items: center; gap: 6px;
}
.mc-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--model-color, var(--text3));
flex-shrink: 0; box-shadow: 0 0 6px var(--model-color, transparent);
}
.mc-tag {
font-family: var(--font-body); font-size: 11px; color: var(--text3);
margin-top: 4px; padding-left: 13px;
}
.mc-status {
font-family: var(--font-mono); font-size: 9px; letter-spacing: 1px;
margin-top: 6px; padding-left: 13px;
}
.mc-status.idle { color: var(--text3); }
.mc-status.loading { color: var(--amber); animation: pulse 1.2s ease-in-out infinite; }
.mc-status.ready { color: var(--green); }
.mc-status.api { color: var(--cool); }
.mc-status.error { color: var(--red); }
.mc-load-btn {
margin-top: 8px; margin-left: 13px;
font-family: var(--font-mono); font-size: 10px; letter-spacing: 1px;
background: var(--bg3); border: 1px solid var(--border-l);
color: var(--text2); padding: 3px 10px; border-radius: 4px;
cursor: pointer; transition: all .15s;
}
.mc-load-btn:hover { border-color: var(--forge); color: var(--forge); }
/* ─── Chat area ──────────────────────────────────────────────────────── */
#chat-area {
grid-area: chat;
display: flex; flex-direction: column;
background: var(--bg);
overflow: hidden;
}
#messages {
flex: 1; overflow-y: auto;
padding: 20px 24px;
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: 2px; }
.msg { display: flex; gap: 10px; max-width: 88%; animation: fadein .2s ease; }
.msg.user { flex-direction: row-reverse; align-self: flex-end; }
.msg.bot { align-self: flex-start; }
@keyframes fadein { from { opacity:0; transform: translateY(6px); } to { opacity:1; transform: translateY(0); } }
.msg-avatar {
width: 28px; height: 28px; border-radius: 50%;
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
font-size: 13px;
}
.msg.user .msg-avatar { background: var(--forge-dim); border: 1px solid var(--forge); }
.msg.bot .msg-avatar { background: var(--cool-dim); border: 1px solid var(--cool); }
.msg-bubble {
padding: 10px 14px;
border-radius: var(--radius);
font-family: var(--font-body); font-size: 14px; line-height: 1.65;
white-space: pre-wrap; word-break: break-word;
}
.msg.user .msg-bubble {
background: var(--forge-dim);
border: 1px solid var(--forge);
border-top-right-radius: 2px;
}
.msg.bot .msg-bubble {
background: var(--bg2);
border: 1px solid var(--border);
border-top-left-radius: 2px;
font-family: var(--font-mono); font-size: 13px;
}
.msg-bubble code {
font-family: var(--font-mono); font-size: 12px;
background: var(--bg3); padding: 2px 5px; border-radius: 3px;
}
.think-block {
color: var(--purple); opacity: .75;
font-style: italic; font-size: 12px;
border-left: 2px solid var(--purple);
padding-left: 8px; margin-bottom: 6px;
}
.cursor {
display: inline-block; width: 7px; height: 14px;
background: var(--cool); margin-left: 2px; vertical-align: text-bottom;
animation: blink .8s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }
.empty-state {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 12px;
color: var(--text3);
}
.empty-icon { font-size: 40px; opacity: .5; }
.empty-title {
font-family: var(--font-head); font-size: 14px; letter-spacing: 3px;
color: var(--text3);
}
.empty-hint {
font-family: var(--font-mono); font-size: 11px; color: var(--text3);
text-align: center; max-width: 300px; line-height: 1.7;
}
/* ─── Input bar ───────────────────────────────────────────────────────── */
#input-bar {
border-top: 1px solid var(--border);
background: var(--bg1);
padding: 12px 16px;
}
.input-row {
display: flex; gap: 8px; align-items: flex-end;
}
#msg-input {
flex: 1;
background: var(--bg2); border: 1px solid var(--border);
color: var(--text); font-family: var(--font-body); font-size: 14px;
padding: 10px 14px;
border-radius: var(--radius);
resize: none; min-height: 44px; max-height: 140px;
transition: border-color .15s;
outline: none;
}
#msg-input:focus { border-color: var(--cool); }
#msg-input::placeholder { color: var(--text3); }
.icon-btn {
width: 44px; height: 44px; flex-shrink: 0;
background: var(--bg2); border: 1px solid var(--border);
color: var(--text2); border-radius: var(--radius);
cursor: pointer; display: flex; align-items: center; justify-content: center;
font-size: 16px; transition: all .15s;
}
.icon-btn:hover { border-color: var(--border-l); color: var(--text); }
#send-btn {
background: var(--forge-dim); border-color: var(--forge); color: var(--forge);
font-family: var(--font-mono); font-size: 18px;
}
#send-btn:hover { background: var(--forge); color: var(--bg); }
#send-btn:disabled { opacity: .4; cursor: not-allowed; }
.input-hints {
margin-top: 6px; display: flex; gap: 12px;
font-family: var(--font-mono); font-size: 10px; color: var(--text3);
}
/* ─── Right panel ─────────────────────────────────────────────────────── */
#panel {
grid-area: panel;
border-left: 1px solid var(--border);
background: var(--bg1);
display: flex; flex-direction: column; overflow-y: auto;
}
.panel-section {
padding: 14px 16px 8px;
font-family: var(--font-mono); font-size: 9px; letter-spacing: 2px;
color: var(--text3); text-transform: uppercase;
border-bottom: 1px solid var(--border);
}
.panel-body { padding: 14px 16px; }
/* Image upload */
#drop-zone {
border: 1px dashed var(--border-l); border-radius: var(--radius);
padding: 20px; text-align: center; cursor: pointer;
transition: all .18s; background: var(--bg);
}
#drop-zone:hover, #drop-zone.drag-over { border-color: var(--cool); background: var(--cool-dim); }
#drop-zone.has-image { border-color: var(--green); border-style: solid; }
.dz-icon { font-size: 24px; margin-bottom: 6px; }
.dz-text { font-family: var(--font-mono); font-size: 11px; color: var(--text3); }
#file-input { display: none; }
#preview-img { max-width: 100%; max-height: 160px; border-radius: 4px; margin-top: 8px; display: none; }
.clear-img-btn {
margin-top: 8px; width: 100%;
background: transparent; border: 1px solid var(--border);
color: var(--red); font-family: var(--font-mono); font-size: 11px;
padding: 5px; border-radius: 4px; cursor: pointer; transition: all .15s;
display: none;
}
.clear-img-btn:hover { background: rgba(239,68,68,.1); }
/* Settings sliders */
.param-row {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 10px;
}
.param-label { font-family: var(--font-mono); font-size: 11px; color: var(--text2); }
.param-val { font-family: var(--font-mono); font-size: 11px; color: var(--cool); min-width: 36px; text-align: right; }
input[type="range"] {
width: 100%; accent-color: var(--cool);
margin: 4px 0 8px; cursor: pointer;
}
.toggle-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px;
}
.toggle-label { font-family: var(--font-mono); font-size: 11px; color: var(--text2); }
.toggle {
position: relative; width: 36px; height: 20px;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0; background: var(--bg3);
border: 1px solid var(--border); border-radius: 20px; cursor: pointer;
transition: .2s;
}
.toggle-slider::after {
content: ''; position: absolute; top: 3px; left: 3px;
width: 12px; height: 12px; border-radius: 50%;
background: var(--text3); transition: .2s;
}
.toggle input:checked + .toggle-slider { background: var(--cool-dim); border-color: var(--cool); }
.toggle input:checked + .toggle-slider::after { background: var(--cool); transform: translateX(16px); }
/* Stats */
.stat-row {
display: flex; justify-content: space-between;
margin-bottom: 8px;
font-family: var(--font-mono); font-size: 11px;
}
.stat-key { color: var(--text3); }
.stat-value { color: var(--text); }
/* Clear chat */
.danger-btn {
width: 100%;
background: transparent; border: 1px solid var(--border);
color: var(--red); font-family: var(--font-mono); font-size: 11px; letter-spacing: 1px;
padding: 7px; border-radius: 4px; cursor: pointer; transition: all .15s;
}
.danger-btn:hover { background: rgba(239,68,68,.08); border-color: var(--red); }
/* ─── Status bar ──────────────────────────────────────────────────────── */
#status-bar {
grid-area: status;
border-top: 1px solid var(--border);
background: var(--bg1);
display: flex; align-items: center; gap: 20px;
padding: 0 16px;
font-family: var(--font-mono); font-size: 10px; color: var(--text3);
letter-spacing: .5px;
}
#status-bar .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--green); margin-right: 4px;
box-shadow: 0 0 6px var(--green);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
#status-text { color: var(--text2); }
/* ─── Toast notification ─────────────────────────────────────────────── */
#toast {
position: fixed; bottom: 50px; left: 50%; transform: translateX(-50%);
background: var(--bg3); border: 1px solid var(--border-l);
color: var(--text); font-family: var(--font-mono); font-size: 12px;
padding: 8px 18px; border-radius: 20px;
opacity: 0; pointer-events: none;
transition: opacity .2s; z-index: 100;
}
#toast.show { opacity: 1; }
/* ─── Responsive ─────────────────────────────────────────────────────── */
@media (max-width: 900px) {
:root { --sidebar-w: 0px; --panel-w: 0px; }
#app {
grid-template-columns: 0 1fr 0;
grid-template-areas: "header header header" "sidebar chat panel" "status status status";
}
#sidebar, #panel { display: none; }
}
</style>
</head>
<body>
<div id="app">
<!-- ── Header ─────────────────────────────────────────────────── -->
<header id="header">
<div class="logo">MINI<span>CPM</span> FORGE</div>
<div id="header-model-badge">NO MODEL</div>
<div class="header-right">
<span id="speed-badge">β€” tok/s</span>
<button class="hdr-btn" onclick="clearChat()">NEW CHAT</button>
</div>
</header>
<!-- ── Sidebar ────────────────────────────────────────────────── -->
<aside id="sidebar">
<div class="sidebar-section">MODELS</div>
<div id="model-list">
<!-- injected by JS -->
</div>
<div style="flex:1"></div>
<div style="padding:12px 10px; font-family:var(--font-mono); font-size:10px; color:var(--text3); border-top:1px solid var(--border);">
build-small-hackathon Β· llama.cpp
</div>
</aside>
<!-- ── Chat ───────────────────────────────────────────────────── -->
<main id="chat-area">
<div id="messages">
<div class="empty-state" id="empty-state">
<div class="empty-icon">πŸ”₯</div>
<div class="empty-title">MINICPM FORGE</div>
<div class="empty-hint">Select a model from the sidebar, then load it.<br>Five tiny models β€” one interface.</div>
</div>
</div>
<div id="input-bar">
<div class="input-row">
<button class="icon-btn" id="img-btn" title="Attach image (vision models only)" onclick="document.getElementById('file-input').click()">πŸ–Ό</button>
<input type="file" id="file-input" accept="image/*" onchange="handleFileSelect(event)" />
<textarea id="msg-input" placeholder="Ask something..." rows="1"
onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<button class="icon-btn" id="send-btn" onclick="sendMessage()" title="Send (Enter)">➀</button>
</div>
<div class="input-hints">
<span>⏎ send</span>
<span>β‡§βŽ newline</span>
<span id="hint-model">select a model first</span>
</div>
</div>
</main>
<!-- ── Right Panel ────────────────────────────────────────────── -->
<aside id="panel">
<div class="panel-section">IMAGE INPUT</div>
<div class="panel-body">
<div id="drop-zone" onclick="document.getElementById('file-input').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="handleDrop(event)">
<div class="dz-icon">πŸ“·</div>
<div class="dz-text">Drop image or click<br>Vision models only</div>
<img id="preview-img" alt="preview" />
</div>
<button class="clear-img-btn" id="clear-img-btn" onclick="clearImage()">βœ• Remove image</button>
</div>
<div class="panel-section">PARAMETERS</div>
<div class="panel-body">
<div class="param-row">
<span class="param-label">Temperature</span>
<span class="param-val" id="val-temp">0.7</span>
</div>
<input type="range" min="0" max="2" step="0.05" value="0.7" id="sl-temp"
oninput="document.getElementById('val-temp').textContent=parseFloat(this.value).toFixed(2)">
<div class="param-row">
<span class="param-label">Top-p</span>
<span class="param-val" id="val-topp">0.8</span>
</div>
<input type="range" min="0" max="1" step="0.05" value="0.8" id="sl-topp"
oninput="document.getElementById('val-topp').textContent=parseFloat(this.value).toFixed(2)">
<div class="param-row">
<span class="param-label">Max tokens</span>
<span class="param-val" id="val-maxt">1024</span>
</div>
<input type="range" min="64" max="4096" step="64" value="1024" id="sl-maxt"
oninput="document.getElementById('val-maxt').textContent=this.value">
<div class="toggle-row" style="margin-top:4px;">
<span class="toggle-label">Show thinking</span>
<label class="toggle">
<input type="checkbox" id="toggle-think" checked />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="panel-section">SESSION STATS</div>
<div class="panel-body">
<div class="stat-row"><span class="stat-key">Active model</span><span class="stat-value" id="stat-model">β€”</span></div>
<div class="stat-row"><span class="stat-key">Tokens gen.</span><span class="stat-value" id="stat-tokens">0</span></div>
<div class="stat-row"><span class="stat-key">Messages</span><span class="stat-value" id="stat-msgs">0</span></div>
<div class="stat-row"><span class="stat-key">Last speed</span><span class="stat-value" id="stat-speed">β€”</span></div>
<div style="margin-top:14px;">
<button class="danger-btn" onclick="clearChat()">βœ• CLEAR CHAT</button>
</div>
</div>
</aside>
<!-- ── Status bar ─────────────────────────────────────────────── -->
<footer id="status-bar">
<span><span class="dot"></span><span id="status-text">READY</span></span>
<span id="status-ctx">CTX: β€”</span>
<span id="status-hw">llama.cpp</span>
<span style="margin-left:auto; color:var(--text3)">MiniCPM Forge Β· build-small-hackathon</span>
</footer>
</div>
<div id="toast"></div>
<script>
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
let models = {};
let activeModel = null;
let history = []; // [{user, assistant}]
let imageb64 = null;
let streaming = false;
let totalTokens = 0;
let sessionMsgs = 0;
// ─────────────────────────────────────────────────────────────────────────────
// Startup
// ─────────────────────────────────────────────────────────────────────────────
async function init() {
try {
const res = await fetch('/api/models');
models = await res.json();
renderSidebar();
setStatus('READY Β· ' + Object.keys(models).length + ' models available');
} catch (e) {
setStatus('ERROR loading models: ' + e.message);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Sidebar rendering
// ─────────────────────────────────────────────────────────────────────────────
function renderSidebar() {
const list = document.getElementById('model-list');
list.innerHTML = '';
for (const [id, m] of Object.entries(models)) {
const card = document.createElement('div');
card.className = 'model-card' + (id === activeModel ? ' active' : '');
card.id = 'mc-' + id;
card.style.setProperty('--model-color', m.color);
card.onclick = () => selectModel(id);
const statusTxt = m.api ? 'API MODE' : (m.status || 'idle').toUpperCase();
const statusCls = m.api ? 'api' : (m.status || 'idle');
card.innerHTML = `
<div class="mc-name">
<span class="mc-dot"></span>
${m.name}
</div>
<div class="mc-tag">${m.tag}</div>
<div class="mc-status ${statusCls}" id="mcs-${id}">${statusTxt}</div>
${!m.api && m.status !== 'ready' ? `<button class="mc-load-btn" onclick="event.stopPropagation();loadModel('${id}')">⬇ LOAD</button>` : ''}
`;
list.appendChild(card);
}
}
function selectModel(id) {
activeModel = id;
const m = models[id];
// Update all cards
document.querySelectorAll('.model-card').forEach(c => c.classList.remove('active'));
const card = document.getElementById('mc-' + id);
if (card) card.classList.add('active');
document.getElementById('header-model-badge').textContent = m.name.toUpperCase();
document.getElementById('hint-model').textContent = m.vision ? 'vision enabled' : 'text only';
document.getElementById('stat-model').textContent = m.name;
document.getElementById('status-ctx').textContent = 'CTX: ' + m.ctx;
document.getElementById('img-btn').style.opacity = m.vision ? '1' : '0.4';
// Show thinking toggle only for thinking-capable models
document.querySelector('.toggle-row').style.display = m.thinking ? 'flex' : 'none';
setStatus('Model: ' + m.name + ' Β· ' + (m.api ? 'API mode' : m.status || 'idle'));
toast('Switched to ' + m.name);
}
async function loadModel(id) {
const btn = document.querySelector(`#mc-${id} .mc-load-btn`);
if (btn) { btn.textContent = '⏳ LOADING…'; btn.disabled = true; }
const statusEl = document.getElementById('mcs-' + id);
if (statusEl) { statusEl.className = 'mc-status loading'; statusEl.textContent = 'LOADING…'; }
setStatus('Loading ' + models[id].name + ' from HF Hub…');
try {
const res = await fetch('/api/load', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model_id: id})
});
const data = await res.json();
if (data.status === 'ready' || data.status === 'api') {
models[id].status = data.status;
if (statusEl) { statusEl.className = 'mc-status ' + data.status; statusEl.textContent = data.status.toUpperCase(); }
if (btn) btn.remove();
setStatus(models[id].name + ' ready');
toast(models[id].name + ' loaded βœ“');
} else {
// Poll status
pollModelStatus(id);
}
} catch (e) {
setStatus('Load error: ' + e.message);
if (btn) { btn.textContent = '⬇ RETRY'; btn.disabled = false; }
}
}
async function pollModelStatus(id) {
const statusEl = document.getElementById('mcs-' + id);
for (let i = 0; i < 120; i++) {
await new Promise(r => setTimeout(r, 3000));
try {
const res = await fetch('/api/models');
const fresh = await res.json();
const s = fresh[id]?.status || 'idle';
if (statusEl) { statusEl.className = 'mc-status ' + s.split(':')[0]; statusEl.textContent = s.toUpperCase().split(':')[0]; }
if (s === 'ready') {
models[id].status = 'ready';
const btn = document.querySelector(`#mc-${id} .mc-load-btn`);
if (btn) btn.remove();
setStatus(models[id].name + ' ready');
toast(models[id].name + ' loaded βœ“');
return;
}
if (s.startsWith('error')) {
setStatus('Error: ' + s);
return;
}
} catch (_) {}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Chat
// ─────────────────────────────────────────────────────────────────────────────
function sendMessage() {
if (streaming) return;
const input = document.getElementById('msg-input');
const text = input.value.trim();
if (!text && !imageb64) return;
if (!activeModel) { toast('Select a model first'); return; }
input.value = '';
autoResize(input);
appendUserMsg(text, imageb64);
history.push({user: text, assistant: ''});
sessionMsgs++;
document.getElementById('stat-msgs').textContent = sessionMsgs;
document.getElementById('empty-state')?.remove();
const params = {
temperature: parseFloat(document.getElementById('sl-temp').value),
top_p: parseFloat(document.getElementById('sl-topp').value),
max_tokens: parseInt(document.getElementById('sl-maxt').value),
thinking_mode: document.getElementById('toggle-think').checked,
};
const capturedImage = imageb64;
imageb64 = null;
clearImage();
streamResponse(text, params, capturedImage);
}
async function streamResponse(message, params, imageB64) {
streaming = true;
document.getElementById('send-btn').disabled = true;
setStatus('Generating…');
const bubble = appendBotMsg('');
const cursor = document.createElement('span');
cursor.className = 'cursor';
bubble.appendChild(cursor);
let fullText = '';
let lastSpeed = 0;
try {
const resp = await fetch('/stream/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
model_id: activeModel,
message, history: history.slice(0, -1),
image_b64: imageB64,
params
})
});
const reader = resp.body.getReader();
const dec = new TextDecoder();
while (true) {
const {done, value} = await reader.read();
if (done) break;
const lines = dec.decode(value, {stream: true}).split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6).trim();
if (!raw) continue;
try {
const ev = JSON.parse(raw);
if (ev.done) {
cursor.remove();
setStatus('Done Β· ' + (ev.total || 0) + ' tokens');
document.getElementById('stat-tokens').textContent = (totalTokens += (ev.total || 0));
break;
}
if (ev.token) {
fullText += ev.token;
bubble.innerHTML = ''; // re-render
bubble.appendChild(renderMarkdown(fullText));
const c2 = document.createElement('span'); c2.className = 'cursor';
bubble.appendChild(c2);
scrollBottom();
}
if (ev.speed) {
lastSpeed = ev.speed;
document.getElementById('speed-badge').textContent = ev.speed + ' tok/s';
document.getElementById('stat-speed').textContent = ev.speed + ' tok/s';
}
} catch (_) {}
}
}
} catch (e) {
bubble.textContent = '⚠ ' + e.message;
cursor.remove();
setStatus('Error: ' + e.message);
}
// Update history
if (history.length > 0) history[history.length-1].assistant = fullText;
streaming = false;
document.getElementById('send-btn').disabled = false;
cursor.remove();
}
function renderMarkdown(text) {
// Minimal: handle <think>...</think>, code blocks, inline code
const frag = document.createDocumentFragment();
// Extract think blocks
const thinkRe = /<think>([\s\S]*?)<\/think>/g;
let lastIdx = 0, m;
while ((m = thinkRe.exec(text)) !== null) {
if (m.index > lastIdx) {
const span = document.createElement('span');
span.textContent = text.slice(lastIdx, m.index);
frag.appendChild(span);
}
const think = document.createElement('div');
think.className = 'think-block';
think.textContent = 'πŸ€” ' + m[1].trim();
frag.appendChild(think);
lastIdx = m.index + m[0].length;
}
const rest = text.slice(lastIdx);
// Code blocks ```...```
const codeRe = /```[\w]*\n?([\s\S]*?)```/g;
let li = 0;
const span = document.createElement('span');
let cm;
while ((cm = codeRe.exec(rest)) !== null) {
span.appendChild(document.createTextNode(rest.slice(li, cm.index)));
const pre = document.createElement('pre');
pre.style.cssText = 'background:var(--bg3);padding:8px 12px;border-radius:6px;margin:6px 0;overflow-x:auto;font-size:12px;';
pre.appendChild(document.createTextNode(cm[1]));
span.appendChild(pre);
li = cm.index + cm[0].length;
}
span.appendChild(document.createTextNode(rest.slice(li)));
frag.appendChild(span);
return frag;
}
// ─────────────────────────────────────────────────────────────────────────────
// DOM helpers
// ─────────────────────────────────────────────────────────────────────────────
function appendUserMsg(text, imgB64) {
const msgs = document.getElementById('messages');
const row = document.createElement('div');
row.className = 'msg user';
const avatar = document.createElement('div');
avatar.className = 'msg-avatar'; avatar.textContent = 'πŸ‘€';
const bubble = document.createElement('div');
bubble.className = 'msg-bubble';
if (imgB64) {
const img = document.createElement('img');
img.src = `data:image/png;base64,${imgB64}`;
img.style.cssText = 'max-width:200px;max-height:150px;border-radius:6px;display:block;margin-bottom:6px;';
bubble.appendChild(img);
}
bubble.appendChild(document.createTextNode(text || ''));
row.appendChild(bubble);
row.appendChild(avatar);
msgs.appendChild(row);
scrollBottom();
}
function appendBotMsg(text) {
const msgs = document.getElementById('messages');
const row = document.createElement('div');
row.className = 'msg bot';
const avatar = document.createElement('div');
avatar.className = 'msg-avatar'; avatar.textContent = 'πŸ”₯';
const bubble = document.createElement('div');
bubble.className = 'msg-bubble';
bubble.textContent = text;
row.appendChild(avatar);
row.appendChild(bubble);
msgs.appendChild(row);
scrollBottom();
return bubble;
}
function scrollBottom() {
const msgs = document.getElementById('messages');
msgs.scrollTop = msgs.scrollHeight;
}
function clearChat() {
history = [];
sessionMsgs = 0;
document.getElementById('stat-msgs').textContent = '0';
const msgs = document.getElementById('messages');
msgs.innerHTML = '';
const empty = document.createElement('div');
empty.className = 'empty-state'; empty.id = 'empty-state';
empty.innerHTML = '<div class="empty-icon">πŸ”₯</div><div class="empty-title">MINICPM FORGE</div><div class="empty-hint">Select a model from the sidebar, then load it.<br>Five tiny models β€” one interface.</div>';
msgs.appendChild(empty);
}
// ─────────────────────────────────────────────────────────────────────────────
// Image handling
// ─────────────────────────────────────────────────────────────────────────────
function handleFileSelect(e) {
const file = e.target.files[0];
if (file) loadImageFile(file);
}
function handleDrop(e) {
e.preventDefault();
document.getElementById('drop-zone').classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) loadImageFile(file);
}
function loadImageFile(file) {
const reader = new FileReader();
reader.onload = ev => {
const dataUrl = ev.target.result;
imageb64 = dataUrl.split(',')[1];
const preview = document.getElementById('preview-img');
preview.src = dataUrl;
preview.style.display = 'block';
document.getElementById('drop-zone').classList.add('has-image');
document.getElementById('clear-img-btn').style.display = 'block';
toast('Image ready');
};
reader.readAsDataURL(file);
}
function clearImage() {
imageb64 = null;
const preview = document.getElementById('preview-img');
preview.style.display = 'none'; preview.src = '';
document.getElementById('drop-zone').classList.remove('has-image');
document.getElementById('clear-img-btn').style.display = 'none';
document.getElementById('file-input').value = '';
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
function handleKey(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
}
function autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
}
function setStatus(msg) {
document.getElementById('status-text').textContent = msg.toUpperCase();
}
let toastTimer;
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 2200);
}
// ─────────────────────────────────────────────────────────────────────────────
init();
</script>
</body>
</html>