const transcript = document.querySelector("#messages");
const scroller = document.querySelector("#conversation-scroll");
const form = document.querySelector("form");
const input = document.querySelector("textarea");
const send = document.querySelector("#send");
const status = document.querySelector("#status");
const history = [];
let pending = false;
function resizeInput() {
input.style.height = "32px";
input.style.height = `${Math.min(96, input.scrollHeight)}px`;
send.disabled = pending || !input.value.trim();
}
function addMessage(role, text) {
const element = document.createElement("div");
element.className = `message ${role}`;
element.textContent = text; // Model output and file contents are never HTML.
transcript.append(element);
return element;
}
function requestPayload(question) {
const messages = history.slice(-200);
const encoder = new TextEncoder();
while (messages.length && encoder.encode(JSON.stringify({ question, messages })).length > 64 * 1024) messages.splice(0, 2);
const json = JSON.stringify({ question, messages });
if (encoder.encode(json).length > 64 * 1024) throw new Error("Please shorten your message.");
return json;
}
async function submit() {
const question = input.value.trim();
if (pending || !question) return;
let userMessage;
pending = true;
send.disabled = true;
input.readOnly = true;
status.className = "";
status.textContent = "Thinking…";
try {
const payload = requestPayload(question);
userMessage = addMessage("user", question);
input.value = "";
resizeInput();
scroller.scrollTop = scroller.scrollHeight;
const result = await window.wimble.run({ path: "./chat.run.js", args: [payload] });
if (result.exitCode !== 0 || !result.contentType.startsWith("application/json")) throw new Error("I couldn't finish that reply. Please try again.");
const reply = JSON.parse(result.stdout);
if (typeof reply.answer !== "string") throw new Error("I couldn't finish that reply. Please try again.");
const nearBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 60;
addMessage("assistant", reply.answer);
if (reply.sources?.length) {
const links = document.createElement("div");
links.className = "sources";
for (const source of reply.sources) {
const url = new URL(source.url);
if (url.origin !== "https://wimble.run") continue;
const link = document.createElement("a");
link.href = url.href; link.target = "_blank"; link.rel = "noopener noreferrer"; link.textContent = source.path;
links.append(link);
}
transcript.append(links);
}
history.push({ role: "user", content: question }, { role: "assistant", content: reply.answer });
if (nearBottom) scroller.scrollTop = scroller.scrollHeight;
status.textContent = "";
} catch (error) {
userMessage?.remove();
input.value = question;
status.className = "error";
status.textContent = error instanceof Error ? error.message : "Couldn't connect. Please try again.";
} finally {
pending = false;
input.readOnly = false;
resizeInput();
}
}
form.addEventListener("submit", event => { event.preventDefault(); void submit(); });
input.addEventListener("input", resizeInput);
input.addEventListener("keydown", event => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); void submit(); }
});
// Keep the original pointer-following face, including pointer updates outside
// this iframe delivered by Wimble's bridge.
const face = document.querySelector(".face");
const pupils = [...document.querySelectorAll(".pupil")];
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
let bridgePointer = null, frame = null, localPointer = null;
let x = 0, y = 0;
function animate() {
const pointer = localPointer || (bridgePointer && frame ? { x: bridgePointer.x - frame.left, y: bridgePointer.y - frame.top } : null);
const rect = face.getBoundingClientRect();
const dx = pointer ? pointer.x - rect.left - rect.width / 2 : 0;
const dy = pointer ? pointer.y - rect.top - rect.height / 2 : 0;
const distance = Math.hypot(dx, dy) || 1;
const travel = Math.min(8, distance * .08);
const easing = reduceMotion ? 1 : .22;
x += (dx / distance * travel - x) * easing;
y += (dy / distance * travel - y) * easing;
for (const pupil of pupils) {
pupil.style.setProperty("--pupil-x", `${x.toFixed(2)}px`);
pupil.style.setProperty("--pupil-y", `${y.toFixed(2)}px`);
}
requestAnimationFrame(animate);
}
addEventListener("pointermove", event => { if (event.isPrimary) localPointer = { x: event.clientX, y: event.clientY }; }, { passive: true });
addEventListener("pointerout", event => { if (event.isPrimary && event.relatedTarget === null) localPointer = null; }, { passive: true });
addEventListener("pointerleave", () => { localPointer = null; }, { passive: true });
addEventListener("pointerup", event => { if (event.isPrimary && event.pointerType !== "mouse") localPointer = null; }, { passive: true });
addEventListener("pointercancel", () => { localPointer = null; }, { passive: true });
window.wimble.subscribe("pointer", value => { bridgePointer = value; });
window.wimble.subscribe("frame", value => { frame = value; });
animate();