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

846 lines
16 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"),
2025-08-10 22:32:40 +02:00
$reasoningEffort = document.getElementById("reasoning-effort"),
$reasoningTokens = document.getElementById("reasoning-tokens"),
2025-08-11 00:15:58 +02:00
$json = document.getElementById("json"),
$search = document.getElementById("search"),
2025-08-05 03:56:23 +02:00
$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");
2025-08-10 22:32:40 +02:00
const messages = [],
models = {};
2025-08-10 15:53:30 +02:00
let autoScrolling = false,
2025-08-11 00:15:58 +02:00
jsonMode = false,
searchTool = false,
2025-08-10 15:53:30 +02:00
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({
2025-08-11 00:15:58 +02:00
top: $messages.scrollHeight,
2025-08-10 15:53:30 +02:00
behavior: "smooth",
});
}, 0);
}
class Message {
#id;
#role;
#reasoning;
#text;
2025-08-11 00:15:58 +02:00
#tags = [];
#error = false;
#editing = false;
#expanded = false;
#state = false;
2025-08-10 15:53:30 +02:00
#_diff;
#pending = {};
#patching = {};
#_message;
2025-08-11 00:15:58 +02:00
#_tags;
#_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);
2025-08-11 00:15:58 +02:00
// message role (wrapper)
const _wrapper = make("div", "role", this.#role);
this.#_message.appendChild(_wrapper);
// message role
2025-08-11 00:15:58 +02:00
const _role = make("div");
_role.textContent = this.#role;
_wrapper.appendChild(_role);
2025-08-11 00:15:58 +02:00
// message tags
this.#_tags = make("div", "tags");
_wrapper.appendChild(this.#_tags);
// 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) {
2025-08-11 00:15:58 +02:00
if (!only || only === "tags") {
this.#_tags.innerHTML = this.#tags
.map((tag) => `<div class="tag-${tag}" title="${tag}"></div>`)
.join("");
this.#_message.classList.toggle("has-tags", this.#tags.length > 0);
}
if (this.#error) {
return;
}
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-11 00:15:58 +02:00
let text = this.#text;
2025-08-11 01:17:12 +02:00
if (text && this.#tags.includes("json")) {
2025-08-11 00:15:58 +02:00
text = `\`\`\`json\n${text}\n\`\`\``;
}
this.#patch("text", this.#_text, text, () => {
2025-08-10 15:53:30 +02:00
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",
2025-08-11 00:15:58 +02:00
messages.map((message) => message.getData(true)).filter(Boolean),
2025-08-09 21:16:24 +02:00
);
}
2025-08-11 00:15:58 +02:00
getData(full = false) {
const data = {
role: this.#role,
text: this.#text,
};
2025-08-11 00:15:58 +02:00
if (this.#reasoning && full) {
data.reasoning = this.#reasoning;
}
2025-08-11 00:15:58 +02:00
if (this.#error && full) {
data.error = this.#error;
}
if (this.#tags.length && full) {
data.tags = this.#tags;
}
return data;
}
2025-08-11 00:15:58 +02:00
addTag(tag) {
if (this.#tags.includes(tag)) {
return;
}
this.#tags.push(tag);
this.#render("tags");
if (tag === "json") {
this.#render("text");
}
this.#save();
}
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();
}
2025-08-11 00:15:58 +02:00
showError(error) {
this.#error = error;
this.#_message.classList.add("errored");
const _err = make("div", "error");
_err.textContent = this.#error;
this.#_text.appendChild(_err);
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) {
2025-08-11 00:15:58 +02:00
const err = await response.json();
throw new Error(err?.error || response.statusText);
2025-08-05 03:56:23 +02:00
}
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() {
2025-08-10 22:32:40 +02:00
const modelList = await json("/-/models");
2025-08-05 03:56:23 +02:00
2025-08-10 22:32:40 +02:00
if (!modelList) {
2025-08-05 03:56:23 +02:00
alert("Failed to load models.");
2025-08-09 21:16:24 +02:00
return [];
2025-08-05 03:56:23 +02:00
}
$model.innerHTML = "";
2025-08-10 22:32:40 +02:00
for (const model of modelList) {
2025-08-05 03:56:23 +02:00
const el = document.createElement("option");
el.value = model.id;
2025-08-10 16:38:02 +02:00
el.title = model.description;
2025-08-05 03:56:23 +02:00
el.textContent = model.name;
2025-08-10 16:38:02 +02:00
el.dataset.tags = (model.tags || []).join(",");
2025-08-05 03:56:23 +02:00
$model.appendChild(el);
2025-08-10 22:32:40 +02:00
models[model.id] = model;
2025-08-05 03:56:23 +02:00
}
2025-08-09 21:16:24 +02:00
2025-08-11 00:15:58 +02:00
dropdown($model, 4);
2025-08-09 21:16:24 +02:00
2025-08-10 22:32:40 +02:00
return modelList;
2025-08-05 03:56:23 +02:00
}
2025-08-10 22:32:40 +02:00
function restore(modelList) {
2025-08-09 21:16:24 +02:00
$role.value = loadValue("role", "user");
2025-08-10 22:32:40 +02:00
$model.value = loadValue("model", modelList[0].id);
2025-08-09 21:16:24 +02:00
$prompt.value = loadValue("prompt", "normal");
$temperature.value = loadValue("temperature", 0.85);
2025-08-10 22:32:40 +02:00
$reasoningEffort.value = loadValue("reasoning-effort", "medium");
$reasoningTokens.value = loadValue("reasoning-tokens", 1024);
2025-08-05 03:56:23 +02:00
2025-08-11 00:15:58 +02:00
if (loadValue("json")) {
$json.click();
}
if (loadValue("search")) {
$search.click();
}
2025-08-09 21:16:24 +02:00
if (loadValue("scrolling")) {
$scrolling.click();
}
2025-08-11 00:15:58 +02:00
loadValue("messages", []).forEach((message) => {
const obj = new Message(message.role, message.reasoning, message.text);
if (message.error) {
obj.showError(message.error);
}
if (message.tags) {
message.tags.forEach((tag) => obj.addTag(tag));
}
});
2025-08-09 21:16:24 +02:00
scroll(true);
2025-08-10 22:36:15 +02:00
// small fix, sometimes when hard reloading we don't scroll all the way
setTimeout(scroll, 250, 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", () => {
2025-08-10 22:32:40 +02:00
const model = $model.value,
data = model ? models[model] : null;
storeValue("model", model);
if (data?.tags.includes("reasoning")) {
$reasoningEffort.parentNode.classList.remove("none");
$reasoningTokens.parentNode.classList.toggle(
"none",
!!$reasoningEffort.value,
);
} else {
$reasoningEffort.parentNode.classList.add("none");
$reasoningTokens.parentNode.classList.add("none");
}
2025-08-11 00:15:58 +02:00
$json.classList.toggle("none", !data?.tags.includes("json"));
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", () => {
2025-08-11 00:15:58 +02:00
const value = $temperature.value,
temperature = parseFloat(value);
storeValue("temperature", value);
$temperature.classList.toggle(
"invalid",
Number.isNaN(temperature) || temperature < 0 || temperature > 2,
);
2025-08-05 03:56:23 +02:00
});
2025-08-10 22:32:40 +02:00
$reasoningEffort.addEventListener("change", () => {
const effort = $reasoningEffort.value;
storeValue("reasoning-effort", effort);
$reasoningTokens.parentNode.classList.toggle("none", !!effort);
});
2025-08-11 00:15:58 +02:00
$reasoningTokens.addEventListener("input", () => {
const value = $reasoningTokens.value,
tokens = parseInt(value);
storeValue("reasoning-tokens", value);
$reasoningTokens.classList.toggle(
"invalid",
Number.isNaN(tokens) || tokens <= 0 || tokens > 1024 * 1024,
);
});
$json.addEventListener("click", () => {
jsonMode = !jsonMode;
storeValue("json", jsonMode);
$json.classList.toggle("on", jsonMode);
});
$search.addEventListener("click", () => {
searchTool = !searchTool;
storeValue("search", searchTool);
$search.classList.toggle("on", searchTool);
2025-08-10 22:32:40 +02:00
});
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");
2025-08-11 00:15:58 +02:00
scroll();
2025-08-09 21:16:24 +02:00
} 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;
}
2025-08-10 22:32:40 +02:00
if (!$temperature.value) {
$temperature.value = 0.85;
}
2025-08-05 03:56:23 +02:00
const temperature = parseFloat($temperature.value);
2025-08-11 00:15:58 +02:00
if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) {
2025-08-05 03:56:23 +02:00
return;
}
2025-08-10 22:32:40 +02:00
const effort = $reasoningEffort.value,
tokens = parseInt($reasoningTokens.value);
if (
!effort &&
(Number.isNaN(tokens) || tokens <= 0 || tokens > 1024 * 1024)
) {
return;
}
2025-08-05 03:56:23 +02:00
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,
2025-08-10 22:32:40 +02:00
reasoning: {
effort: effort,
tokens: tokens || 0,
},
2025-08-11 00:15:58 +02:00
json: jsonMode,
search: searchTool,
2025-08-11 01:22:31 +02:00
messages: messages
.map((message) => message.getData())
.filter((data) => data?.text),
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
2025-08-11 00:15:58 +02:00
if (jsonMode) {
message.addTag("json");
}
if (searchTool) {
message.addTag("search");
}
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
2025-08-11 00:15:58 +02:00
break;
case "error":
message.showError(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-10 22:32:40 +02:00
dropdown($reasoningEffort);
2025-08-09 22:14:35 +02:00
2025-08-05 03:56:23 +02:00
loadModels().then(restore);
})();