1
0
mirror of https://github.com/coalaura/whiskr.git synced 2025-09-08 17:06:42 +00:00

initial commit

This commit is contained in:
Laura
2025-08-05 03:56:23 +02:00
commit 4b40053ce9
27 changed files with 1287 additions and 0 deletions

326
static/chat.css Normal file
View File

@@ -0,0 +1,326 @@
* {
box-sizing: border-box;
}
html,
body {
font-family: "Nata Sans", sans-serif;
font-size: 15px;
background: #181926;
color: #cad3f5;
height: 100%;
width: 100%;
margin: 0;
}
body {
display: flex;
overflow: hidden;
}
#page {
display: flex;
flex-direction: column;
gap: 25px;
background: #1e2030;
box-shadow: 0px 0px 4px 4px #1e2030;
margin: auto;
margin-top: 30px;
width: 100%;
max-width: 1100px;
height: calc(100% - 30px);
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.hidden {
opacity: 0 !important;
pointer-events: none !important;
}
#messages {
display: flex;
flex-direction: column;
gap: 65px;
height: 100%;
overflow-y: auto;
padding: 14px 12px;
}
#messages:empty::before {
content: "no messages";
color: #a5adcb;
font-style: italic;
align-self: center;
}
#message,
.message {
border: none;
box-shadow: 0px 0px 4px 4px #24273a;
border-radius: 6px;
background: #24273a;
font: inherit;
color: inherit;
}
.message {
position: relative;
max-width: 700px;
min-width: 200px;
width: max-content;
}
.message.user {
align-self: end;
}
.message.system {
align-self: center;
}
.message .role {
position: absolute;
line-height: 12px;
font-size: 12px;
top: 4px;
left: 4px;
padding-left: 20px;
}
#messages .message .role::before {
content: "";
width: 16px;
height: 16px;
position: absolute;
top: -1px;
left: 0;
}
.message.user .role::before {
background-image: url(icons/user.svg);
}
.message.system .role::before {
background-image: url(icons/system.svg);
}
.message.assistant .role::before {
background-image: url(icons/assistant.svg);
}
.message .text {
display: block;
background: transparent;
padding: 10px 12px;
padding-top: 28px;
white-space: pre-line;
width: 100%;
}
.message:not(.editing) textarea.text,
.message.editing div.text {
display: none;
}
textarea {
border: none;
resize: none;
outline: none;
color: inherit;
font: inherit;
}
#chat {
display: flex;
position: relative;
justify-content: center;
padding: 0 12px;
height: 240px;
}
#message {
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
width: 100%;
height: 100%;
padding: 14px 16px;
}
.message .options {
display: flex;
gap: 4px;
position: absolute;
top: 4px;
right: 4px;
opacity: 0;
pointer-events: none;
transition: 150ms;
}
.message:hover .options {
opacity: 1;
pointer-events: all;
}
.message.waiting .options,
.message.reasoning .options,
.message.receiving .options {
display: none;
}
.message .text::before {
font-style: italic;
}
.message.waiting .text::before {
content: "waiting...";
}
.message.reasoning .text::before {
content: "reasoning...";
}
.message:empty.receiving .text::before {
content: "receiving...";
}
button {
background: transparent;
border: none;
color: inherit;
cursor: pointer;
}
#chat button {
position: absolute;
}
input,
select {
border: none;
background: #363a4f;
font: inherit;
color: inherit;
outline: none;
}
#chat .options {
position: absolute;
bottom: 4px;
left: 12px;
width: max-content;
display: flex;
align-items: center;
gap: 12px;
}
#chat .option {
display: flex;
align-items: center;
gap: 4px;
}
#chat .option+.option::before {
content: "";
display: block;
width: 2px;
height: 12px;
background: #5b6078;
margin-right: 4px;
}
#bottom,
.message .role::before,
#clear,
#add,
#send,
.message .edit,
.message .delete,
#chat .option label {
display: block;
width: 20px;
height: 20px;
background-position: center;
background-size: contain;
background-repeat: no-repeat;
}
.message .edit {
background-image: url(icons/edit.svg);
}
.message.editing .edit {
background-image: url(icons/save.svg);
}
.message .delete {
background-image: url(icons/delete.svg);
}
#chat .option label {
background-size: 18px;
}
#model {
width: 180px;
padding: 2px 4px;
text-align: right;
}
#temperature {
appearance: textfield;
width: 50px;
padding: 2px 4px;
text-align: right;
}
label[for="role"] {
background-image: url(icons/user.svg);
}
label[for="model"] {
background-image: url(icons/model.svg);
}
label[for="temperature"] {
background-image: url(icons/temperature.svg);
}
#bottom {
top: -38px;
left: 50%;
transform: translateX(-50%);
width: 28px;
height: 28px;
background-image: url(icons/down.svg);
transition: 150ms;
}
#add,
#send {
bottom: 4px;
right: 12px;
width: 28px;
height: 28px;
background-image: url(icons/send.svg);
}
#add {
bottom: 4px;
right: 44px;
background-image: url(icons/add.svg);
}
#clear {
position: unset !important;
background-image: url(icons/trash.svg);
}
.completing #add {
display: none;
}
.completing #send {
background-image: url(icons/stop.svg);
}

422
static/chat.js Normal file
View 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);
})();

7
static/icons/add.svg Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 889 B

View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 668 B

7
static/icons/delete.svg Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 699 B

7
static/icons/down.svg Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 586 B

7
static/icons/edit.svg Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 1001 B

7
static/icons/model.svg Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 1.2 KiB

7
static/icons/save.svg Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 1.1 KiB

7
static/icons/send.svg Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 808 B

7
static/icons/stop.svg Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 812 B

7
static/icons/system.svg Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 912 B

7
static/icons/trash.svg Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 805 B

7
static/icons/user.svg Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 918 B

52
static/index.html Normal file
View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Nata+Sans:wght@100..900&display=swap" rel="stylesheet" />
<link href="chat.css" rel="stylesheet" />
<title>chat</title>
</head>
<body>
<div id="page">
<div id="messages"></div>
<div id="chat">
<button id="bottom" class="hidden" title="Scroll to bottom"></button>
<textarea id="message" placeholder="Type something..."></textarea>
<button id="add" title="Add message"></button>
<button id="send" title="Add message and start completion"></button>
<div class="options">
<div class="option">
<label for="role" title="Role"></label>
<select id="role">
<option value="user" selected>User</option>
<option value="assistant">Assistant</option>
<option value="system">System</option>
</select>
</div>
<div class="option">
<label for="model" title="Model"></label>
<select id="model"></select>
</div>
<div class="option">
<label for="temperature" title="Temperature (0 - 1)"></label>
<input id="temperature" type="number" min="0" max="1" step="0.05" value="0.85" />
</div>
<div class="option">
<button id="clear" title="Clear the entire chat"></button>
</div>
</div>
</div>
</div>
<script src="chat.js"></script>
</body>
</html>