2025-08-05 03:56:23 +02:00
|
|
|
(() => {
|
|
|
|
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"),
|
2025-08-07 22:09:08 +02:00
|
|
|
$prompt = document.getElementById("prompt"),
|
2025-08-05 03:56:23 +02:00
|
|
|
$temperature = document.getElementById("temperature"),
|
|
|
|
$add = document.getElementById("add"),
|
|
|
|
$send = document.getElementById("send"),
|
|
|
|
$clear = document.getElementById("clear");
|
|
|
|
|
2025-08-07 22:09:08 +02:00
|
|
|
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();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-08-05 03:56:23 +02:00
|
|
|
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;
|
2025-08-07 22:09:08 +02:00
|
|
|
$prompt.value = localStorage.getItem("prompt") || "normal";
|
2025-08-05 03:56:23 +02:00
|
|
|
$temperature.value = localStorage.getItem("temperature") || 0.85;
|
|
|
|
|
|
|
|
try {
|
2025-08-07 22:09:08 +02:00
|
|
|
JSON.parse(localStorage.getItem("messages") || "[]").forEach(
|
|
|
|
(message) =>
|
|
|
|
new Message(
|
|
|
|
message.role,
|
|
|
|
message.reasoning,
|
|
|
|
message.text,
|
|
|
|
),
|
|
|
|
);
|
2025-08-05 03:56:23 +02:00
|
|
|
} catch {}
|
|
|
|
}
|
|
|
|
|
|
|
|
function pushMessage() {
|
|
|
|
const text = $message.value.trim();
|
|
|
|
|
|
|
|
if (!text) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
$message.value = "";
|
|
|
|
|
2025-08-07 22:09:08 +02:00
|
|
|
return new Message($role.value, "", text);
|
2025-08-05 03:56:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
$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);
|
|
|
|
});
|
|
|
|
|
2025-08-07 22:09:08 +02:00
|
|
|
$prompt.addEventListener("change", () => {
|
|
|
|
localStorage.setItem("prompt", $prompt.value);
|
|
|
|
});
|
|
|
|
|
2025-08-05 03:56:23 +02:00
|
|
|
$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;
|
|
|
|
}
|
|
|
|
|
2025-08-07 22:09:08 +02:00
|
|
|
for (const message of messages) {
|
|
|
|
message.delete();
|
|
|
|
}
|
2025-08-05 03:56:23 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
$send.addEventListener("click", () => {
|
|
|
|
if (controller) {
|
|
|
|
controller.abort();
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const temperature = parseFloat($temperature.value);
|
|
|
|
|
|
|
|
if (Number.isNaN(temperature) || temperature < 0 || temperature > 1) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
pushMessage();
|
|
|
|
|
|
|
|
controller = new AbortController();
|
|
|
|
|
|
|
|
$chat.classList.add("completing");
|
|
|
|
|
|
|
|
const body = {
|
2025-08-07 22:09:08 +02:00
|
|
|
prompt: $prompt.value,
|
2025-08-05 03:56:23 +02:00
|
|
|
model: $model.value,
|
|
|
|
temperature: temperature,
|
2025-08-07 22:09:08 +02:00
|
|
|
messages: messages.map((message) => message.getData()),
|
2025-08-05 03:56:23 +02:00
|
|
|
};
|
|
|
|
|
2025-08-07 22:09:08 +02:00
|
|
|
const message = new Message("assistant", "", "");
|
2025-08-05 03:56:23 +02:00
|
|
|
|
2025-08-07 22:09:08 +02:00
|
|
|
message.setState("waiting");
|
2025-08-05 03:56:23 +02:00
|
|
|
|
|
|
|
stream(
|
|
|
|
"/-/chat",
|
|
|
|
{
|
|
|
|
method: "POST",
|
|
|
|
headers: {
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
},
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
signal: controller.signal,
|
|
|
|
},
|
|
|
|
(chunk) => {
|
|
|
|
if (!chunk) {
|
|
|
|
controller = null;
|
|
|
|
|
2025-08-07 22:09:08 +02:00
|
|
|
message.setState(false);
|
2025-08-05 03:56:23 +02:00
|
|
|
|
|
|
|
$chat.classList.remove("completing");
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-08-07 22:09:08 +02:00
|
|
|
console.log(chunk);
|
|
|
|
|
2025-08-05 03:56:23 +02:00
|
|
|
switch (chunk.type) {
|
|
|
|
case "reason":
|
2025-08-07 22:09:08 +02:00
|
|
|
message.setState("reasoning");
|
|
|
|
message.addReasoning(chunk.text);
|
2025-08-05 03:56:23 +02:00
|
|
|
|
|
|
|
break;
|
|
|
|
case "text":
|
2025-08-07 22:09:08 +02:00
|
|
|
message.setState("receiving");
|
|
|
|
message.addText(chunk.text);
|
2025-08-05 03:56:23 +02:00
|
|
|
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
$message.addEventListener("keydown", (event) => {
|
|
|
|
if (!event.ctrlKey || event.key !== "Enter") {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
$send.click();
|
|
|
|
});
|
|
|
|
|
|
|
|
loadModels().then(restore);
|
|
|
|
})();
|