| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
| <title>BartlebyGPT</title> |
| <style> |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| :root { |
| --bg: #fafafa; |
| --surface: #ffffff; |
| --border: #d4d4d4; |
| --border-focus: #888; |
| --text: #1a1a1a; |
| --text-muted: #666; |
| --user-bg: #e8e8e8; |
| --bot-bg: #f0f0f0; |
| --accent: #333; |
| --accent-text: #fff; |
| --input-bg: #fff; |
| } |
| |
| @media (prefers-color-scheme: dark) { |
| :root { |
| --bg: #111; |
| --surface: #1a1a1a; |
| --border: #333; |
| --border-focus: #666; |
| --text: #e5e5e5; |
| --text-muted: #999; |
| --user-bg: #2a2a2a; |
| --bot-bg: #222; |
| --accent: #e5e5e5; |
| --accent-text: #111; |
| --input-bg: #1a1a1a; |
| } |
| } |
| |
| html, body { |
| height: 100%; |
| overflow: hidden; |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
| font-size: 15px; |
| line-height: 1.5; |
| background: var(--bg); |
| color: var(--text); |
| } |
| |
| #app { |
| display: flex; |
| flex-direction: column; |
| height: 100dvh; |
| max-width: 720px; |
| margin: 0 auto; |
| padding: 8px; |
| } |
| |
| |
| #header { |
| flex: 0 0 auto; |
| padding: 8px 0; |
| text-align: center; |
| } |
| |
| #header h1 { |
| font-size: 1.1em; |
| font-weight: 600; |
| margin-bottom: 6px; |
| } |
| |
| #model-loader { |
| display: flex; |
| gap: 6px; |
| align-items: center; |
| } |
| |
| #model-loader input { |
| flex: 1; |
| padding: 6px 10px; |
| border: 1.5px solid var(--border); |
| border-radius: 6px; |
| background: var(--input-bg); |
| color: var(--text); |
| font-size: 13px; |
| outline: none; |
| } |
| |
| #model-loader input:focus { |
| border-color: var(--border-focus); |
| } |
| |
| #load-btn { |
| padding: 6px 14px; |
| border: none; |
| border-radius: 6px; |
| background: var(--accent); |
| color: var(--accent-text); |
| font-size: 13px; |
| font-weight: 600; |
| cursor: pointer; |
| white-space: nowrap; |
| } |
| |
| #load-btn:disabled { |
| opacity: 0.5; |
| cursor: not-allowed; |
| } |
| |
| #status { |
| font-size: 12px; |
| color: var(--text-muted); |
| margin-top: 4px; |
| min-height: 1.4em; |
| } |
| |
| |
| #chat { |
| flex: 1 1 auto; |
| min-height: 0; |
| overflow-y: auto; |
| padding: 12px 0; |
| display: flex; |
| flex-direction: column; |
| gap: 10px; |
| } |
| |
| .msg-row { |
| display: flex; |
| max-width: 100%; |
| } |
| |
| .msg-row.user { justify-content: flex-start; } |
| .msg-row.bot { justify-content: flex-end; } |
| |
| .msg-bubble { |
| max-width: 85%; |
| padding: 10px 14px; |
| border-radius: 14px; |
| font-size: 14px; |
| line-height: 1.5; |
| white-space: pre-wrap; |
| word-break: break-word; |
| } |
| |
| .msg-row.user .msg-bubble { |
| background: var(--user-bg); |
| border-bottom-left-radius: 4px; |
| } |
| |
| .msg-row.bot .msg-bubble { |
| background: var(--bot-bg); |
| border-bottom-right-radius: 4px; |
| } |
| |
| |
| #input-area { |
| flex: 0 0 auto; |
| padding: 6px 0; |
| display: flex; |
| gap: 6px; |
| } |
| |
| #msg-input { |
| flex: 1; |
| padding: 8px 12px; |
| border: 1.5px solid var(--border); |
| border-radius: 8px; |
| background: var(--input-bg); |
| color: var(--text); |
| font-size: 15px; |
| outline: none; |
| resize: none; |
| height: 2.6em; |
| line-height: 1.25; |
| } |
| |
| #msg-input:focus { |
| border-color: var(--border-focus); |
| } |
| |
| #send-btn { |
| padding: 8px 18px; |
| border: none; |
| border-radius: 8px; |
| background: var(--accent); |
| color: var(--accent-text); |
| font-size: 15px; |
| font-weight: 600; |
| cursor: pointer; |
| } |
| |
| #send-btn:disabled { |
| opacity: 0.4; |
| cursor: not-allowed; |
| } |
| |
| |
| #params-toggle { |
| flex: 0 0 auto; |
| padding: 4px 0; |
| } |
| |
| #params-toggle summary { |
| cursor: pointer; |
| font-size: 13px; |
| color: var(--text-muted); |
| user-select: none; |
| } |
| |
| #params-content { |
| display: flex; |
| gap: 12px; |
| flex-wrap: wrap; |
| padding: 8px 0; |
| } |
| |
| .param-group { |
| display: flex; |
| flex-direction: column; |
| gap: 2px; |
| flex: 1; |
| min-width: 120px; |
| } |
| |
| .param-group label { |
| font-size: 12px; |
| color: var(--text-muted); |
| } |
| |
| .param-group input[type="range"] { |
| width: 100%; |
| } |
| |
| .param-group .param-val { |
| font-size: 11px; |
| color: var(--text-muted); |
| text-align: right; |
| } |
| |
| |
| .generating::after { |
| content: "▌"; |
| animation: blink 0.6s infinite; |
| } |
| |
| @keyframes blink { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0; } |
| } |
| </style> |
| </head> |
| <body> |
| <div id="app"> |
|
|
| <div id="header"> |
| <h1>BartlebyGPT</h1> |
| <div id="model-loader"> |
| <input type="text" id="repo-input" value="staeiou/bartleby-qwen3-4b-2507_v2_onnx" |
| placeholder="HF ONNX repo ID"> |
| <button id="load-btn" onclick="loadModel()">Load Model</button> |
| </div> |
| <div id="status">Enter a Hugging Face ONNX model repo and click Load Model.</div> |
| </div> |
|
|
| <div id="chat"></div> |
|
|
| <div id="input-area"> |
| <input type="text" id="msg-input" placeholder="What do you want?" |
| disabled autocomplete="off"> |
| <button id="send-btn" onclick="sendMessage()" disabled>Send</button> |
| </div> |
|
|
| <details id="params-toggle"> |
| <summary>Params</summary> |
| <div id="params-content"> |
| <div class="param-group"> |
| <label>Max tokens</label> |
| <input type="range" id="p-max-tokens" min="1" max="512" value="256" |
| oninput="this.nextElementSibling.textContent=this.value"> |
| <span class="param-val">256</span> |
| </div> |
| <div class="param-group"> |
| <label>Temperature</label> |
| <input type="range" id="p-temp" min="0" max="2" step="0.05" value="0.7" |
| oninput="this.nextElementSibling.textContent=this.value"> |
| <span class="param-val">0.7</span> |
| </div> |
| <div class="param-group"> |
| <label>Top-p</label> |
| <input type="range" id="p-top-p" min="0.1" max="1" step="0.05" value="0.9" |
| oninput="this.nextElementSibling.textContent=this.value"> |
| <span class="param-val">0.9</span> |
| </div> |
| </div> |
| </details> |
|
|
| </div> |
|
|
| <script type="module"> |
| import { AutoTokenizer, AutoModelForCausalLM, TextStreamer } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3"; |
| |
| let tokenizer = null; |
| let model = null; |
| let generating = false; |
| |
| const chatEl = document.getElementById("chat"); |
| const msgInput = document.getElementById("msg-input"); |
| const sendBtn = document.getElementById("send-btn"); |
| const loadBtn = document.getElementById("load-btn"); |
| const repoInput = document.getElementById("repo-input"); |
| const statusEl = document.getElementById("status"); |
| |
| function setStatus(msg) { |
| statusEl.textContent = msg; |
| } |
| |
| function setInputEnabled(enabled) { |
| msgInput.disabled = !enabled; |
| sendBtn.disabled = !enabled; |
| } |
| |
| function addMessage(role, text) { |
| const row = document.createElement("div"); |
| row.className = `msg-row ${role}`; |
| const bubble = document.createElement("div"); |
| bubble.className = "msg-bubble"; |
| bubble.textContent = text; |
| row.appendChild(bubble); |
| chatEl.appendChild(row); |
| chatEl.scrollTop = chatEl.scrollHeight; |
| return bubble; |
| } |
| |
| window.loadModel = async function () { |
| const repo = repoInput.value.trim(); |
| if (!repo) return; |
| |
| loadBtn.disabled = true; |
| setInputEnabled(false); |
| setStatus("Loading tokenizer..."); |
| |
| try { |
| tokenizer = await AutoTokenizer.from_pretrained(repo); |
| setStatus("Loading model (this may take a while for large models)..."); |
| model = await AutoModelForCausalLM.from_pretrained(repo, { |
| dtype: "q8", |
| use_external_data_format: 2, |
| progress_callback: (progress) => { |
| if (progress.status === "progress" && progress.progress != null) { |
| setStatus(`Loading model... ${Math.round(progress.progress)}%`); |
| } |
| }, |
| }); |
| setStatus("Model loaded. Start chatting!"); |
| setInputEnabled(true); |
| msgInput.focus(); |
| } catch (e) { |
| setStatus(`Error: ${e.message}`); |
| console.error(e); |
| } finally { |
| loadBtn.disabled = false; |
| } |
| }; |
| |
| window.sendMessage = async function () { |
| if (!model || !tokenizer || generating) return; |
| |
| let userText = (msgInput.value || "").trim(); |
| if (!userText) return; |
| |
| |
| if (userText.length > 2000) userText = userText.slice(0, 2000); |
| |
| msgInput.value = ""; |
| addMessage("user", userText); |
| |
| |
| const messages = [{ role: "user", content: userText }]; |
| |
| const text = tokenizer.apply_chat_template(messages, { |
| tokenize: false, |
| add_generation_prompt: true, |
| }); |
| |
| const inputs = tokenizer(text, { return_tensors: "pt" }); |
| const inputLen = inputs.input_ids.dims[1]; |
| |
| const botBubble = addMessage("bot", ""); |
| botBubble.classList.add("generating"); |
| |
| generating = true; |
| setInputEnabled(false); |
| |
| const maxTokens = parseInt(document.getElementById("p-max-tokens").value); |
| const temperature = parseFloat(document.getElementById("p-temp").value); |
| const topP = parseFloat(document.getElementById("p-top-p").value); |
| |
| try { |
| |
| const streamer = new TextStreamer(tokenizer, { |
| skip_prompt: true, |
| skip_special_tokens: true, |
| callback_function: (text) => { |
| botBubble.textContent += text; |
| chatEl.scrollTop = chatEl.scrollHeight; |
| }, |
| }); |
| |
| await model.generate({ |
| ...inputs, |
| max_new_tokens: maxTokens, |
| do_sample: temperature > 0, |
| temperature: temperature > 0 ? temperature : undefined, |
| top_p: topP, |
| streamer, |
| }); |
| } catch (e) { |
| botBubble.textContent += `\n[Error: ${e.message}]`; |
| console.error(e); |
| } finally { |
| botBubble.classList.remove("generating"); |
| generating = false; |
| setInputEnabled(true); |
| msgInput.focus(); |
| } |
| }; |
| |
| |
| msgInput.addEventListener("keydown", (e) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| window.sendMessage(); |
| } |
| }); |
| </script> |
| </body> |
| </html> |
|
|