1
0
mirror of https://github.com/coalaura/whiskr.git synced 2025-09-09 09:19:54 +00:00
Files
whiskr/static/js/chat.js

647 lines
12 KiB
JavaScript
Raw Normal View History

2025-08-05 03:56:23 +02:00
(() => {
const $messages = document.getElementById("messages"),
$chat = document.getElementById("chat"),
$message = document.getElementById("message"),
$bottom = document.getElementById("bottom"),
$role = document.getElementById("role"),
$model = document.getElementById("model"),
$prompt = document.getElementById("prompt"),
2025-08-05 03:56:23 +02:00
$temperature = document.getElementById("temperature"),
$add = document.getElementById("add"),
$send = document.getElementById("send"),
2025-08-09 21:16:24 +02:00
$scrolling = document.getElementById("scrolling"),
2025-08-05 03:56:23 +02:00
$clear = document.getElementById("clear");
const messages = [];
2025-08-10 15:53:30 +02:00
let autoScrolling = false,
interacted = false;
2025-08-09 21:16:24 +02:00
function scroll(force = false) {
if (!autoScrolling && !force) {
return;
}
2025-08-10 15:53:30 +02:00
setTimeout(() => {
$messages.scroll({
top: $messages.scrollHeight + 200,
behavior: "smooth",
});
}, 0);
}
class Message {
#id;
#role;
#reasoning;
#text;
#editing = false;
#expanded = false;
#state = false;
2025-08-10 15:53:30 +02:00
#_diff;
#pending = {};
#patching = {};
#_message;
#_role;
#_reasoning;
#_text;
#_edit;
constructor(role, reasoning, text) {
this.#id = uid();
this.#role = role;
this.#reasoning = reasoning || "";
this.#text = text || "";
2025-08-10 15:53:30 +02:00
this.#_diff = document.createElement("div");
this.#build();
this.#render();
messages.push(this);
2025-08-10 15:53:30 +02:00
if (this.#reasoning || this.#text) {
this.#save();
}
}
#build() {
// main message div
this.#_message = make("div", "message", this.#role);
// message role
this.#_role = make("div", "role", this.#role);
this.#_message.appendChild(this.#_role);
// message reasoning (wrapper)
const _reasoning = make("div", "reasoning");
this.#_message.appendChild(_reasoning);
// message reasoning (toggle)
const _toggle = make("button", "toggle");
_toggle.textContent = "Reasoning";
_reasoning.appendChild(_toggle);
_toggle.addEventListener("click", () => {
this.#expanded = !this.#expanded;
if (this.#expanded) {
this.#_message.classList.add("expanded");
} else {
this.#_message.classList.remove("expanded");
}
});
// message reasoning (content)
this.#_reasoning = make("div", "reasoning-text", "markdown");
_reasoning.appendChild(this.#_reasoning);
// message content
this.#_text = make("div", "text", "markdown");
this.#_message.appendChild(this.#_text);
// message edit textarea
this.#_edit = make("textarea", "text");
this.#_message.appendChild(this.#_edit);
2025-08-09 21:16:24 +02:00
this.#_edit.addEventListener("keydown", (event) => {
if (event.ctrlKey && event.key === "Enter") {
this.toggleEdit();
} else if (event.key === "Escape") {
this.#_edit.value = this.#text;
this.toggleEdit();
}
});
// message options
const _opts = make("div", "options");
this.#_message.appendChild(_opts);
// copy option
const _optCopy = make("button", "copy");
_optCopy.title = "Copy message content";
_opts.appendChild(_optCopy);
let timeout;
_optCopy.addEventListener("click", () => {
clearTimeout(timeout);
navigator.clipboard.writeText(this.#text);
_optCopy.classList.add("copied");
timeout = setTimeout(() => {
_optCopy.classList.remove("copied");
}, 1000);
});
// edit option
const _optEdit = make("button", "edit");
_optEdit.title = "Edit message content";
_opts.appendChild(_optEdit);
_optEdit.addEventListener("click", () => {
this.toggleEdit();
});
// delete option
const _optDelete = make("button", "delete");
_optDelete.title = "Delete message";
_opts.appendChild(_optDelete);
_optDelete.addEventListener("click", () => {
this.delete();
});
2025-08-09 21:16:24 +02:00
// add to dom
$messages.appendChild(this.#_message);
scroll();
}
2025-08-10 15:53:30 +02:00
#handleImages(element) {
element.querySelectorAll("img:not(.image)").forEach((img) => {
img.classList.add("image");
img.addEventListener("load", () => {
scroll(!interacted);
});
});
}
#patch(name, element, md, after = false) {
if (!element.firstChild) {
element.innerHTML = render(md);
this.#handleImages(element);
after?.();
return;
}
this.#pending[name] = md;
if (this.#patching[name]) {
return;
}
this.#patching[name] = true;
schedule(() => {
const html = render(this.#pending[name]);
this.#patching[name] = false;
this.#_diff.innerHTML = html;
morphdom(element, this.#_diff, {
childrenOnly: true,
onBeforeElUpdated: (fromEl, toEl) => {
return !fromEl.isEqualNode || !fromEl.isEqualNode(toEl);
},
});
this.#_diff.innerHTML = "";
this.#handleImages(element);
after?.();
});
}
2025-08-09 21:16:24 +02:00
#render(only = false, noScroll = false) {
if (!only || only === "role") {
this.#_role.textContent = this.#role;
}
if (!only || only === "reasoning") {
2025-08-10 15:53:30 +02:00
this.#patch("reasoning", this.#_reasoning, this.#reasoning, () => {
this.#_reasoning.style.setProperty(
"--height",
`${this.#_reasoning.scrollHeight}px`,
);
2025-08-10 15:53:30 +02:00
noScroll || scroll();
});
2025-08-09 21:16:24 +02:00
2025-08-10 15:53:30 +02:00
this.#_message.classList.toggle("has-reasoning", !!this.#reasoning);
}
if (!only || only === "text") {
2025-08-10 15:53:30 +02:00
this.#patch("text", this.#_text, this.#text, () => {
noScroll || scroll();
});
2025-08-10 15:53:30 +02:00
this.#_message.classList.toggle("has-text", !!this.#text);
2025-08-09 21:16:24 +02:00
}
}
#save() {
2025-08-09 21:16:24 +02:00
storeValue(
"messages",
messages.map((message) => message.getData(true)),
);
}
getData(includeReasoning = false) {
const data = {
role: this.#role,
text: this.#text,
};
if (this.#reasoning && includeReasoning) {
data.reasoning = this.#reasoning;
}
return data;
}
setState(state) {
if (this.#state === state) {
return;
}
if (this.#state) {
this.#_message.classList.remove(this.#state);
}
if (state) {
this.#_message.classList.add(state);
}
this.#state = state;
}
addReasoning(chunk) {
this.#reasoning += chunk;
this.#render("reasoning");
this.#save();
}
addText(text) {
this.#text += text;
this.#render("text");
this.#save();
}
stopEdit() {
if (!this.#editing) {
return;
}
this.toggleEdit();
}
toggleEdit() {
this.#editing = !this.#editing;
if (this.#editing) {
this.#_edit.value = this.#text;
this.#_edit.style.height = `${this.#_text.offsetHeight}px`;
this.#_edit.style.width = `${this.#_text.offsetWidth}px`;
this.setState("editing");
this.#_edit.focus();
} else {
this.#text = this.#_edit.value;
this.setState(false);
2025-08-09 21:16:24 +02:00
this.#render(false, true);
this.#save();
}
}
delete() {
const index = messages.findIndex((msg) => msg.#id === this.#id);
if (index === -1) {
return;
}
2025-08-09 21:16:24 +02:00
this.#_message.remove();
messages.splice(index, 1);
this.#save();
2025-08-09 21:16:24 +02:00
$messages.dispatchEvent(new Event("scroll"));
}
}
2025-08-05 03:56:23 +02:00
let controller;
async function json(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(response.statusText);
}
return await response.json();
} catch (err) {
console.error(err);
return false;
}
}
async function stream(url, options, callback) {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(response.statusText);
}
const reader = response.body.getReader(),
decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, {
stream: true,
});
while (true) {
const idx = buffer.indexOf("\n\n");
if (idx === -1) {
break;
}
const frame = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 2);
if (!frame) {
continue;
}
try {
const chunk = JSON.parse(frame);
if (!chunk) {
throw new Error("invalid chunk");
}
callback(chunk);
} catch (err) {
console.warn("bad frame", frame);
console.warn(err);
}
}
}
} catch (err) {
if (err.name !== "AbortError") {
callback({
type: "error",
text: err.message,
});
}
} finally {
callback(false);
}
}
async function loadModels() {
const models = await json("/-/models");
if (!models) {
alert("Failed to load models.");
2025-08-09 21:16:24 +02:00
return [];
2025-08-05 03:56:23 +02:00
}
$model.innerHTML = "";
for (const model of models) {
const el = document.createElement("option");
el.value = model.id;
el.textContent = model.name;
$model.appendChild(el);
}
2025-08-09 21:16:24 +02:00
dropdown($model);
return models;
2025-08-05 03:56:23 +02:00
}
function restore(models) {
2025-08-09 21:16:24 +02:00
$role.value = loadValue("role", "user");
$model.value = loadValue("model", models[0].id);
$prompt.value = loadValue("prompt", "normal");
$temperature.value = loadValue("temperature", 0.85);
2025-08-05 03:56:23 +02:00
2025-08-09 21:16:24 +02:00
if (loadValue("scrolling")) {
$scrolling.click();
}
loadValue("messages", []).forEach(
(message) => new Message(message.role, message.reasoning, message.text),
);
scroll(true);
2025-08-05 03:56:23 +02:00
}
function pushMessage() {
const text = $message.value.trim();
if (!text) {
return false;
}
$message.value = "";
return new Message($role.value, "", text);
2025-08-05 03:56:23 +02:00
}
$messages.addEventListener("scroll", () => {
const bottom =
$messages.scrollHeight - ($messages.scrollTop + $messages.offsetHeight);
if (bottom >= 80) {
$bottom.classList.remove("hidden");
} else {
$bottom.classList.add("hidden");
}
});
$bottom.addEventListener("click", () => {
2025-08-09 21:16:24 +02:00
interacted = true;
scroll(true);
2025-08-05 03:56:23 +02:00
});
$role.addEventListener("change", () => {
storeValue("role", $role.value);
2025-08-05 03:56:23 +02:00
});
$model.addEventListener("change", () => {
storeValue("model", $model.value);
2025-08-05 03:56:23 +02:00
});
$prompt.addEventListener("change", () => {
storeValue("prompt", $prompt.value);
});
2025-08-05 03:56:23 +02:00
$temperature.addEventListener("input", () => {
storeValue("temperature", $temperature.value);
2025-08-05 03:56:23 +02:00
});
$message.addEventListener("input", () => {
storeValue("message", $message.value);
2025-08-05 03:56:23 +02:00
});
$add.addEventListener("click", () => {
2025-08-09 21:16:24 +02:00
interacted = true;
2025-08-05 03:56:23 +02:00
pushMessage();
});
$clear.addEventListener("click", () => {
if (!confirm("Are you sure you want to delete all messages?")) {
return;
}
2025-08-09 21:16:24 +02:00
interacted = true;
for (let x = messages.length - 1; x >= 0; x--) {
messages[x].delete();
}
2025-08-05 03:56:23 +02:00
});
2025-08-09 21:16:24 +02:00
$scrolling.addEventListener("click", () => {
interacted = true;
autoScrolling = !autoScrolling;
if (autoScrolling) {
$scrolling.title = "Turn off auto-scrolling";
$scrolling.classList.add("on");
} else {
$scrolling.title = "Turn on auto-scrolling";
$scrolling.classList.remove("on");
}
storeValue("scrolling", autoScrolling);
});
2025-08-05 03:56:23 +02:00
$send.addEventListener("click", () => {
2025-08-09 21:16:24 +02:00
interacted = true;
2025-08-05 03:56:23 +02:00
if (controller) {
controller.abort();
return;
}
const temperature = parseFloat($temperature.value);
if (Number.isNaN(temperature) || temperature < 0 || temperature > 1) {
return;
}
pushMessage();
controller = new AbortController();
$chat.classList.add("completing");
const body = {
prompt: $prompt.value,
2025-08-05 03:56:23 +02:00
model: $model.value,
temperature: temperature,
messages: messages.map((message) => message.getData()),
2025-08-05 03:56:23 +02:00
};
const message = new Message("assistant", "", "");
2025-08-05 03:56:23 +02:00
message.setState("waiting");
2025-08-05 03:56:23 +02:00
stream(
"/-/chat",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: controller.signal,
},
(chunk) => {
if (!chunk) {
controller = null;
message.setState(false);
2025-08-05 03:56:23 +02:00
$chat.classList.remove("completing");
return;
}
switch (chunk.type) {
case "reason":
message.setState("reasoning");
message.addReasoning(chunk.text);
2025-08-05 03:56:23 +02:00
break;
case "text":
message.setState("receiving");
message.addText(chunk.text);
2025-08-05 03:56:23 +02:00
break;
}
},
);
});
$message.addEventListener("keydown", (event) => {
if (!event.ctrlKey || event.key !== "Enter") {
return;
}
$send.click();
});
2025-08-09 21:16:24 +02:00
addEventListener("wheel", () => {
interacted = true;
});
2025-08-09 22:14:35 +02:00
dropdown($role);
dropdown($prompt);
2025-08-05 03:56:23 +02:00
loadModels().then(restore);
})();