mirror of
https://github.com/coalaura/whiskr.git
synced 2025-09-09 09:19:54 +00:00
423 lines
7.8 KiB
JavaScript
423 lines
7.8 KiB
JavaScript
![]() |
(() => {
|
||
|
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"),
|
||
|
$temperature = document.getElementById("temperature"),
|
||
|
$add = document.getElementById("add"),
|
||
|
$send = document.getElementById("send"),
|
||
|
$clear = document.getElementById("clear");
|
||
|
|
||
|
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.");
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
models.sort((a, b) => a.name > b.name);
|
||
|
|
||
|
$model.innerHTML = "";
|
||
|
|
||
|
for (const model of models) {
|
||
|
const el = document.createElement("option");
|
||
|
|
||
|
el.value = model.id;
|
||
|
el.textContent = model.name;
|
||
|
|
||
|
$model.appendChild(el);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function restore(models) {
|
||
|
$role.value = localStorage.getItem("role") || "user";
|
||
|
$model.value = localStorage.getItem("model") || models[0].id;
|
||
|
$temperature.value = localStorage.getItem("temperature") || 0.85;
|
||
|
|
||
|
try {
|
||
|
const messages = JSON.parse(localStorage.getItem("messages") || "[]");
|
||
|
|
||
|
messages.forEach(addMessage);
|
||
|
} catch {}
|
||
|
}
|
||
|
|
||
|
function saveMessages() {
|
||
|
localStorage.setItem("messages", JSON.stringify(buildMessages(false)));
|
||
|
}
|
||
|
|
||
|
function scrollMessages() {
|
||
|
$messages.scroll({
|
||
|
top: $messages.scrollHeight + 200,
|
||
|
behavior: "smooth",
|
||
|
});
|
||
|
}
|
||
|
|
||
|
function toggleEditing(el) {
|
||
|
const text = el.querySelector("div.text"),
|
||
|
edit = el.querySelector("textarea.text");
|
||
|
|
||
|
if (el.classList.contains("editing")) {
|
||
|
text.textContent = edit.value.trim();
|
||
|
|
||
|
el.classList.remove("editing");
|
||
|
|
||
|
saveMessages();
|
||
|
} else {
|
||
|
edit.value = text.textContent;
|
||
|
edit.style.height = `${text.offsetHeight}px`;
|
||
|
|
||
|
el.classList.add("editing");
|
||
|
|
||
|
edit.focus();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function addMessage(message) {
|
||
|
const el = document.createElement("div");
|
||
|
|
||
|
el.classList.add("message", message.role);
|
||
|
|
||
|
// message role
|
||
|
const role = document.createElement("div");
|
||
|
|
||
|
role.textContent = message.role;
|
||
|
role.classList.add("role");
|
||
|
|
||
|
el.appendChild(role);
|
||
|
|
||
|
// message content
|
||
|
const text = document.createElement("div");
|
||
|
|
||
|
text.textContent = message.content;
|
||
|
text.classList.add("text");
|
||
|
|
||
|
el.appendChild(text);
|
||
|
|
||
|
// message edit textarea
|
||
|
const edit = document.createElement("textarea");
|
||
|
|
||
|
edit.classList.add("text");
|
||
|
|
||
|
el.appendChild(edit);
|
||
|
|
||
|
// message options
|
||
|
const opts = document.createElement("div");
|
||
|
|
||
|
opts.classList.add("options");
|
||
|
|
||
|
el.appendChild(opts);
|
||
|
|
||
|
// edit option
|
||
|
const optEdit = document.createElement("button");
|
||
|
|
||
|
optEdit.title = "Edit message content";
|
||
|
optEdit.classList.add("edit");
|
||
|
|
||
|
opts.appendChild(optEdit);
|
||
|
|
||
|
optEdit.addEventListener("click", () => {
|
||
|
toggleEditing(el);
|
||
|
});
|
||
|
|
||
|
// delete option
|
||
|
const optDelete = document.createElement("button");
|
||
|
|
||
|
optDelete.title = "Delete message";
|
||
|
optDelete.classList.add("delete");
|
||
|
|
||
|
opts.appendChild(optDelete);
|
||
|
|
||
|
optDelete.addEventListener("click", () => {
|
||
|
el.remove();
|
||
|
|
||
|
saveMessages();
|
||
|
});
|
||
|
|
||
|
// append to messages
|
||
|
$messages.appendChild(el);
|
||
|
|
||
|
scrollMessages();
|
||
|
|
||
|
return {
|
||
|
set(content) {
|
||
|
text.textContent = content;
|
||
|
|
||
|
scrollMessages();
|
||
|
},
|
||
|
state(state) {
|
||
|
if (state && el.classList.contains(state)) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
el.classList.remove("waiting", "reasoning", "receiving");
|
||
|
|
||
|
if (state) {
|
||
|
el.classList.add(state);
|
||
|
}
|
||
|
|
||
|
scrollMessages();
|
||
|
},
|
||
|
};
|
||
|
}
|
||
|
|
||
|
function pushMessage() {
|
||
|
const text = $message.value.trim();
|
||
|
|
||
|
if (!text) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
addMessage({
|
||
|
role: $role.value,
|
||
|
content: text,
|
||
|
});
|
||
|
|
||
|
$message.value = "";
|
||
|
|
||
|
saveMessages();
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
function buildMessages(clean = true) {
|
||
|
const messages = [];
|
||
|
|
||
|
$messages.querySelectorAll(".message").forEach((message) => {
|
||
|
if (clean && message.classList.contains("editing")) {
|
||
|
toggleEditing(message);
|
||
|
}
|
||
|
|
||
|
const role = message.querySelector(".role"),
|
||
|
text = message.querySelector(".text");
|
||
|
|
||
|
if (!role || !text) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
messages.push({
|
||
|
role: role.textContent.trim(),
|
||
|
content: text.textContent.trim().replace(/\r/g, ""),
|
||
|
});
|
||
|
});
|
||
|
|
||
|
return messages;
|
||
|
}
|
||
|
|
||
|
$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", () => {
|
||
|
$messages.scroll({
|
||
|
top: $messages.scrollHeight,
|
||
|
behavior: "smooth",
|
||
|
});
|
||
|
});
|
||
|
|
||
|
$role.addEventListener("change", () => {
|
||
|
localStorage.setItem("role", $role.value);
|
||
|
});
|
||
|
|
||
|
$model.addEventListener("change", () => {
|
||
|
localStorage.setItem("model", $model.value);
|
||
|
});
|
||
|
|
||
|
$temperature.addEventListener("input", () => {
|
||
|
localStorage.setItem("temperature", $temperature.value);
|
||
|
});
|
||
|
|
||
|
$message.addEventListener("input", () => {
|
||
|
localStorage.setItem("message", $message.value);
|
||
|
});
|
||
|
|
||
|
$add.addEventListener("click", () => {
|
||
|
pushMessage();
|
||
|
});
|
||
|
|
||
|
$clear.addEventListener("click", () => {
|
||
|
if (!confirm("Are you sure you want to delete all messages?")) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
$messages.innerHTML = "";
|
||
|
|
||
|
saveMessages();
|
||
|
});
|
||
|
|
||
|
$send.addEventListener("click", () => {
|
||
|
if (controller) {
|
||
|
controller.abort();
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const temperature = parseFloat($temperature.value);
|
||
|
|
||
|
if (Number.isNaN(temperature) || temperature < 0 || temperature > 1) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
pushMessage();
|
||
|
saveMessages();
|
||
|
|
||
|
controller = new AbortController();
|
||
|
|
||
|
$chat.classList.add("completing");
|
||
|
|
||
|
const body = {
|
||
|
model: $model.value,
|
||
|
temperature: temperature,
|
||
|
messages: buildMessages(),
|
||
|
};
|
||
|
|
||
|
const result = {
|
||
|
role: "assistant",
|
||
|
content: "",
|
||
|
};
|
||
|
|
||
|
const message = addMessage(result);
|
||
|
|
||
|
message.state("waiting");
|
||
|
|
||
|
stream(
|
||
|
"/-/chat",
|
||
|
{
|
||
|
method: "POST",
|
||
|
headers: {
|
||
|
"Content-Type": "application/json",
|
||
|
},
|
||
|
body: JSON.stringify(body),
|
||
|
signal: controller.signal,
|
||
|
},
|
||
|
(chunk) => {
|
||
|
if (!chunk) {
|
||
|
controller = null;
|
||
|
|
||
|
saveMessages();
|
||
|
|
||
|
$chat.classList.remove("completing");
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
switch (chunk.type) {
|
||
|
case "reason":
|
||
|
message.state("reasoning");
|
||
|
|
||
|
break;
|
||
|
case "text":
|
||
|
result.content += chunk.text;
|
||
|
|
||
|
message.state("receive");
|
||
|
message.set(result.content);
|
||
|
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
saveMessages();
|
||
|
},
|
||
|
);
|
||
|
});
|
||
|
|
||
|
$message.addEventListener("keydown", (event) => {
|
||
|
if (!event.ctrlKey || event.key !== "Enter") {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
$send.click();
|
||
|
});
|
||
|
|
||
|
loadModels().then(restore);
|
||
|
})();
|