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

cleanup styling, show reasoning, fixes & improvements

This commit is contained in:
Laura
2025-08-07 22:09:08 +02:00
parent c63d6d400f
commit 01989ef188
18 changed files with 624 additions and 209 deletions

View File

@@ -23,7 +23,6 @@ body {
flex-direction: column;
gap: 25px;
background: #1e2030;
box-shadow: 0px 0px 4px 4px #1e2030;
margin: auto;
margin-top: 30px;
width: 100%;
@@ -57,7 +56,6 @@ body {
#message,
.message {
border: none;
box-shadow: 0px 0px 4px 4px #24273a;
border-radius: 6px;
background: #24273a;
font: inherit;
@@ -69,6 +67,7 @@ body {
max-width: 700px;
min-width: 200px;
width: max-content;
padding-top: 22px;
}
.message.user {
@@ -83,8 +82,8 @@ body {
position: absolute;
line-height: 12px;
font-size: 12px;
top: 4px;
left: 4px;
top: 6px;
left: 6px;
padding-left: 20px;
}
@@ -109,12 +108,11 @@ body {
background-image: url(icons/assistant.svg);
}
.message .reasoning,
.message .text {
display: block;
background: transparent;
padding: 10px 12px;
padding-top: 28px;
white-space: pre-line;
padding: 8px 12px;
width: 100%;
}
@@ -123,6 +121,62 @@ body {
display: none;
}
.message:not(.expanded) .reasoning-text {
height: 0;
overflow: hidden;
}
.message.expanded .reasoning-text {
padding: 10px 12px;
background: #1e2030;
margin-top: 10px;
border-radius: 6px;
}
.message:not(.has-reasoning) .reasoning {
display: none;
}
.reasoning .toggle {
position: relative;
padding: 0 20px;
font-weight: 600;
font-size: 14px;
margin-top: 2px;
}
.reasoning .toggle::after,
.reasoning .toggle::before {
content: "";
background-image: url(icons/reasoning.svg);
position: absolute;
top: 0;
left: -2px;
width: 20px;
height: 20px;
}
.reasoning .toggle::after {
background-image: url(icons/chevron.svg);
left: unset;
right: -2px;
top: 1px;
transition: 150ms;
}
.message.expanded .reasoning .toggle::after {
transform: rotate(180deg);
}
.markdown p {
margin: 0;
margin-bottom: 14px;
}
.markdown p:last-child {
margin-bottom: 0;
}
textarea {
border: none;
resize: none;
@@ -169,46 +223,47 @@ textarea {
display: none;
}
.message.reasoning .reasoning button.toggle::before {
animation: rotating 2s linear infinite;
background-image: url(icons/loading.svg);
}
.message .text::before {
font-style: italic;
}
.message:empty.receiving .text::before,
.message.waiting .text::before {
content: "waiting...";
content: ". . .";
}
.message.reasoning .text::before {
content: "reasoning...";
}
.message:empty.receiving .text::before {
content: "receiving...";
button,
input,
select {
border: none;
font: inherit;
color: inherit;
outline: none;
}
button {
background: transparent;
border: none;
color: inherit;
cursor: pointer;
}
input,
select {
background: #363a4f;
}
#chat button {
position: absolute;
}
input,
select {
border: none;
background: #363a4f;
font: inherit;
color: inherit;
outline: none;
}
#chat .options {
position: absolute;
bottom: 4px;
left: 12px;
left: 20px;
width: max-content;
display: flex;
align-items: center;
@@ -230,11 +285,14 @@ select {
margin-right: 4px;
}
.reasoning .toggle::before,
.reasoning .toggle::after,
#bottom,
.message .role::before,
#clear,
#add,
#send,
.message .copy,
.message .edit,
.message .delete,
#chat .option label {
@@ -246,6 +304,14 @@ select {
background-repeat: no-repeat;
}
.message .copy {
background-image: url(icons/copy.svg);
}
.message .copy.copied {
background-image: url(icons/check.svg);
}
.message .edit {
background-image: url(icons/edit.svg);
}
@@ -265,7 +331,6 @@ select {
#model {
width: 180px;
padding: 2px 4px;
text-align: right;
}
#temperature {
@@ -283,6 +348,10 @@ label[for="model"] {
background-image: url(icons/model.svg);
}
label[for="prompt"] {
background-image: url(icons/prompt.svg);
}
label[for="temperature"] {
background-image: url(icons/temperature.svg);
}
@@ -300,7 +369,7 @@ label[for="temperature"] {
#add,
#send {
bottom: 4px;
right: 12px;
right: 20px;
width: 28px;
height: 28px;
background-image: url(icons/send.svg);
@@ -308,7 +377,7 @@ label[for="temperature"] {
#add {
bottom: 4px;
right: 44px;
right: 52px;
background-image: url(icons/add.svg);
}
@@ -323,4 +392,14 @@ label[for="temperature"] {
.completing #send {
background-image: url(icons/stop.svg);
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -5,11 +5,286 @@
$bottom = document.getElementById("bottom"),
$role = document.getElementById("role"),
$model = document.getElementById("model"),
$prompt = document.getElementById("prompt"),
$temperature = document.getElementById("temperature"),
$add = document.getElementById("add"),
$send = document.getElementById("send"),
$clear = document.getElementById("clear");
const messages = [];
function uid() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function make(tag, ...classes) {
const el = document.createElement(tag);
el.classList.add(...classes);
return el;
}
function render(markdown) {
return marked.parse(DOMPurify.sanitize(markdown), {
gfm: true,
});
}
function scroll() {
$messages.scroll({
top: $messages.scrollHeight + 200,
behavior: "smooth",
});
}
class Message {
#id;
#role;
#reasoning;
#text;
#editing = false;
#expanded = false;
#state = false;
#_message;
#_role;
#_reasoning;
#_text;
#_edit;
constructor(role, reasoning, text) {
this.#id = uid();
this.#role = role;
this.#reasoning = reasoning || "";
this.#text = text || "";
this.#build();
this.#render();
messages.push(this);
}
#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", "markdown");
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");
_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);
// 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();
});
$messages.appendChild(this.#_message);
scroll();
}
#render(only = false) {
if (!only || only === "role") {
this.#_role.textContent = this.#role;
}
if (!only || only === "reasoning") {
this.#_reasoning.innerHTML = render(this.#reasoning);
if (this.#reasoning) {
this.#_message.classList.add("has-reasoning");
} else {
this.#_message.classList.remove("has-reasoning");
}
}
if (!only || only === "text") {
this.#_text.innerHTML = render(this.#text);
}
scroll();
}
#save() {
const data = messages.map((message) => message.getData(true));
console.log("save", data);
localStorage.setItem("messages", JSON.stringify(data));
}
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);
this.#render();
this.#save();
}
}
delete() {
const index = messages.findIndex((msg) => msg.#id === this.#id);
if (index === -1) {
return;
}
console.log("delete", index);
messages.splice(index, 1);
this.#_message.remove();
this.#save();
}
}
let controller;
async function json(url) {
@@ -116,134 +391,21 @@
function restore(models) {
$role.value = localStorage.getItem("role") || "user";
$model.value = localStorage.getItem("model") || models[0].id;
$prompt.value = localStorage.getItem("prompt") || "normal";
$temperature.value = localStorage.getItem("temperature") || 0.85;
try {
const messages = JSON.parse(localStorage.getItem("messages") || "[]");
messages.forEach(addMessage);
JSON.parse(localStorage.getItem("messages") || "[]").forEach(
(message) =>
new Message(
message.role,
message.reasoning,
message.text,
),
);
} 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();
@@ -251,40 +413,9 @@
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;
return new Message($role.value, "", text);
}
$messages.addEventListener("scroll", () => {
@@ -313,6 +444,10 @@
localStorage.setItem("model", $model.value);
});
$prompt.addEventListener("change", () => {
localStorage.setItem("prompt", $prompt.value);
});
$temperature.addEventListener("input", () => {
localStorage.setItem("temperature", $temperature.value);
});
@@ -330,9 +465,9 @@
return;
}
$messages.innerHTML = "";
saveMessages();
for (const message of messages) {
message.delete();
}
});
$send.addEventListener("click", () => {
@@ -349,26 +484,21 @@
}
pushMessage();
saveMessages();
controller = new AbortController();
$chat.classList.add("completing");
const body = {
prompt: $prompt.value,
model: $model.value,
temperature: temperature,
messages: buildMessages(),
messages: messages.map((message) => message.getData()),
};
const result = {
role: "assistant",
content: "",
};
const message = new Message("assistant", "", "");
const message = addMessage(result);
message.state("waiting");
message.setState("waiting");
stream(
"/-/chat",
@@ -384,28 +514,27 @@
if (!chunk) {
controller = null;
saveMessages();
message.setState(false);
$chat.classList.remove("completing");
return;
}
console.log(chunk);
switch (chunk.type) {
case "reason":
message.state("reasoning");
message.setState("reasoning");
message.addReasoning(chunk.text);
break;
case "text":
result.content += chunk.text;
message.state("receive");
message.set(result.content);
message.setState("receiving");
message.addText(chunk.text);
break;
}
saveMessages();
},
);
});

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

7
static/icons/check.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: 579 B

7
static/icons/chevron.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: 568 B

7
static/icons/copy.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: 783 B

7
static/icons/loading.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

View File

@@ -3,5 +3,5 @@
<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"/>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 894 B

7
static/icons/prompt.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: 685 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: 1.2 KiB

View File

@@ -36,6 +36,13 @@
<label for="model" title="Model"></label>
<select id="model"></select>
</div>
<div class="option">
<label for="prompt" title="Prompt"></label>
<select id="prompt">
<option value="" selected>No Prompt</option>
<option value="normal">Assistant</option>
</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" />
@@ -47,6 +54,8 @@
</div>
</div>
<script src="purify.min.js"></script>
<script src="marked.min.js"></script>
<script src="chat.js"></script>
</body>
</html>

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

69
static/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
static/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long