| <!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: #fdfcfb; |
| --paper: #fff; |
| --ink: #1a1a1a; |
| --ink-soft: #6b6964; |
| --ink-faint: #b0ada6; |
| --accent: #4a3f35; |
| --border: #e6e4df; |
| } |
| * { box-sizing: border-box; } |
| html, body { height: 100%; margin: 0; } |
| body { |
| font-family: Georgia, "Times New Roman", "Libre Baskerville", serif; |
| background: var(--bg); |
| color: var(--ink); |
| display: flex; |
| flex-direction: column; |
| height: 100vh; |
| letter-spacing: 0.01em; |
| } |
| header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 22px 28px; |
| background: var(--paper); |
| border-bottom: 1px solid var(--border); |
| } |
| .brand { display: flex; align-items: center; gap: 14px; } |
| .brand img { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; display: block; } |
| .brand h1 { font-size: 26px; font-weight: 300; letter-spacing: 0.08em; margin: 0; color: var(--ink); text-transform: uppercase; } |
| .brand .sub { font-size: 12px; color: var(--ink-soft); letter-spacing: 0.06em; } |
| |
| .playground-link { |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| text-decoration: none; transition: background .15s, border-color .15s, transform .15s; |
| letter-spacing: 0.02em; cursor: pointer; |
| } |
| .playground-link.secondary { |
| font-size: 13px; color: var(--ink-soft); |
| padding: 7px 12px; border: 1px solid var(--border); border-radius: 8px; |
| background: var(--paper); |
| } |
| .playground-link.secondary:hover { background: #f2f1ed; color: var(--ink); border-color: var(--ink-faint); } |
| |
| .playground-link.primary { |
| display: inline-flex; align-items: center; gap: 10px; |
| font-size: 15px; font-weight: 500; color: var(--paper); |
| background: var(--ink); padding: 12px 22px; border-radius: 24px; |
| border: 1px solid var(--ink); |
| } |
| .playground-link.primary:hover { background: #000; border-color: #000; transform: translateY(-1px); } |
| .playground-link.primary .hero-tag { |
| font-size: 11px; font-weight: 400; color: rgba(255,255,255,0.7); |
| padding: 3px 8px; border-radius: 10px; background: rgba(255,255,255,0.12); |
| text-transform: uppercase; letter-spacing: 0.08em; |
| } |
| |
| 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: 11px; color: var(--ink-faint); padding-left: 4px; letter-spacing: 0.06em; text-transform: uppercase; } |
| .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: #f2f1ed; border: none; align-self: flex-end; max-width: 78%; color: var(--ink); } |
| .msg.assistant .bubble { background: var(--paper); border: 1px solid var(--border); align-self: flex-start; max-width: 92%; color: var(--ink); box-shadow: 0 1px 4px rgba(0,0,0,0.04); } |
| .bubble.error { color: #b94c4c; border-color: #b94c4c; } |
| .empty { text-align: center; color: var(--ink-soft); margin-top: 60px; font-size: 14px; } |
| .empty-hero { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 24px 16px; } |
| .hero-logo { width: 72px; height: 72px; border-radius: 50%; object-fit: cover; } |
| .hero-title { font-size: 38px; font-weight: 300; letter-spacing: 0.04em; margin: 0; color: var(--ink); } |
| .hero-sub { font-size: 15px; color: var(--ink-soft); margin: 0 0 12px; } |
| .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(--paper); padding: 14px 20px 18px; } |
| .composer-inner { max-width: 820px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; } |
| |
| |
| .attachments { display: flex; flex-wrap: wrap; gap: 6px; max-width: 820px; margin: 0 auto 6px; padding: 0; } |
| .attachment { |
| display: flex; align-items: center; gap: 6px; |
| background: #f2f1ed; border: 1px solid var(--border); border-radius: 10px; |
| padding: 4px 8px 4px 4px; font-size: 12px; color: var(--ink); |
| } |
| .attachment img { width: 28px; height: 28px; border-radius: 6px; object-fit: cover; display: block; } |
| .attachment .remove { cursor: pointer; color: var(--ink-soft); font-size: 14px; padding: 0 2px; } |
| .attachment .remove:hover { color: #b94c4c; } |
| |
| |
| #input { |
| flex: 1; background: var(--bg); color: var(--ink); border: 1px solid var(--border); |
| border-radius: 14px; padding: 13px 16px; 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(--ink-faint); } |
| |
| |
| .icon-btn { |
| background: var(--paper); border: 1px solid var(--border); border-radius: 50%; |
| width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; |
| cursor: pointer; color: var(--ink-soft); transition: background 0.15s, color 0.15s; |
| flex: 0 0 auto; |
| } |
| .icon-btn:hover { background: #f2f1ed; color: var(--ink); } |
| .icon-btn svg { width: 18px; height: 18px; } |
| .icon-btn input[type="file"] { display: none; } |
| |
| |
| button#send { |
| background: var(--ink); color: var(--paper); border: none; border-radius: 50%; |
| width: 42px; height: 42px; font-size: 16px; font-family: Georgia, serif; cursor: pointer; |
| flex: 0 0 auto; transition: opacity 0.15s; |
| } |
| button#send:disabled { opacity: 0.4; cursor: not-allowed; } |
| |
| |
| .docs-overlay { |
| position: fixed; inset: 0; background: rgba(26, 26, 26, 0.45); |
| display: none; align-items: center; justify-content: center; z-index: 100; |
| padding: 20px; |
| } |
| .docs-overlay.open { display: flex; } |
| .docs-modal { |
| background: var(--paper); border-radius: 16px; max-width: 720px; width: 100%; |
| max-height: 80vh; overflow: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.25); |
| border: 1px solid var(--border); |
| } |
| .docs-header { |
| display: flex; align-items: center; justify-content: space-between; |
| padding: 18px 24px; border-bottom: 1px solid var(--border); |
| } |
| .docs-header h3 { margin: 0; font-size: 18px; font-weight: 400; letter-spacing: 0.04em; } |
| .docs-close { |
| background: none; border: none; font-size: 22px; cursor: pointer; color: var(--ink-soft); |
| padding: 0 4px; line-height: 1; |
| } |
| .docs-close:hover { color: var(--ink); } |
| .docs-body { padding: 20px 24px 24px; font-size: 14px; line-height: 1.55; color: var(--ink); } |
| .docs-body p { margin: 0 0 14px; } |
| .docs-body code { |
| font-family: "SF Mono", "Menlo", "Monaco", "Courier New", monospace; |
| font-size: 13px; background: #f4f2ed; padding: 1px 5px; border-radius: 4px; |
| } |
| .docs-snippet { |
| background: #1a1a1a; color: #e8eaf0; padding: 16px 18px; border-radius: 10px; |
| overflow: auto; font-family: "SF Mono", "Menlo", "Monaco", "Courier New", monospace; |
| font-size: 13px; line-height: 1.55; margin: 14px 0 0; position: relative; |
| white-space: pre; |
| } |
| .docs-snippet .copy-btn { |
| position: absolute; top: 10px; right: 10px; |
| background: rgba(255,255,255,0.1); color: #e8eaf0; border: 1px solid rgba(255,255,255,0.15); |
| padding: 4px 10px; border-radius: 6px; font-size: 11px; cursor: pointer; |
| font-family: -apple-system, BlinkMacSystemFont, sans-serif; text-transform: uppercase; letter-spacing: 0.08em; |
| } |
| .docs-snippet .copy-btn:hover { background: rgba(255,255,255,0.18); } |
| .docs-footer { font-size: 12px; color: var(--ink-soft); margin-top: 14px; } |
| </style> |
| </head> |
| <body> |
| <header> |
| <div class="brand"> |
| <img src="https://cdn-avatars.huggingface.co/v1/production/uploads/680a76de1b8d08640089fab0/r60-JyW_pv4llgjEyDYj9.png" alt="" /> |
| <div> |
| <h1>Thinking Machines</h1> |
| <div class="sub">Inkling</div> |
| </div> |
| </div> |
| <button class="playground-link secondary" id="openDocs" type="button"> |
| How to use → |
| </button> |
| </header> |
|
|
| <main> |
| <div id="messages"> |
| <div class="empty"> |
| <div class="empty-hero"> |
| <img class="hero-logo" src="https://cdn-avatars.huggingface.co/v1/production/uploads/680a76de1b8d08640089fab0/r60-JyW_pv4llgjEyDYj9.png" alt="" /> |
| <h2 class="hero-title">Inkling</h2> |
| <p class="hero-sub">Your AI assistant from Thinking Machines.</p> |
| <a class="playground-link primary" href="https://tinker.thinkingmachines.ai/playground" target="_blank" rel="noopener noreferrer"> |
| Try the Playground →<span class="hero-tag">Faster experience</span> |
| </a> |
| </div> |
| </div> |
| </div> |
| </main> |
|
|
| <div class="composer"> |
| <div class="attachments" id="attachments" hidden></div> |
| <div class="composer-inner"> |
| <label class="icon-btn" title="Attach image"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> |
| <path d="M21 12.5l-8.5 8.5a5.5 5.5 0 0 1-7.8-7.8l9.4-9.4a3.7 3.7 0 0 1 5.2 5.2L9.4 18.9a1.85 1.85 0 0 1-2.6-2.6L15 8.2" /> |
| </svg> |
| <input type="file" id="imgFile" accept="image/*" multiple /> |
| </label> |
| <textarea id="input" rows="1" placeholder="Message Inkling…"></textarea> |
| <button id="send" title="Send">↑</button> |
| </div> |
| </div> |
|
|
| <div class="docs-overlay" id="docsOverlay"> |
| <div class="docs-modal" id="docsModal"> |
| <div class="docs-header"> |
| <h3>How to use Inkling via Inference Providers</h3> |
| <button class="docs-close" id="closeDocs" type="button" aria-label="Close">×</button> |
| </div> |
| <div class="docs-body"> |
| <p>Reach the model through Hugging Face's OpenAI-compatible router. Set <code>HF_TOKEN</code> in your environment and use the <code>thinkingmachines/Inkling:together</code> model id.</p> |
| <pre class="docs-snippet" id="docsCode"><button class="copy-btn" id="copySnippet" type="button">Copy</button>import os |
| from openai import OpenAI |
|
|
| client = OpenAI( |
| base_url="https://router.huggingface.co/v1", |
| api_key=os.environ["HF_TOKEN"], |
| ) |
|
|
| stream = client.chat.completions.create( |
| model="thinkingmachines/Inkling:together", |
| messages=[ |
| { |
| "role": "user", |
| "content": [ |
| { |
| "type": "text", |
| "text": "Describe this image in one sentence." |
| }, |
| { |
| "type": "image_url", |
| "image_url": { |
| "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" |
| } |
| } |
| ] |
| } |
| ], |
| stream=True, |
| ) |
|
|
| for chunk in stream: |
| print(chunk.choices[0].delta.content, end="")</pre> |
| <p class="docs-footer">The <code>:auto</code> suffix routes to your preferred provider in settings; you can also use <code>:cheapest</code> or <code>:fastest</code>.</p> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| "use strict"; |
| |
| |
| |
| |
| |
| const $ = (id) => document.getElementById(id); |
| const messagesEl = $("messages"); |
| const inputEl = $("input"); |
| const sendBtn = $("send"); |
| const fileInput = $("imgFile"); |
| const attachmentsEl = $("attachments"); |
| |
| const pendingFiles = []; |
| 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);color:#e8eaf0;"><code>${c.replace(/^\n/, "")}</code></pre>`); |
| out = out.replace(/`([^`]+)`/g, (_, c) => |
| `<code style="background:#0e0f15;padding:1px 5px;border-radius:5px;color:#e8eaf0;">${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) { |
| sendBtn.disabled = busy; |
| inputEl.disabled = busy; |
| } |
| |
| |
| function renderAttachments() { |
| attachmentsEl.innerHTML = ""; |
| if (!pendingFiles.length) { attachmentsEl.hidden = true; return; } |
| attachmentsEl.hidden = false; |
| pendingFiles.forEach((pf, i) => { |
| const chip = document.createElement("div"); |
| chip.className = "attachment"; |
| chip.innerHTML = ` |
| <img src="${pf.objectUrl}" alt="" /> |
| <span>${escape(pf.file.name)}</span> |
| <span class="remove" data-i="${i}">×</span>`; |
| attachmentsEl.appendChild(chip); |
| }); |
| } |
| |
| function addFiles(fileList) { |
| for (const file of fileList) { |
| if (!file.type.startsWith("image/")) continue; |
| pendingFiles.push({ file, url: null, objectUrl: URL.createObjectURL(file) }); |
| } |
| renderAttachments(); |
| } |
| |
| function removeAttachment(i) { |
| const pf = pendingFiles[i]; |
| if (pf) URL.revokeObjectURL(pf.objectUrl); |
| pendingFiles.splice(i, 1); |
| renderAttachments(); |
| } |
| |
| fileInput.addEventListener("change", () => { |
| if (fileInput.files && fileInput.files.length) { |
| addFiles(fileInput.files); |
| fileInput.value = ""; |
| } |
| }); |
| attachmentsEl.addEventListener("click", (e) => { |
| const t = e.target.closest(".remove"); |
| if (t) removeAttachment(Number(t.dataset.i)); |
| }); |
| |
| |
| async function uploadOne(pf) { |
| const fd = new FormData(); |
| fd.append("image", pf.file); |
| const res = await fetch("/upload", { method: "POST", body: fd }); |
| if (!res.ok) throw new Error(`Upload failed: HTTP ${res.status}`); |
| const { url } = await res.json(); |
| pf.url = url; |
| } |
| |
| async function jpost(path, body) { |
| const res = await fetch(path, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify(body), |
| }); |
| if (!res.ok) throw new Error(`HTTP ${res.status} ${path}`); |
| 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("No event_id from server."); |
| const es = new EventSource(`/gradio_api/call/v2/chat/${encodeURIComponent(eventId)}`); |
| liveStream = es; |
| return new Promise((resolve, reject) => { |
| let latest = "", 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 (_) {} } |
| 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 && pendingFiles.length === 0) return; |
| |
| const files = pendingFiles.slice(); |
| pendingFiles.length = 0; |
| renderAttachments(); |
| inputEl.value = ""; |
| inputEl.style.height = "auto"; |
| |
| const bubble = addMessage("assistant", '<span class="cursor"></span>'); |
| setBusy(true); |
| let assistantText = ""; |
| function onDelta(t) { |
| assistantText = t; |
| bubble.innerHTML = renderMarkdown(t) + '<span class="cursor"></span>'; |
| scrollToEnd(); |
| } |
| |
| try { |
| |
| for (const pf of files) await uploadOne(pf); |
| |
| |
| const parts = []; |
| if (text) parts.push({ type: "text", text }); |
| for (const pf of files) { |
| const fullUrl = new URL(pf.url, window.location.origin).toString(); |
| parts.push({ type: "image_url", image_url: { url: fullUrl } }); |
| } |
| const userContent = parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts; |
| |
| |
| let displayHtml = ""; |
| if (text) displayHtml += escape(text); |
| for (const pf of files) displayHtml += `<img src="${pf.objectUrl}" style="max-width:120px;max-height:120px;border-radius:8px;display:block;margin-top:${text ? "8px" : "0"};" alt="" />`; |
| addMessage("user", displayHtml); |
| history.push({ role: "user", content: userContent }); |
| |
| const payload = { |
| messages: history.slice(), |
| temperature: 0.7, |
| }; |
| assistantText = await streamChat(payload); |
| if (!assistantText) { |
| assistantText = "(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); |
| 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(); |
| |
| |
| const docsOverlay = $("docsOverlay"); |
| const docsModal = $("docsModal"); |
| $("openDocs").addEventListener("click", () => docsOverlay.classList.add("open")); |
| $("closeDocs").addEventListener("click", () => docsOverlay.classList.remove("open")); |
| docsOverlay.addEventListener("click", (e) => { |
| if (e.target === docsOverlay) docsOverlay.classList.remove("open"); |
| }); |
| document.addEventListener("keydown", (e) => { |
| if (e.key === "Escape" && docsOverlay.classList.contains("open")) { |
| docsOverlay.classList.remove("open"); |
| } |
| }); |
| $("copySnippet").addEventListener("click", () => { |
| const code = $("docsCode").innerText.replace(/^Copy$/, "").trim(); |
| navigator.clipboard.writeText(code).then(() => { |
| const btn = $("copySnippet"); |
| const orig = btn.textContent; |
| btn.textContent = "Copied"; |
| setTimeout(() => (btn.textContent = orig), 1200); |
| }).catch(() => {}); |
| }); |
| </script> |
|
|
| </body> |
| </html> |