1
0
mirror of https://github.com/coalaura/whiskr.git synced 2025-09-08 00:29:54 +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

3
.gitignore vendored
View File

@@ -1 +1,2 @@
.env
.env
debug.json

24
chat.go
View File

@@ -6,16 +6,18 @@ import (
"fmt"
"io"
"net/http"
"os"
"github.com/revrost/go-openrouter"
)
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Text string `json:"text"`
}
type Request struct {
Prompt string `json:"prompt"`
Model string `json:"model"`
Temperature float64 `json:"temperature"`
Messages []Message `json:"messages"`
@@ -24,7 +26,8 @@ type Request struct {
func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
var request openrouter.ChatCompletionRequest
if _, ok := ModelMap[r.Model]; !ok {
model, ok := ModelMap[r.Model]
if !ok {
return nil, fmt.Errorf("unknown model: %q", r.Model)
}
@@ -36,6 +39,15 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
request.Temperature = float32(r.Temperature)
prompt, err := BuildPrompt(r.Prompt, model)
if err != nil {
return nil, err
}
if prompt != "" {
request.Messages = append(request.Messages, openrouter.SystemMessage(prompt))
}
for index, message := range r.Messages {
if message.Role != openrouter.ChatMessageRoleSystem && message.Role != openrouter.ChatMessageRoleAssistant && message.Role != openrouter.ChatMessageRoleUser {
return nil, fmt.Errorf("[%d] invalid role: %q", index+1, message.Role)
@@ -44,7 +56,7 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
request.Messages = append(request.Messages, openrouter.ChatCompletionMessage{
Role: message.Role,
Content: openrouter.Content{
Text: message.Content,
Text: message.Text,
},
})
}
@@ -74,6 +86,10 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
request.Stream = true
// DEBUG
b, _ := json.MarshalIndent(request, "", "\t")
os.WriteFile("debug.json", b, 0755)
ctx := r.Context()
stream, err := OpenRouterStartStream(ctx, *request)

51
prompts.go Normal file
View File

@@ -0,0 +1,51 @@
package main
import (
"bytes"
_ "embed"
"fmt"
"text/template"
"time"
)
type PromptData struct {
Name string
Slug string
Date string
}
var (
//go:embed prompts/normal.txt
PromptNormal string
PromptNormalTmpl = template.Must(template.New("normal").Parse(PromptNormal))
)
func BuildPrompt(name string, model *Model) (string, error) {
if name == "" {
return "", nil
}
var tmpl *template.Template
switch name {
case "normal":
tmpl = PromptNormalTmpl
default:
return "", fmt.Errorf("unknown prompt: %q", name)
}
var buf bytes.Buffer
err := tmpl.Execute(&buf, PromptData{
Name: model.Name,
Slug: model.ID,
Date: time.Now().Format(time.RFC1123),
})
if err != nil {
return "", err
}
return buf.String(), nil
}

16
prompts/normal.txt Normal file
View File

@@ -0,0 +1,16 @@
You are {{.Name}}, an AI assistant created to be helpful, harmless, and honest. Today's date is {{.Date}}.
Your responses should be accurate and informative. When answering questions, be direct and helpful without unnecessary preambles or acknowledgments of instructions.
Use markdown formatting when it improves clarity:
- **Bold** for emphasis
- *Italics* for softer emphasis
- `Code` for inline code
- Triple backticks for code blocks with language identifiers
- Tables using pipe syntax when presenting structured data
Be conversational and adapt to the user's tone. Ask clarifying questions when requests are ambiguous. If you cannot answer something due to knowledge limitations, say so directly.
When asked about your identity, you are {{.Name}}. You don't have access to real-time information or the ability to browse the internet, so for current events or time-sensitive information, acknowledge this limitation.
Focus on being genuinely helpful while maintaining natural conversation flow.

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