Humanlike-Chat / index.html
kvyb's picture
Thinking levels: Off / Low / Medium / High (edge maps the level to the trained effort line)
8bb23b5 verified
Raw History Blame Contribute Delete
11.4 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Humanlike Chat</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
color: #111827;
background: #fff;
font: 15px/1.5 Inter, ui-sans-serif, system-ui, sans-serif;
}
main {
display: grid;
grid-template-rows: auto 1fr auto auto;
width: min(880px, 100%);
height: 100dvh;
margin: auto;
}
header { padding: 14px 16px; border-bottom: 1px solid #e5e7eb; }
h1 { margin: 0; font-size: 15px; font-weight: 600; }
header p { margin: 4px 0 0; color: #6b7280; font-size: 12px; }
header a { color: #4f46e5; }
code { color: #374151; overflow-wrap: anywhere; }
details { margin-top: 10px; }
summary { color: #6b7280; font-size: 12px; cursor: pointer; }
summary:hover { color: #4f46e5; }
.field { margin-top: 10px; }
.field label { display: block; margin-bottom: 4px; color: #6b7280; font-size: 12px; }
#system {
width: 100%;
min-height: 76px;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
font: inherit;
font-size: 13px;
resize: vertical;
}
#reasoning {
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
font: inherit;
font-size: 13px;
}
#chat { padding: 20px 16px; overflow-y: auto; }
.message {
max-width: 80%;
margin: 0 0 14px;
padding: 10px 14px;
border-radius: 12px;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.assistant { background: #f3f4f6; }
.user { margin-left: auto; color: #fff; background: #4f46e5; }
.error { color: #991b1b; background: #fee2e2; }
.pending { color: #6b7280; font-style: italic; }
.reasoning {
margin-bottom: 8px;
padding-left: 9px;
border-left: 2px solid #d1d5db;
color: #6b7280;
font-size: 13px;
font-style: italic;
}
form {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
padding: 14px 16px;
border-top: 1px solid #e5e7eb;
}
textarea {
min-height: 60px;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font: inherit;
resize: vertical;
}
button {
padding: 10px 20px;
color: #fff;
background: #4f46e5;
border: 0;
border-radius: 8px;
font: inherit;
font-weight: 600;
cursor: pointer;
align-self: end;
}
button:disabled { opacity: 0.5; cursor: wait; }
textarea:focus, button:focus-visible, select:focus-visible, summary:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
#status { min-height: 32px; margin: 0; padding: 0 16px 12px; color: #6b7280; font-size: 12px; }
#status.busy::before {
content: "";
display: inline-block;
width: 10px;
height: 10px;
margin-right: 8px;
vertical-align: -1px;
border: 2px solid #d1d5db;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-color-scheme: dark) {
:root { color-scheme: dark; }
body { color: #f3f4f6; background: #111827; }
header, form { border-color: #374151; }
header p, #status, .pending, summary, .field label, .reasoning { color: #9ca3af; }
summary:hover { color: #818cf8; }
code { color: #d1d5db; }
#system, textarea, #reasoning { color: #f3f4f6; background: #1f2937; border-color: #4b5563; }
.assistant { background: #1f2937; }
.error { color: #fecaca; background: #450a0a; }
.reasoning { border-color: #4b5563; }
#status.busy::before { border-color: #4b5563; border-top-color: #818cf8; }
}
@media (max-width: 620px) {
.message { max-width: 92%; }
form { grid-template-columns: 1fr; }
button { width: 100%; }
}
</style>
</head>
<body>
<main>
<header>
<h1>Humanlike Chat</h1>
<p>
Live demo of the hosted endpoint <code>https://api.lessthanthreeai.com/v1</code>.
</p>
<details>
<summary>Settings</summary>
<div class="field">
<label for="system">System prompt</label>
<textarea id="system"
placeholder="Optional. Sets identity, setting, or relationship for the conversation."></textarea>
</div>
<div class="field">
<label for="reasoning">Thinking</label>
<select id="reasoning">
<option value="off">Off (fastest, the default)</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
</details>
</header>
<div id="chat" aria-live="polite"></div>
<form id="form">
<textarea id="prompt" required aria-label="Message" placeholder="Type a message"></textarea>
<button id="send" type="submit">Send</button>
</form>
<p id="status" aria-live="polite"></p>
</main>
<script>
const API = "https://api.lessthanthreeai.com/v1";
const MODEL = "gemma-4-31b-humanlike-chat";
const INTRO = "Say something. The first reply can take a few minutes while the inference worker starts.";
const MAX_TOKENS = { off: 512, low: 4096, medium: 4096, high: 8192 };
const chat = document.querySelector("#chat");
const form = document.querySelector("#form");
const prompt = document.querySelector("#prompt");
const send = document.querySelector("#send");
const status = document.querySelector("#status");
const system = document.querySelector("#system");
const reasoning = document.querySelector("#reasoning");
// One conversation per page load. Reloading starts a fresh session.
const messages = [];
system.value = localStorage.getItem("systemPrompt") || "";
reasoning.value = localStorage.getItem("thinkingLevel") in MAX_TOKENS ? localStorage.getItem("thinkingLevel") : "off";
system.addEventListener("input", () => localStorage.setItem("systemPrompt", system.value));
reasoning.addEventListener("change", () => localStorage.setItem("thinkingLevel", reasoning.value));
function addMessage(role, text) {
const element = document.createElement("div");
element.className = `message ${role}`;
element.textContent = text;
chat.append(element);
chat.scrollTop = chat.scrollHeight;
return element;
}
function addAssistantMessage() {
const element = document.createElement("div");
element.className = "message assistant";
const thoughts = document.createElement("div");
thoughts.className = "reasoning";
thoughts.hidden = true;
const content = document.createElement("div");
content.className = "pending";
content.textContent = "waiting for the first token";
element.append(thoughts, content);
chat.append(element);
chat.scrollTop = chat.scrollHeight;
return { element, thoughts, content };
}
// Server-sent events arrive as "data: {...}" chunks. The endpoint may also send
// ": status={...}" comment lines while a cold worker starts.
function applyEvent(line, target) {
if (line.startsWith(":")) {
const payload = line.slice(1).trim().replace(/^status=/, "");
try {
const state = JSON.parse(payload);
if (state.message) status.textContent = state.message;
} catch {}
return;
}
if (!line.startsWith("data:")) return;
const payload = line.slice(5).trim();
if (!payload || payload === "[DONE]") return;
const delta = (JSON.parse(payload).choices || [{}])[0].delta || {};
if (delta.reasoning) {
target.thoughts.hidden = false;
target.thoughts.textContent += delta.reasoning;
chat.scrollTop = chat.scrollHeight;
}
if (delta.content) {
if (target.content.classList.contains("pending")) {
target.content.classList.remove("pending");
target.content.textContent = "";
status.textContent = "";
}
target.content.textContent += delta.content;
chat.scrollTop = chat.scrollHeight;
}
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const text = prompt.value.trim();
if (!text) return;
messages.push({ role: "user", content: text });
addMessage("user", text);
prompt.value = "";
send.disabled = true;
status.classList.add("busy");
status.textContent = "Starting the model. A cold start can take a few minutes.";
const target = addAssistantMessage();
const effort = reasoning.value;
const systemPrompt = system.value.trim();
const request = systemPrompt
? [{ role: "system", content: systemPrompt }, ...messages]
: messages;
const chatTemplateKwargs = effort === "off"
? { enable_thinking: false, preserve_thinking: false }
: { enable_thinking: true, reasoning_effort: effort, preserve_thinking: false };
try {
const response = await fetch(`${API}/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: MODEL,
messages: request,
stream: true,
max_tokens: MAX_TOKENS[effort],
chat_template_kwargs: chatTemplateKwargs,
}),
});
if (!response.ok) {
const body = await response.text();
let message = body;
try { message = JSON.parse(body).error?.message || body; } catch {}
throw new Error(message || `HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) applyEvent(line.trim(), target);
if (done) break;
}
if (buffer.trim()) applyEvent(buffer.trim(), target);
target.content.classList.remove("pending");
if (!target.content.textContent) target.content.textContent = "(no final answer returned)";
messages.push({ role: "assistant", content: target.content.textContent });
status.textContent = "Complete. Later replies are fast while the worker stays warm.";
} catch (error) {
target.content.classList.remove("pending");
target.element.classList.add("error");
target.content.textContent = `Request failed: ${error.message}`;
status.textContent = "The inference service did not complete the request.";
} finally {
status.classList.remove("busy");
send.disabled = false;
prompt.focus();
}
});
addMessage("assistant", INTRO);
prompt.focus();
</script>
</body>
</html>