mirror of
https://github.com/coalaura/whiskr.git
synced 2025-09-09 17:29:54 +00:00
initial commit
This commit is contained in:
422
static/chat.js
Normal file
422
static/chat.js
Normal file
@@ -0,0 +1,422 @@
|
||||
(() => {
|
||||
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);
|
||||
})();
|
Reference in New Issue
Block a user