lfm3-studio / studio.html
akhaliq's picture
akhaliq HF Staff
status bar shows connecting / waiting / streaming states
7cbd347 verified
Raw History Blame Contribute Delete
27.5 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LFM3 Studio — Liquid AI</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/inter@5/index.css" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap" />
<style>
:root {
--purple: #6c4fe0;
--purple-soft: rgba(108, 79, 224, 0.08);
--purple-mid: rgba(108, 79, 224, 0.18);
--ink: #0a0a0a;
--muted: #6b7280;
--dim: #9ca3af;
--line: #e7e7e7;
--panel: #fafafa;
--radius: 16px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
[hidden] { display: none !important; }
html { height: 100%; }
body {
font-family: "Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif;
color: var(--ink); background: #ffffff; min-height: 100vh;
padding-bottom: 40px; /* status bar */
-webkit-font-smoothing: antialiased;
}
/* ------- top bar ------- */
.topbar {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; gap: 14px; padding: 12px 24px;
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 9px; font-weight: 650; font-size: 0.95rem; white-space: nowrap; }
.brand .mark { width: 22px; height: 22px; display: block; flex-shrink: 0; }
.brand .brand-sub { color: var(--muted); font-weight: 500; padding-left: 12px; margin-left: 4px; border-left: 1px solid var(--line); }
.spacer { flex: 1; }
.toplink { color: var(--muted); text-decoration: none; font-size: 0.84rem; font-weight: 550; white-space: nowrap; transition: color 0.15s; }
.toplink:hover { color: var(--ink); }
.toplink.cta {
color: #fff; background: var(--ink); border-radius: 999px; padding: 8px 16px;
transition: background 0.15s;
}
.toplink.cta:hover { background: var(--purple); }
/* ------- hero ------- */
main { display: flex; flex-direction: column; align-items: center; }
.hero { text-align: center; padding: 64px 20px 30px; max-width: 860px; }
.badge {
display: inline-flex; align-items: center; gap: 8px;
font-size: 0.78rem; font-weight: 550; color: var(--purple);
background: var(--purple-soft); border: 1px solid var(--purple-mid);
border-radius: 999px; padding: 7px 16px; margin-bottom: 26px;
}
.badge-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--purple); animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
.hero h1 {
font-family: "Instrument Serif", Georgia, serif; font-weight: 400;
font-size: clamp(2.6rem, 6.4vw, 4.6rem); letter-spacing: -0.015em; line-height: 1.04;
}
.hero h1 em { color: var(--purple); }
.hero .sub {
margin: 20px auto 0; max-width: 620px;
font-size: clamp(0.95rem, 1.6vw, 1.08rem); color: var(--muted); font-weight: 450; line-height: 1.55;
}
/* ------- console ------- */
.console {
width: min(820px, calc(100vw - 32px));
background: #fff; border: 1px solid var(--line); border-radius: 22px;
box-shadow: 0 18px 50px rgba(10, 10, 10, 0.06);
overflow: hidden;
}
.console-head {
display: flex; align-items: center; gap: 10px; padding: 13px 18px;
border-bottom: 1px solid var(--line); background: var(--panel);
}
.console-head .title { font-size: 0.84rem; font-weight: 650; }
.engine-state {
margin-left: auto; display: inline-flex; align-items: center; gap: 7px;
font-size: 0.74rem; font-weight: 600; color: var(--muted);
}
.engine-state .dot { width: 8px; height: 8px; border-radius: 50%; background: #d1d5db; transition: background 0.3s; }
.engine-state.warming .dot { background: #f59e0b; animation: pulse 1.4s infinite; }
.engine-state.ready .dot { background: #22c55e; }
.engine-state.error .dot { background: #ef4444; }
.messages { padding: 20px 18px 8px; display: flex; flex-direction: column; gap: 18px; max-height: 58vh; overflow-y: auto; }
.messages:empty { display: none; }
.msg { display: flex; flex-direction: column; gap: 6px; max-width: 92%; }
.msg .who { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--dim); }
.msg.user { align-self: flex-end; align-items: flex-end; }
.msg.user .bubble {
background: var(--purple-soft); border: 1px solid var(--purple-mid);
border-radius: 14px 14px 4px 14px; padding: 10px 14px; font-size: 0.94rem; line-height: 1.55;
white-space: pre-wrap; word-break: break-word;
}
.msg.assistant .bubble { font-size: 0.94rem; line-height: 1.65; word-break: break-word; }
.msg.assistant .bubble p { margin: 0 0 0.6em; }
.msg.assistant .bubble p:last-child { margin-bottom: 0; }
.msg.assistant .bubble code {
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.86em;
background: var(--panel); border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px;
}
.msg.assistant .bubble pre {
background: #0d0d0f; color: #e5e7eb; border-radius: 10px; padding: 12px 14px;
overflow-x: auto; margin: 0.5em 0; font-size: 0.85em; line-height: 1.5;
}
.msg.assistant .bubble pre code { background: none; border: none; padding: 0; color: inherit; }
.think {
border: 1px solid var(--line); border-left: 3px solid var(--purple);
border-radius: 10px; background: var(--panel); margin-bottom: 8px;
}
.think summary {
list-style: none; cursor: pointer; padding: 8px 12px; font-size: 0.76rem; font-weight: 650;
color: var(--purple); display: flex; align-items: center; gap: 7px; user-select: none;
}
.think summary::-webkit-details-marker { display: none; }
.think summary::after { content: "+"; margin-left: auto; color: var(--dim); font-weight: 500; }
.think[open] summary::after { content: "−"; }
.think .think-body {
padding: 0 12px 10px; font-size: 0.82rem; line-height: 1.55; color: var(--muted);
white-space: pre-wrap; max-height: 260px; overflow-y: auto;
}
.cursor { display: inline-block; width: 8px; height: 1.05em; background: var(--purple); vertical-align: text-bottom; animation: pulse 0.9s infinite; border-radius: 2px; }
.msg .meta { font-size: 0.72rem; color: var(--dim); font-variant-numeric: tabular-nums; }
/* composer */
.composer { padding: 14px 16px 16px; border-top: 1px solid var(--line); }
.input-row { display: flex; gap: 10px; align-items: flex-end; }
textarea, select, input[type="number"], input[type="text"] {
font-family: inherit; color: var(--ink);
background: #fff; border: 1px solid var(--line); border-radius: 12px; outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
textarea:focus, select:focus, input:focus { border-color: var(--purple); box-shadow: 0 0 0 3px var(--purple-soft); }
textarea { width: 100%; padding: 12px 14px; font-size: 0.95rem; line-height: 1.5; resize: none; }
textarea::placeholder { color: var(--dim); }
.send {
border: none; border-radius: 999px; padding: 12px 22px; font: 650 0.9rem/1 inherit; font-family: inherit;
background: var(--ink); color: #fff; cursor: pointer; transition: background 0.15s; white-space: nowrap;
}
.send:hover { background: var(--purple); }
.send:disabled { background: #d1d5db; cursor: not-allowed; }
.send.stop { background: #ef4444; }
.send.stop:hover { background: #dc2626; }
.controls { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; align-items: center; }
.seg { display: flex; gap: 2px; background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: 3px; }
.seg button {
border: none; border-radius: 999px; padding: 6px 14px; font: 600 0.78rem/1 inherit; font-family: inherit;
background: transparent; color: var(--muted); cursor: pointer; transition: all 0.15s;
}
.seg button:hover { color: var(--ink); }
.seg button.active { background: var(--ink); color: #fff; }
.toggle { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
.toggle input { display: none; }
.toggle .track {
width: 34px; height: 20px; border-radius: 999px; background: #d1d5db; position: relative; transition: background 0.15s;
}
.toggle .track::after {
content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%;
background: #fff; transition: transform 0.15s; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.toggle input:checked + .track { background: var(--purple); }
.toggle input:checked + .track::after { transform: translateX(14px); }
.toggle .lbl { font-size: 0.8rem; font-weight: 600; color: var(--muted); }
.adv-toggle {
border: none; background: none; font: 600 0.78rem/1 inherit; font-family: inherit;
color: var(--muted); cursor: pointer; margin-left: auto; padding: 6px 4px;
}
.adv-toggle:hover { color: var(--purple); }
.advanced { border-top: 1px dashed var(--line); margin-top: 12px; padding-top: 14px; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; }
.field { display: flex; flex-direction: column; gap: 5px; }
.field.wide { grid-column: 1 / -1; }
.field label { font-size: 0.66rem; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--dim); display: flex; justify-content: space-between; }
.field label output { color: var(--purple); font-weight: 700; }
.field input[type="range"] { accent-color: var(--purple); width: 100%; }
.field input[type="number"], .field input[type="text"] { padding: 8px 10px; font-size: 0.86rem; }
.field textarea { font-size: 0.86rem; padding: 9px 11px; }
.check { display: flex; align-items: center; gap: 7px; font-size: 0.8rem; color: var(--muted); }
.check input { accent-color: var(--purple); }
.examples { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 22px; padding: 0 16px; }
.chip {
border: 1px solid var(--line); background: #fff; border-radius: 999px; padding: 8px 15px;
font: 550 0.8rem/1.2 inherit; font-family: inherit; color: var(--muted); cursor: pointer; transition: all 0.15s;
}
.chip:hover { border-color: var(--purple); color: var(--purple); background: var(--purple-soft); }
.error-bar {
width: min(820px, calc(100vw - 32px)); margin-top: 14px;
border: 1px solid #fecaca; background: #fef2f2; color: #b91c1c;
border-radius: 12px; padding: 11px 16px; font-size: 0.86rem;
}
/* ------- stats strip ------- */
.stats {
display: flex; flex-wrap: wrap; gap: 0; justify-content: center;
margin-top: 46px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
width: min(980px, calc(100vw - 32px));
}
.stat { padding: 22px 34px; text-align: center; border-right: 1px solid var(--line); }
.stat:last-child { border-right: none; }
.stat .k { font-size: 0.66rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--dim); }
.stat .v { margin-top: 6px; font-family: "Instrument Serif", Georgia, serif; font-size: 1.5rem; color: var(--ink); }
.stat .v em { color: var(--purple); font-style: normal; }
footer { text-align: center; margin-top: 42px; color: var(--dim); font-size: 0.78rem; line-height: 1.7; padding: 0 20px; }
footer a { color: var(--muted); }
/* ------- status bar ------- */
.statusbar {
position: fixed; left: 0; right: 0; bottom: 0; height: 40px; z-index: 30;
display: flex; align-items: center; gap: 16px; padding: 0 18px;
background: rgba(255,255,255,0.92); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-top: 1px solid var(--line); font-size: 0.75rem; color: var(--muted);
}
.statusbar .grow { flex: 1; }
.statusbar b { color: var(--ink); font-weight: 650; }
.statusbar .num { font-variant-numeric: tabular-nums; }
@media (max-width: 640px) {
.brand .brand-sub { display: none; }
.toplink:not(.cta) { display: none; }
.stat { padding: 16px 18px; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<svg class="mark" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 2L20 12L12 22L4 12Z" fill="#6c4fe0"/>
<path d="M12 7L16 12L12 17L8 12Z" fill="#fff" fill-opacity="0.85"/>
</svg>
Liquid
<span class="brand-sub">LFM3 Studio</span>
</div>
<div class="spacer"></div>
<a class="toplink" href="https://huggingface.co/LiquidAI/LFM3-1.7B" target="_blank" rel="noopener">Model card</a>
<a class="toplink" href="https://www.liquid.ai" target="_blank" rel="noopener">liquid.ai</a>
<a class="toplink cta" href="/workflow/" target="_blank" rel="noopener">Open the workflow ↗</a>
</header>
<main>
<section class="hero">
<div class="badge"><span class="badge-dot"></span> LFM3-1.7B · tri-hybrid architecture · 256K context</div>
<h1><em>Device-native</em> intelligence,<br />streaming live.</h1>
<p class="sub">
Chat with LFM3-1.7B — Liquid AI's 1.7B-parameter model built for local agents,
tool calling and structured output. Reasoning traces included, tokens streamed
as they generate.
</p>
</section>
<section class="console">
<div class="console-head">
<span class="title">Chat</span>
<span class="engine-state" id="engineState"><span class="dot"></span><span id="engineLabel">Engine cold</span></span>
</div>
<div class="messages" id="messages"></div>
<div class="composer">
<div class="input-row">
<textarea id="input" rows="2" placeholder="Ask LFM3 anything… (Enter to send, Shift+Enter for a newline)"></textarea>
<button class="send" id="sendBtn">Send</button>
</div>
<div class="controls">
<div class="seg" id="presetSeg">
<button data-preset="general" class="active">General</button>
<button data-preset="math">Math &amp; STEM</button>
</div>
<label class="toggle">
<input type="checkbox" id="thinkingToggle" checked />
<span class="track"></span>
<span class="lbl">Thinking</span>
</label>
<button class="adv-toggle" id="advToggle">Advanced ⚙</button>
</div>
<div class="advanced" id="advanced" hidden>
<div class="field wide">
<label>System prompt</label>
<textarea id="systemPrompt" rows="2">You are a helpful assistant trained by Liquid AI.</textarea>
</div>
<div class="field">
<label>Temperature <output id="tempOut">preset</output></label>
<input type="range" id="temperature" min="-1" max="2" step="0.05" value="-1" />
</div>
<div class="field">
<label>Top-p <output id="topPOut">preset</output></label>
<input type="range" id="topP" min="-1" max="1" step="0.05" value="-1" />
</div>
<div class="field">
<label>Top-k <output id="topKOut">preset</output></label>
<input type="range" id="topK" min="-1" max="200" step="5" value="-1" />
</div>
<div class="field">
<label>Repetition penalty <output id="repOut">preset</output></label>
<input type="range" id="repPenalty" min="-1" max="2" step="0.05" value="-1" />
</div>
<div class="field">
<label>Max new tokens</label>
<input type="number" id="maxTokens" min="1" max="8192" step="1" value="1024" />
</div>
<div class="field">
<label>Seed</label>
<input type="number" id="seed" min="0" step="1" value="0" />
</div>
<div class="field" style="justify-content:flex-end">
<label class="check"><input type="checkbox" id="randomize" checked /> Randomize seed</label>
</div>
</div>
</div>
</section>
<div class="examples" id="examples">
<button class="chip">Explain how a heat pump works.</button>
<button class="chip">What is C. elegans?</button>
<button class="chip">Solve: a train travels 120 km in 1.5 h, then 80 km in 45 min. Average speed?</button>
<button class="chip">用中文解释什么是边缘计算。</button>
<button class="chip">Extract JSON: name, date and total from "Invoice 4521 — ACME Corp, billed 2026-10-11, amount due $1,299.50".</button>
</div>
<div class="error-bar" id="errorBar" hidden></div>
<section class="stats">
<div class="stat"><div class="k">Parameters</div><div class="v">1.73<em>B</em></div></div>
<div class="stat"><div class="k">Context</div><div class="v">256<em>K</em></div></div>
<div class="stat"><div class="k">Architecture</div><div class="v">tri-<em>hybrid</em></div></div>
<div class="stat"><div class="k">Languages</div><div class="v">16</div></div>
<div class="stat"><div class="k">Last run</div><div class="v" id="statSpeed">—</div></div>
</section>
<footer>
An unofficial studio for <a href="https://huggingface.co/LiquidAI/LFM3-1.7B" target="_blank" rel="noopener">LiquidAI/LFM3-1.7B</a>
(LFM Open License v1.0). Node-based canvas at <a href="/workflow/" target="_blank" rel="noopener">/workflow</a>.
Design language inspired by <a href="https://www.liquid.ai" target="_blank" rel="noopener">liquid.ai</a>.
</footer>
</main>
<div class="statusbar">
<span id="statusText"><b>LFM3 Studio</b> · warming the engine…</span>
<span class="grow"></span>
<span class="num" id="statusStats"></span>
</div>
<script>
(() => {
const $ = (id) => document.getElementById(id);
const messages = $("messages"), input = $("input"), sendBtn = $("sendBtn");
const engineState = $("engineState"), engineLabel = $("engineLabel");
const statusText = $("statusText"), statusStats = $("statusStats"), errorBar = $("errorBar");
const history = []; // [{role, content}] — thinking traces excluded
let streaming = false;
let abort = null;
/* ---------------- engine warmup (gradio call protocol) ---------------- */
function setEngine(state, label) {
engineState.className = "engine-state " + state;
engineLabel.textContent = label;
}
async function warmup() {
setEngine("warming", "Engine warming…");
try {
const r = await fetch("/gradio_api/call/warmup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: [] }),
});
const { event_id } = await r.json();
const ev = await fetch(`/gradio_api/call/warmup/${event_id}`);
const text = await ev.text();
const line = text.split("\n").find((l) => l.startsWith("data:"));
const data = line ? JSON.parse(line.slice(5).trim()) : null;
if (data && data[0] && data[0].warm) {
setEngine("ready", "Engine ready");
statusText.innerHTML = `<b>LFM3 Studio</b> · engine ready (warmup ${data[0].seconds}s)`;
return;
}
throw new Error("unexpected warmup response");
} catch (e) {
setEngine("error", "Warmup failed");
statusText.innerHTML = "<b>LFM3 Studio</b> · engine cold — first reply builds it (~1 min)";
}
}
warmup();
/* ---------------- controls ---------------- */
let preset = "general";
$("presetSeg").addEventListener("click", (e) => {
const btn = e.target.closest("button[data-preset]");
if (!btn) return;
preset = btn.dataset.preset;
document.querySelectorAll("#presetSeg button").forEach((b) => b.classList.toggle("active", b === btn));
});
$("advToggle").addEventListener("click", () => { $("advanced").hidden = !$("advanced").hidden; });
const sliders = [
["temperature", "tempOut", (v) => v < 0 ? "preset" : v.toFixed(2)],
["topP", "topPOut", (v) => v < 0 ? "preset" : v.toFixed(2)],
["topK", "topKOut", (v) => v < 0 ? "preset" : String(v)],
["repPenalty", "repOut", (v) => v < 0 ? "preset" : v.toFixed(2)],
];
for (const [id, out, fmt] of sliders) {
$(id).addEventListener("input", () => { $(out).textContent = fmt(parseFloat($(id).value)); });
}
$("examples").addEventListener("click", (e) => {
const chip = e.target.closest(".chip");
if (!chip || streaming) return;
input.value = chip.textContent;
input.focus();
});
/* ---------------- rendering ---------------- */
function esc(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function md(src) {
let html = esc(src);
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => `<pre><code>${code.replace(/\n$/, "")}</code></pre>`);
html = html.replace(/`([^`\n]+)`/g, "<code>$1</code>");
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
html = html.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, "<em>$1</em>");
html = html.split(/\n{2,}/).map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`).join("");
return html;
}
function addUserMsg(text) {
const div = document.createElement("div");
div.className = "msg user";
div.innerHTML = `<span class="who">You</span><div class="bubble"></div>`;
div.querySelector(".bubble").textContent = text;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function addAssistantMsg() {
const div = document.createElement("div");
div.className = "msg assistant";
div.innerHTML = `
<span class="who">LFM3-1.7B</span>
<details class="think" open hidden>
<summary>Thinking…</summary>
<div class="think-body"></div>
</details>
<div class="bubble"><span class="cursor"></span></div>
<span class="meta" hidden></span>`;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
return div;
}
function showError(msg) {
errorBar.textContent = msg;
errorBar.hidden = false;
clearTimeout(showError._t);
showError._t = setTimeout(() => { errorBar.hidden = true; }, 9000);
}
/* ---------------- streaming ---------------- */
async function send() {
const text = input.value.trim();
if (!text || streaming) return;
input.value = "";
streaming = true;
sendBtn.textContent = "Stop";
sendBtn.classList.add("stop");
addUserMsg(text);
const msgEl = addAssistantMsg();
const thinkBox = msgEl.querySelector(".think");
const thinkBody = msgEl.querySelector(".think-body");
const thinkSummary = msgEl.querySelector(".think summary");
const bubble = msgEl.querySelector(".bubble");
const meta = msgEl.querySelector(".meta");
const historyForModel = [...history];
const sys = $("systemPrompt").value.trim();
const body = {
messages: [...(sys ? [{ role: "system", content: sys }] : []),
...historyForModel, { role: "user", content: text }],
preset,
max_new_tokens: parseInt($("maxTokens").value, 10) || 1024,
temperature: parseFloat($("temperature").value),
top_p: parseFloat($("topP").value),
top_k: parseFloat($("topK").value),
repetition_penalty: parseFloat($("repPenalty").value),
enable_thinking: $("thinkingToggle").checked,
seed: parseInt($("seed").value, 10) || 0,
randomize: $("randomize").checked,
};
let raw = "";
let sawDone = false;
abort = new AbortController();
statusText.innerHTML = "<b>LFM3 Studio</b> · connecting…";
try {
const res = await fetch("/api/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: abort.signal,
});
if (!res.ok) {
let detail = res.statusText;
try { detail = (await res.json()).detail || detail; } catch (_) {}
throw new Error(detail);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, idx);
buf = buf.slice(idx + 2);
const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
if (!dataLine) continue;
const ev = JSON.parse(dataLine.slice(5).trim());
if (ev.type === "start") {
statusText.innerHTML = "<b>LFM3 Studio</b> · waiting for the first token — a cold engine can take a minute…";
} else if (ev.type === "token") {
if (!raw) statusText.innerHTML = "<b>LFM3 Studio</b> · streaming…";
raw += ev.text;
renderStream();
} else if (ev.type === "done") {
sawDone = true;
meta.hidden = false;
meta.textContent =
`${ev.tokens} tokens · ${ev.tokens_per_second} tok/s · ${ev.seconds}s · seed ${ev.seed}`;
$("statSpeed").innerHTML = `${ev.tokens_per_second}<em> tok/s</em>`;
statusStats.textContent =
`last run: ${ev.tokens} tokens in ${ev.seconds}s (${ev.tokens_per_second} tok/s)`;
} else if (ev.type === "error") {
throw new Error(ev.message);
}
}
}
if (!sawDone && raw.trim() === "") throw new Error("Stream ended before the model finished.");
} catch (e) {
if (e.name === "AbortError") {
meta.hidden = false;
meta.textContent = "stopped";
} else {
showError(e.message || String(e));
if (raw.trim() === "") msgEl.remove();
}
} finally {
const cursor = bubble.querySelector(".cursor");
if (cursor) cursor.remove();
renderStream(true);
streaming = false;
abort = null;
statusText.innerHTML = "<b>LFM3 Studio</b> · engine ready";
sendBtn.textContent = "Send";
sendBtn.classList.remove("stop");
}
function renderStream(final = false) {
let think = "", reply = raw;
if (body.enable_thinking) {
const close = raw.indexOf("</think>");
if (close >= 0) {
think = raw.slice(0, close).replace(/^<think>/, "").trim();
reply = raw.slice(close + 8).trim();
} else {
think = raw.replace(/^<think>/, "").trim();
reply = "";
}
}
if (think) {
thinkBox.hidden = false;
thinkBody.textContent = think;
thinkSummary.firstChild.textContent = final ? "Reasoning trace" : "Thinking…";
if (reply) thinkBox.open = false;
}
const cursorHtml = final ? "" : '<span class="cursor"></span>';
bubble.innerHTML = (reply ? md(reply) : "") + cursorHtml;
if (!reply && !think) bubble.innerHTML = cursorHtml;
messages.scrollTop = messages.scrollHeight;
}
// commit to history (without the reasoning trace)
const close = raw.indexOf("</think>");
const replyText = body.enable_thinking && close >= 0 ? raw.slice(close + 8).trim() : raw.trim();
if (replyText) {
history.push({ role: "user", content: text }, { role: "assistant", content: replyText });
}
}
sendBtn.addEventListener("click", () => {
if (streaming) { abort && abort.abort(); return; }
send();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); streaming ? null : send(); }
});
input.focus();
})();
</script>
</body>
</html>