| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Inkling · Chat</title> |
| <style> |
| :root { |
| --bg: #0b0c10; |
| --panel: #14161d; |
| --panel-2: #1b1e27; |
| --border: #2a2e3a; |
| --text: #e8eaf0; |
| --muted: #9aa0ad; |
| --accent: #6ee7b7; |
| --accent-2: #818cf8; |
| --user-bg: #2b3a5e; |
| --assistant-bg: #1b1e27; |
| --danger: #f87171; |
| } |
| * { 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(--bg); |
| color: var(--text); |
| display: flex; |
| flex-direction: column; |
| height: 100vh; |
| } |
| header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 14px 20px; |
| background: linear-gradient(120deg, #12131a, #14161d); |
| border-bottom: 1px solid var(--border); |
| } |
| .brand { display: flex; align-items: baseline; gap: 10px; } |
| .brand .logo { font-size: 22px; } |
| .brand h1 { font-size: 19px; margin: 0; letter-spacing: 0.3px; } |
| .brand .sub { font-size: 12px; color: var(--muted); } |
| .controls { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } |
| .controls label { font-size: 12px; color: var(--muted); display: flex; flex-direction: column; gap: 4px; } |
| select, input[type="number"] { |
| background: var(--panel-2); |
| color: var(--text); |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| padding: 7px 9px; |
| font-size: 13px; |
| outline: none; |
| } |
| select:focus, input:focus { border-color: var(--accent-2); } |
| |
| main { flex: 1; overflow-y: auto; padding: 24px 0; } |
| #messages { max-width: 820px; margin: 0 auto; padding: 0 20px; display: flex; flex-direction: column; gap: 16px; } |
| .msg { display: flex; flex-direction: column; gap: 6px; } |
| .msg .who { font-size: 12px; color: var(--muted); padding-left: 4px; } |
| .bubble { |
| padding: 13px 16px; |
| border-radius: 14px; |
| line-height: 1.55; |
| font-size: 15px; |
| white-space: pre-wrap; |
| word-wrap: break-word; |
| overflow-wrap: anywhere; |
| } |
| .msg.user .bubble { background: var(--user-bg); border: 1px solid #3a4d77; align-self: flex-end; max-width: 78%; } |
| .msg.assistant .bubble { background: var(--assistant-bg); border: 1px solid var(--border); align-self: flex-start; max-width: 92%; } |
| .bubble.error { color: var(--danger); border-color: var(--danger); } |
| .empty { text-align: center; color: var(--muted); margin-top: 80px; } |
| .empty .logo { font-size: 40px; } |
| .cursor::after { content: "▋"; color: var(--accent); animation: blink 1s steps(2) infinite; } |
| @keyframes blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } } |
| |
| .composer { border-top: 1px solid var(--border); background: var(--panel); padding: 16px 20px; } |
| .composer-inner { max-width: 820px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; } |
| #input { |
| flex: 1; background: var(--panel-2); color: var(--text); border: 1px solid var(--border); |
| border-radius: 12px; padding: 12px 14px; font-size: 15px; resize: none; |
| max-height: 200px; min-height: 46px; outline: none; font-family: inherit; line-height: 1.4; |
| } |
| #input:focus { border-color: var(--accent-2); } |
| button#send { |
| background: var(--accent); color: #06281c; border: none; border-radius: 12px; |
| padding: 0 22px; height: 46px; font-size: 15px; font-weight: 600; cursor: pointer; |
| } |
| button#send:disabled { background: var(--panel-2); color: var(--muted); cursor: not-allowed; } |
| button#stop { |
| background: var(--panel-2); color: var(--text); border: 1px solid var(--border); |
| border-radius: 12px; padding: 0 18px; height: 46px; font-size: 14px; cursor: pointer; display: none; |
| } |
| .hint { max-width: 820px; margin: 8px auto 0; font-size: 11px; color: var(--muted); text-align: center; } |
| |
| #status { font-size: 11px; color: var(--muted); margin-right: auto; align-self: center; } |
| </style> |
| </head> |
| <body> |
| <header> |
| <div class="brand"> |
| <span class="logo">🐨</span> |
| <div> |
| <h1>Inkling</h1> |
| <div class="sub">thinkingmachines/Inkling · via HF Inference Providers</div> |
| </div> |
| </div> |
| <div class="controls"> |
| <label>Provider |
| <select id="provider"> |
| <option value="auto">auto</option> |
| <option value="cheapest">cheapest</option> |
| <option value="fastest">fastest</option> |
| </select> |
| </label> |
| <label>Temperature |
| <input type="number" id="temperature" value="0.7" min="0" max="2" step="0.1" style="width:70px" /> |
| </label> |
| <label>Max tokens |
| <input type="number" id="max_tokens" value="2048" min="1" max="32000" step="1" style="width:90px" /> |
| </label> |
| </div> |
| </header> |
|
|
| <main> |
| <div id="messages"> |
| <div class="empty"> |
| <div class="logo">🐨</div> |
| <p>Ask Inkling anything. Replies stream in over SSE.</p> |
| </div> |
| </div> |
| </main> |
|
|
| <div class="composer"> |
| <div class="composer-inner"> |
| <span id="status"></span> |
| <textarea id="input" rows="1" placeholder="Message Inkling… (Enter to send, Shift+Enter for newline)"></textarea> |
| <button id="stop" title="Stop generating">Stop</button> |
| <button id="send">Send</button> |
| </div> |
| <div class="hint">Backend: gradio.Server · SSE from <code>/gradio_api/call/v2/chat</code></div> |
| </div> |
|
|
| <script> |
| "use strict"; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const $ = (id) => document.getElementById(id); |
| const messagesEl = $("messages"); |
| const inputEl = $("input"); |
| const sendBtn = $("send"); |
| const stopBtn = $("stop"); |
| const statusEl = $("status"); |
| |
| const history = []; |
| let liveStream = null; |
| |
| function escape(s) { |
| return s.replace(/[&<>"']/g, (c) => |
| ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); |
| } |
| |
| function renderMarkdown(src) { |
| let out = escape(src); |
| out = out.replace(/```([\s\S]*?)```/g, (_, c) => |
| `<pre style="background:#0e0f15;padding:10px;border-radius:8px;overflow:auto;border:1px solid var(--border)"><code>${c.replace(/^\n/, "")}</code></pre>`); |
| out = out.replace(/`([^`]+)`/g, (_, c) => |
| `<code style="background:#0e0f15;padding:1px 5px;border-radius:5px">${c}</code>`); |
| out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"); |
| out = out.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>"); |
| out = out.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, |
| '<a href="$2" target="_blank" rel="noopener" style="color:var(--accent)">$1</a>'); |
| out = out.replace(/\n/g, "<br>"); |
| return out; |
| } |
| |
| function clearEmpty() { |
| const e = messagesEl.querySelector(".empty"); |
| if (e) e.remove(); |
| } |
| |
| function addMessage(role, content = "") { |
| clearEmpty(); |
| const wrap = document.createElement("div"); |
| wrap.className = `msg ${role}`; |
| const who = document.createElement("div"); |
| who.className = "who"; |
| who.textContent = role === "user" ? "You" : "Inkling"; |
| const bubble = document.createElement("div"); |
| bubble.className = "bubble"; |
| bubble.innerHTML = content; |
| wrap.append(who, bubble); |
| messagesEl.appendChild(wrap); |
| scrollToEnd(); |
| return bubble; |
| } |
| |
| function scrollToEnd() { |
| messagesEl.parentElement.scrollTop = messagesEl.parentElement.scrollHeight; |
| } |
| |
| function setBusy(busy, msg = "") { |
| sendBtn.disabled = busy; |
| stopBtn.style.display = busy ? "block" : "none"; |
| inputEl.disabled = busy; |
| statusEl.textContent = msg; |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function jpost(path, body) { |
| const res = await fetch(path, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify(body), |
| }); |
| if (!res.ok) { |
| const text = await res.text().catch(() => ""); |
| throw new Error(`HTTP ${res.status} ${path}: ${text.slice(0, 200)}`); |
| } |
| return res.json(); |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function streamChat(payload) { |
| |
| |
| const join = await jpost("/gradio_api/call/v2/chat", payload); |
| const eventId = join.event_id; |
| if (!eventId) throw new Error("Server did not return an event_id."); |
| |
| |
| const sseUrl = `/gradio_api/call/v2/chat/${encodeURIComponent(eventId)}`; |
| const es = new EventSource(sseUrl, { withCredentials: false }); |
| liveStream = es; |
| |
| return new Promise((resolve, reject) => { |
| let latest = ""; |
| let settled = false; |
| |
| const finish = (fn) => { |
| if (settled) return; |
| settled = true; |
| setTimeout(() => { try { es.close(); } catch (_) {} }, 0); |
| fn(); |
| }; |
| |
| |
| |
| es.addEventListener("generating", (ev) => { |
| try { |
| const arr = JSON.parse(ev.data); |
| if (Array.isArray(arr) && typeof arr[0] === "string") { |
| latest = arr[0]; |
| onDelta(latest); |
| } |
| } catch (_) { } |
| }); |
| |
| |
| es.addEventListener("complete", (ev) => { |
| try { |
| const arr = JSON.parse(ev.data); |
| if (Array.isArray(arr) && typeof arr[0] === "string") { |
| latest = arr[0]; |
| onDelta(latest); |
| } |
| } catch (_) { } |
| finish(() => resolve(latest)); |
| }); |
| |
| |
| es.addEventListener("error", (ev) => { |
| let msg = "Stream error."; |
| if (ev.data) { |
| try { msg = JSON.parse(ev.data) ?? msg; } catch (_) { msg = ev.data; } |
| } |
| finish(() => reject(new Error(typeof msg === "string" ? msg : "Stream error."))); |
| }); |
| |
| |
| |
| |
| es.onerror = () => { |
| if (settled) return; |
| finish(() => reject(new Error( |
| latest ? null : "Connection lost before any output arrived."))); |
| }; |
| }); |
| } |
| |
| async function send() { |
| const text = inputEl.value.trim(); |
| if (!text) return; |
| |
| addMessage("user", escape(text)); |
| history.push({ role: "user", content: text }); |
| inputEl.value = ""; |
| inputEl.style.height = "auto"; |
| |
| const bubble = addMessage("assistant", '<span class="cursor"></span>'); |
| setBusy(true, "streaming…"); |
| let assistantText = ""; |
| |
| function onDelta(t) { |
| assistantText = t; |
| bubble.innerHTML = renderMarkdown(t) + '<span class="cursor"></span>'; |
| scrollToEnd(); |
| } |
| |
| try { |
| const payload = { |
| messages: history.slice(), |
| provider: $("provider").value, |
| system_prompt: "", |
| temperature: parseFloat($("temperature").value), |
| max_tokens: parseInt($("max_tokens").value, 10), |
| }; |
| assistantText = await streamChat(payload); |
| if (!assistantText) { |
| assistantText = "(The model returned no content.)"; |
| bubble.textContent = assistantText; |
| } else if (assistantText.startsWith("⚠️")) { |
| bubble.classList.add("error"); |
| } else { |
| history.push({ role: "assistant", content: assistantText }); |
| } |
| bubble.innerHTML = renderMarkdown(assistantText); |
| } catch (err) { |
| bubble.classList.add("error"); |
| bubble.textContent = `⚠️ ${err?.message ?? err}`; |
| } finally { |
| liveStream = null; |
| setBusy(false, ""); |
| inputEl.focus(); |
| } |
| } |
| |
| sendBtn.addEventListener("click", send); |
| stopBtn.addEventListener("click", () => { |
| try { liveStream?.close?.(); } catch (_) {} |
| liveStream = null; |
| setBusy(false, "stopped"); |
| }); |
| inputEl.addEventListener("keydown", (e) => { |
| if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } |
| }); |
| inputEl.addEventListener("input", () => { |
| inputEl.style.height = "auto"; |
| inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + "px"; |
| }); |
| inputEl.focus(); |
| </script> |
| </body> |
| </html> |