AK391
Fix SyntaxError from broken think-tag regex in stripMarkers
39d586d
Raw
History Blame Contribute Delete
23.1 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ThinkingCap · Qwen 3.6 27B</title>
<style>
:root {
/* BottleCap AI identity: the logo is pure black (#101010) on near-white
(#f0f0f0). Dark, minimal, monochrome UI. */
--bc-mark: #101010;
--bc-bg: #0e0e0f;
--bc-surface: #18181b;
--bc-surface-2: #1f1f23;
--bc-line: #2a2a2e;
--bc-ink: #f0f0f0;
--bc-muted: #9a9aa0;
--bc-faint: #6a6a70;
--bc-accent: #2f2f33; /* monochrome "accent" (borders/hover) */
--bc-accent-strong: #e8e8e8;
--bc-user-bubble: #202023;
--bc-user-border: #2f2f33;
--bc-assistant-bubble: #18181b;
--bc-think-bg: #131316;
--bc-think-ink: #9a9aa0;
--bc-danger: #f87171;
--bc-ok: #86efac;
--radius: 14px;
--logo: url("https://cdn-avatars.huggingface.co/v1/production/uploads/6410186e06c3b5ca88451747/suQ_-Q9WpitObx-oBv0i_.png");
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bc-bg);
color: var(--bc-ink);
display: flex;
flex-direction: column;
height: 100vh;
}
/* Header */
header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
background: var(--bc-surface);
border-bottom: 1px solid var(--bc-line);
z-index: 5;
}
.logo {
width: 40px; height: 40px;
background: #f0f0f0;
border-radius: 10px;
background-image: var(--logo);
background-size: cover;
background-position: center;
flex-shrink: 0;
}
.titles { line-height: 1.2; }
.titles h1 { margin: 0; font-size: 16px; font-weight: 700; color: var(--bc-ink); letter-spacing: -0.2px; }
.titles p { margin: 2px 0 0; font-size: 12px; color: var(--bc-muted); }
.spacer { flex: 1; }
.pill {
font-size: 11px; color: var(--bc-faint);
border: 1px solid var(--bc-line); border-radius: 999px;
padding: 4px 10px; background: var(--bc-surface-2);
}
.icon-btn {
border: 1px solid var(--bc-line); background: var(--bc-surface-2);
border-radius: 10px; width: 38px; height: 38px;
cursor: pointer; font-size: 18px; color: var(--bc-ink);
display: grid; place-items: center;
}
.icon-btn:hover { background: var(--bc-accent); }
/* Settings drawer */
#settings {
display: none;
background: var(--bc-surface);
border-bottom: 1px solid var(--bc-line);
padding: 14px 18px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px 22px;
}
#settings.open { display: grid; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field > label { font-size: 12px; font-weight: 600; color: var(--bc-muted); }
.field .row { display: flex; align-items: center; gap: 8px; }
.field input[type=range] { flex: 1; accent-color: var(--bc-accent-strong); }
.field .val { font-size: 12px; min-width: 42px; text-align: right; color: var(--bc-ink); font-variant-numeric: tabular-nums; }
.field input[type=number] {
width: 90px; padding: 6px 8px;
border: 1px solid var(--bc-line); border-radius: 8px;
background: var(--bc-surface-2); color: var(--bc-ink); font-size: 13px;
}
.switch { position: relative; width: 40px; height: 22px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; inset: 0; background: var(--bc-accent); border-radius: 999px; transition: .2s; cursor: pointer; }
.slider:before { content: ""; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; background: #f0f0f0; border-radius: 50%; transition: .2s; }
.switch input:checked + .slider { background: var(--bc-accent-strong); }
.switch input:checked + .slider:before { transform: translateX(18px); }
.reset-btn { align-self: end; }
/* Chat */
main { flex: 1; overflow-y: auto; padding: 22px 18px 10px; }
#messages { max-width: 820px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
.msg { display: flex; gap: 10px; max-width: 100%; }
.msg.user { flex-direction: row-reverse; }
.avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; display: grid; place-items: center; font-size: 15px; overflow: hidden; }
.msg.user .avatar { background: var(--bc-surface-2); color: var(--bc-ink); }
.msg.assistant .avatar {
background-image: var(--logo); background-size: cover; background-position: center;
background-color: #f0f0f0;
}
.bubble { padding: 12px 14px; border-radius: var(--radius); border: 1px solid var(--bc-line); max-width: 78%; word-wrap: break-word; overflow-wrap: anywhere; line-height: 1.5; font-size: 14.5px; }
.msg.user .bubble { background: var(--bc-user-bubble); border-color: var(--bc-user-border); border-top-right-radius: 4px; }
.msg.assistant .bubble { background: var(--bc-assistant-bubble); border-top-left-radius: 4px; }
.bubble img.thumb { max-width: 220px; max-height: 220px; border-radius: 8px; margin-bottom: 8px; display: block; }
/* Reasoning panel */
.thinking { margin-bottom: 8px; border: 1px solid var(--bc-line); border-radius: 10px; background: var(--bc-think-bg); }
.thinking > summary { cursor: pointer; padding: 8px 12px; font-size: 12.5px; font-weight: 600; color: var(--bc-think-ink); display: flex; align-items: center; gap: 8px; list-style: none; }
.thinking > summary::-webkit-details-marker { display: none; }
.thinking > summary .chev { transition: transform .15s; }
.thinking[open] > summary .chev { transform: rotate(90deg); }
.thinking .body { padding: 0 12px 10px; font-size: 13px; color: var(--bc-think-ink); white-space: pre-wrap; line-height: 1.55; }
.tok-count { margin-left: auto; font-weight: 500; color: var(--bc-accent-strong); font-variant-numeric: tabular-nums; }
.answer { white-space: pre-wrap; }
/* Pending / streaming */
.pending .bubble { display: flex; align-items: center; gap: 10px; color: var(--bc-muted); }
.spinner { width: 14px; height: 14px; border: 2px solid var(--bc-accent); border-top-color: var(--bc-accent-strong); border-radius: 50%; animation: spin .8s linear infinite; flex-shrink: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.elapsed { font-size: 12px; font-variant-numeric: tabular-nums; }
.live-think { font-size: 13px; color: var(--bc-think-ink); white-space: pre-wrap; line-height: 1.55; }
.live-answer { white-space: pre-wrap; }
.caret::after { content: "▍"; color: var(--bc-accent-strong); animation: blink 1s steps(1) infinite; margin-left: 1px; }
@keyframes blink { 50% { opacity: 0; } }
.error .bubble { border-color: #5b2a2a; background: #2a1516; color: var(--bc-danger); }
/* Empty state */
#empty { text-align: center; color: var(--bc-muted); padding: 60px 20px; max-width: 540px; margin: 0 auto; }
#empty .empty-logo { width: 64px; height: 64px; margin: 0 auto 14px; border-radius: 16px; background: #f0f0f0; background-image: var(--logo); background-size: cover; background-position: center; }
#empty h2 { color: var(--bc-ink); font-size: 19px; margin: 0 0 6px; }
#empty p { font-size: 14px; line-height: 1.55; }
.examples { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 18px; }
.examples button { font: inherit; font-size: 13px; padding: 8px 12px; border: 1px solid var(--bc-line); background: var(--bc-surface-2); border-radius: 999px; cursor: pointer; color: var(--bc-ink); }
.examples button:hover { border-color: var(--bc-accent-strong); background: var(--bc-accent); }
/* Composer */
footer { border-top: 1px solid var(--bc-line); background: var(--bc-surface); padding: 12px 18px; }
.composer { max-width: 820px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; }
.composer .attach { position: relative; }
.composer .attach input { display: none; }
.composer .btn { border: 1px solid var(--bc-line); background: var(--bc-surface-2); border-radius: 10px; width: 40px; height: 40px; cursor: pointer; font-size: 18px; color: var(--bc-ink); display: grid; place-items: center; flex-shrink: 0; }
.composer .btn:hover { background: var(--bc-accent); }
.composer .btn.primary { background: var(--bc-mark); border-color: var(--bc-mark); color: #f0f0f0; font-weight: 700; }
.composer .btn.primary:disabled { opacity: .4; cursor: not-allowed; }
#prompt { flex: 1; resize: none; border: 1px solid var(--bc-line); border-radius: 10px; padding: 10px 12px; font: inherit; font-size: 14.5px; line-height: 1.4; max-height: 180px; outline: none; background: var(--bc-surface-2); color: var(--bc-ink); }
#prompt::placeholder { color: var(--bc-faint); }
#prompt:focus { border-color: var(--bc-accent-strong); }
.img-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--bc-user-bubble); border: 1px solid var(--bc-line); border-radius: 999px; padding: 4px 8px 4px 4px; font-size: 12px; margin-right: 8px; }
.img-chip img { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; }
.img-chip .x { cursor: pointer; color: var(--bc-faint); font-weight: 700; }
.hint { max-width: 820px; margin: 6px auto 0; font-size: 11px; color: var(--bc-faint); text-align: center; }
/* Scrollbar (dark) */
main::-webkit-scrollbar { width: 10px; }
main::-webkit-scrollbar-thumb { background: var(--bc-accent); border-radius: 6px; border: 2px solid var(--bc-bg); }
main::-webkit-scrollbar-track { background: transparent; }
</style>
</head>
<body>
<header>
<div class="logo" role="img" aria-label="BottleCap AI"></div>
<div class="titles">
<h1>ThinkingCap · Qwen 3.6 27B</h1>
<p>Token-efficient reasoning — same answers, ~50% fewer thinking tokens</p>
</div>
<div class="spacer"></div>
<span class="pill" id="status">connecting…</span>
<button class="icon-btn" id="gear" title="Settings">⚙️</button>
</header>
<div id="settings">
<div class="field">
<label>Thinking mode</label>
<div class="row">
<label class="switch"><input type="checkbox" id="s-enable" checked /><span class="slider"></span></label>
<span style="font-size:12px;color:var(--bc-muted)">stream the trace</span>
</div>
</div>
<div class="field">
<label>Max new tokens</label>
<input type="number" id="s-max" value="32768" min="256" step="256" />
</div>
<div class="field">
<label>Temperature <span class="val" id="v-temp">1.0</span></label>
<div class="row"><input type="range" id="s-temp" min="0" max="2" step="0.05" value="1.0" /></div>
</div>
<div class="field">
<label>Top-p <span class="val" id="v-topp">0.95</span></label>
<div class="row"><input type="range" id="s-topp" min="0.1" max="1" step="0.01" value="0.95" /></div>
</div>
<div class="field">
<label>Top-k <span class="val" id="v-topk">20</span></label>
<div class="row"><input type="range" id="s-topk" min="0" max="100" step="1" value="20" /></div>
</div>
<div class="field reset-btn">
<button class="btn" id="s-reset" style="width:auto;height:34px;padding:0 14px;font-size:13px;border:1px solid var(--bc-line);background:var(--bc-surface-2);color:var(--bc-ink);border-radius:8px;cursor:pointer;">Reset defaults</button>
</div>
</div>
<main>
<div id="messages">
<div id="empty">
<div class="empty-logo"></div>
<h2>Chat with ThinkingCap-Qwen3.6-27B</h2>
<p>A finetune of Qwen 3.6 27B that keeps answer quality while cutting thinking tokens by ~50% on average (and &gt;90% in best cases). Ask anything — reasoning, math, code, or upload an image.</p>
<div class="examples">
<button>Explain why 0.1 + 0.2 ≠ 0.3 in floating point</button>
<button>Write a Python function to debounce calls</button>
<button>What's a good name for a token-efficient reasoning model?</button>
</div>
</div>
</div>
</main>
<footer>
<div class="composer">
<div class="attach">
<button class="btn" id="attach-btn" title="Attach image">📎</button>
<input type="file" id="file-input" accept="image/*" />
</div>
<textarea id="prompt" rows="1" placeholder="Message ThinkingCap… (Enter to send, Shift+Enter for newline)"></textarea>
<button class="btn primary" id="send" title="Send"></button>
</div>
<div class="hint" id="img-hint"></div>
</footer>
<script type="module">
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
const $ = (s) => document.querySelector(s);
const messagesEl = $("#messages");
const emptyEl = $("#empty");
const promptEl = $("#prompt");
const sendBtn = $("#send");
const fileInput = $("#file-input");
const statusEl = $("#status");
const imgHint = $("#img-hint");
// Reasoning-trace markers (built so the literal tags never sit together here).
const THINK_OPEN = "<" + "think" + ">";
const THINK_CLOSE = "<" + "/think" + ">";
const IM_END = "<" + "|im_end|" + ">";
const EOT = "<" + "|endoftext|" + ">";
let history = [];
let client = null;
let busy = false;
let pendingFile = null;
// --- Settings --------------------------------------------------------------
const DEFAULTS = { enable: true, max: 32768, temp: 1.0, topp: 0.95, topk: 20 };
const settings = { ...DEFAULTS };
function readSettings() {
settings.enable = $("#s-enable").checked;
settings.max = parseInt($("#s-max").value, 10) || DEFAULTS.max;
settings.temp = parseFloat($("#s-temp").value);
settings.topp = parseFloat($("#s-topp").value);
settings.topk = parseInt($("#s-topk").value, 10);
$("#v-temp").textContent = settings.temp.toFixed(2);
$("#v-topp").textContent = settings.topp.toFixed(2);
$("#v-topk").textContent = settings.topk;
}
["s-enable","s-max","s-temp","s-topp","s-topk"].forEach(id => $("#" + id).addEventListener("input", readSettings));
$("#s-reset").addEventListener("click", () => {
$("#s-enable").checked = DEFAULTS.enable;
$("#s-max").value = DEFAULTS.max;
$("#s-temp").value = DEFAULTS.temp;
$("#s-topp").value = DEFAULTS.topp;
$("#s-topk").value = DEFAULTS.topk;
readSettings();
});
readSettings();
$("#gear").addEventListener("click", () => $("#settings").classList.toggle("open"));
// --- Connect to the Gradio backend ----------------------------------------
let connectError = null;
async function connect() {
try {
client = await Client.connect(window.location.origin, {
events: ["status", "data"],
});
connectError = null;
statusEl.textContent = "ready";
statusEl.style.color = "var(--bc-ok)";
} catch (e) {
client = null;
connectError = e;
statusEl.textContent = "offline";
statusEl.style.color = "var(--bc-danger)";
console.error("Client.connect failed:", e);
}
}
connect();
// --- Composer behaviour ---------------------------------------------------
function autosize() {
promptEl.style.height = "auto";
promptEl.style.height = Math.min(promptEl.scrollHeight, 180) + "px";
}
promptEl.addEventListener("input", autosize);
promptEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
});
sendBtn.addEventListener("click", send);
$("#attach-btn").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
const f = fileInput.files[0];
if (!f) return;
pendingFile = f;
const url = URL.createObjectURL(f);
imgHint.innerHTML = `<span class="img-chip"><img src="${url}" />${escapeHtml(f.name)} <span class="x" id="rm-img">✕</span></span>`;
$("#rm-img").addEventListener("click", clearImage);
});
function clearImage() {
pendingFile = null;
fileInput.value = "";
imgHint.innerHTML = "";
}
document.querySelectorAll(".examples button").forEach(b =>
b.addEventListener("click", () => { promptEl.value = b.textContent; autosize(); promptEl.focus(); })
);
// --- Render helpers --------------------------------------------------------
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" }[c]));
}
function stripMarkers(s) {
return s
.split(IM_END).join("")
.split(EOT).join("")
.split(THINK_OPEN).join("")
.split(THINK_CLOSE).join("");
}
// Split accumulated text into a live (thinking, answer) view.
function splitLive(text) {
let think = "", answer = "", state = "answer";
const o = text.indexOf(THINK_OPEN);
const c = text.indexOf(THINK_CLOSE);
if (o === -1 && c === -1) {
// No thinking block at all so far → treat everything as answer.
return { thinking: "", answer: text };
}
if (o !== -1) {
state = "think";
think = text.slice(o + THINK_OPEN.length);
if (c !== -1 && c > o) {
think = text.slice(o + THINK_OPEN.length, c);
answer = text.slice(c + THINK_CLOSE.length);
state = "answer";
}
}
return { thinking: think, answer, state };
}
function addUserBubble(text, imgUrl) {
if (emptyEl) emptyEl.remove();
const el = document.createElement("div");
el.className = "msg user";
const imgHtml = imgUrl ? `<img class="thumb" src="${imgUrl}" />` : "";
el.innerHTML = `<div class="avatar">🧑</div><div class="bubble">${imgHtml}${escapeHtml(text)}</div>`;
messagesEl.appendChild(el);
scrollDown();
}
function addStreamingBubble() {
const el = document.createElement("div");
el.className = "msg assistant pending";
el.innerHTML = `<div class="avatar"></div>
<div class="bubble">
<span class="spinner"></span> thinking…
<span class="elapsed" data-t0>0.0s</span>
<div class="live"></div>
</div>`;
messagesEl.appendChild(el);
scrollDown();
const t0 = performance.now();
const tick = () => {
const node = el.querySelector("[data-t0]");
if (!node || el.dataset.done) return;
node.textContent = ((performance.now() - t0) / 1000).toFixed(1) + "s";
el._timer = requestAnimationFrame(tick);
};
tick();
return el;
}
function updateLive(el, text) {
const live = el.querySelector(".live");
const head = el.querySelector(".bubble");
const { thinking, answer, state } = splitLive(text);
if (thinking || state === "think") {
head.querySelector(".spinner").style.display = "";
head.querySelector(".elapsed").style.display = "";
live.innerHTML = `<div class="live-think caret">${escapeHtml(thinking)}</div>`;
} else if (answer) {
head.querySelector(".spinner").style.display = "none";
head.querySelector(".elapsed").style.display = "none";
live.innerHTML = `<div class="live-answer caret">${escapeHtml(answer)}</div>`;
}
scrollDown();
}
function finalizeBubble(el, data) {
el.dataset.done = "1";
cancelAnimationFrame(el._timer);
const { thinking, answer, thinking_tokens } = data;
const thinkHtml = thinking
? `<details class="thinking" ${stateOpen(el) ? "open" : ""}>
<summary><span class="chev">▸</span> Reasoning <span class="tok-count">${thinking_tokens} thinking tokens</span></summary>
<div class="body">${escapeHtml(thinking)}</div>
</details>`
: "";
const ansHtml = answer ? `<div class="answer">${escapeHtml(answer)}</div>` : "";
el.classList.remove("pending");
el.querySelector(".bubble").innerHTML = thinkHtml + ansHtml;
scrollDown();
}
// Keep the reasoning panel open across finalize if the user expanded it while streaming.
function stateOpen(el) { return el.querySelector(".live-think") && el.dataset.userOpen === "1"; }
function showError(el, msg) {
el.dataset.done = "1";
cancelAnimationFrame(el._timer);
el.classList.remove("pending");
el.classList.add("error");
el.querySelector(".bubble").innerHTML = "⚠️ " + escapeHtml(msg);
}
function scrollDown() { messagesEl.parentElement.scrollTop = messagesEl.parentElement.scrollHeight; }
// --- Send (streaming via Gradio JS client) --------------------------------
async function send() {
const text = promptEl.value.trim();
if (!text && !pendingFile) return;
if (busy) return;
if (!client) {
// Try (re)connecting once before bailing out, so a cold load still works.
statusEl.textContent = "reconnecting…";
await connect();
if (!client) {
showErrorInline(connectError ? String(connectError.message || connectError) : "not connected to backend");
return;
}
}
busy = true;
sendBtn.disabled = true;
const imgUrl = pendingFile ? URL.createObjectURL(pendingFile) : null;
addUserBubble(text || "(image)", imgUrl);
const payload = {
prompt: text || "Describe this image.",
image: pendingFile ? handle_file(pendingFile) : null,
history,
max_new_tokens: settings.max,
temperature: settings.temp,
top_p: settings.topp,
top_k: settings.topk,
enable_thinking: settings.enable,
};
history.push({ role: "user", content: text || "(image)" });
promptEl.value = "";
clearImage();
autosize();
const el = addStreamingBubble();
let accumulated = "";
let finalized = false;
let lastData = null;
try {
// submit() returns an async-iterable job for streaming endpoints.
const job = client.submit("/generate", payload);
for await (const message of job) {
if (message.type === "data" && message.data) {
const chunk = message.data[0];
if (chunk && typeof chunk.text === "string") {
accumulated = chunk.text; // server echoes the full text so far
lastData = chunk;
updateLive(el, accumulated);
}
} else if (message.type === "status") {
const st = message.status || (message.data && message.data.status);
if (st === "error") {
throw new Error("Backend returned an error status");
}
}
}
// If the server yielded a final dict with parsed fields, use it;
// otherwise split the accumulated text on the client.
const finalChunk = lastData || {};
finalizeBubble(el, {
thinking: finalChunk.thinking || (finalChunk.text ? splitLive(finalChunk.text).thinking : ""),
answer: finalChunk.answer || finalChunk.text || accumulated || "",
thinking_tokens: finalChunk.thinking_tokens || 0,
});
finalized = true;
history.push({ role: "assistant", content: finalChunk.answer || finalChunk.text || accumulated || "" });
} catch (e) {
console.error(e);
if (!finalized) {
showError(el, (e && e.message) ? e.message : "Generation failed.");
history.pop();
}
} finally {
busy = false;
sendBtn.disabled = false;
promptEl.focus();
}
}
// Inline error when not connected (no bubble yet).
function showErrorInline(msg) {
const el = document.createElement("div");
el.className = "msg assistant error";
el.innerHTML = `<div class="avatar"></div><div class="bubble">⚠️ ${escapeHtml(msg || "error")}</div>`;
if (emptyEl) emptyEl.remove();
messagesEl.appendChild(el);
scrollDown();
}
</script>
</body>
</html>