Spaces:
Running on Zero
Running on Zero
Download studio.html from akhaliq/lfm3-studio: direct link, hf CLI and curl.
- Browser
- Download file 27.5 kB
-
https://huggingface.co/spaces/akhaliq/lfm3-studio/resolve/main/studio.html
- Command line
-
hf download hf://spaces/akhaliq/lfm3-studio/studio.html
-
curl -L -o studio.html https://huggingface.co/spaces/akhaliq/lfm3-studio/resolve/main/studio.html
27.5 kB
| <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 ; } | |
| 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 & 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| } | |
| 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> | |