1
0
mirror of https://github.com/coalaura/whiskr.git synced 2025-09-08 17:06:42 +00:00
Files
whiskr/static/js/chat.js
2025-08-30 15:05:32 +02:00

1762 lines
36 KiB
JavaScript

(() => {
const $version = document.getElementById("version"),
$total = document.getElementById("total"),
$notifications = document.getElementById("notifications"),
$title = document.getElementById("title"),
$titleRefresh = document.getElementById("title-refresh"),
$titleText = document.getElementById("title-text"),
$messages = document.getElementById("messages"),
$chat = document.getElementById("chat"),
$message = document.getElementById("message"),
$top = document.getElementById("top"),
$bottom = document.getElementById("bottom"),
$resizeBar = document.getElementById("resize-bar"),
$attachments = document.getElementById("attachments"),
$role = document.getElementById("role"),
$model = document.getElementById("model"),
$prompt = document.getElementById("prompt"),
$temperature = document.getElementById("temperature"),
$iterations = document.getElementById("iterations"),
$reasoningEffort = document.getElementById("reasoning-effort"),
$reasoningTokens = document.getElementById("reasoning-tokens"),
$json = document.getElementById("json"),
$search = document.getElementById("search"),
$upload = document.getElementById("upload"),
$add = document.getElementById("add"),
$send = document.getElementById("send"),
$scrolling = document.getElementById("scrolling"),
$export = document.getElementById("export"),
$import = document.getElementById("import"),
$clear = document.getElementById("clear"),
$authentication = document.getElementById("authentication"),
$authError = document.getElementById("auth-error"),
$username = document.getElementById("username"),
$password = document.getElementById("password"),
$login = document.getElementById("login");
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
let platform = "";
detectPlatform().then(result => {
platform = result;
console.info(`Detected platform: ${platform}`);
});
const messages = [],
models = {},
modelList = [],
promptList = [];
let autoScrolling = false,
jsonMode = false,
searchTool = false,
chatTitle = false;
let searchAvailable = false,
activeMessage = null,
isResizing = false,
scrollResize = false,
totalCost = 0;
function updateTotalCost() {
storeValue("total-cost", totalCost);
$total.textContent = formatMoney(totalCost);
}
async function notify(msg, persistent = false) {
console.warn(msg);
const notification = make("div", "notification", "off-screen");
notification.textContent = msg instanceof Error ? msg.message : msg;
$notifications.appendChild(notification);
await wait(250);
notification.classList.remove("off-screen");
if (persistent) {
return;
}
await wait(5000);
notification.style.height = `${notification.getBoundingClientRect().height}px`;
notification.classList.add("off-screen");
await wait(250);
notification.remove();
}
function updateTitle() {
const title = chatTitle || (messages.length ? "New Chat" : "");
$title.classList.toggle("hidden", !messages.length);
$titleText.textContent = title;
document.title = `whiskr${chatTitle ? ` - ${chatTitle}` : ""}`;
storeValue("title", chatTitle);
}
function updateScrollButton() {
const bottom = $messages.scrollHeight - ($messages.scrollTop + $messages.offsetHeight);
$top.classList.toggle("hidden", $messages.scrollTop < 80);
$bottom.classList.toggle("hidden", bottom < 80);
}
function scroll(force = false, instant = false) {
if (!autoScrolling && !force) {
updateScrollButton();
return;
}
setTimeout(() => {
$messages.scroll({
top: $messages.scrollHeight,
behavior: instant ? "instant" : "smooth",
});
}, 0);
}
function preloadIcons(icons) {
for (const icon of icons) {
new Image().src = `/css/icons/${icon}`;
}
}
function mark(index) {
for (let x = 0; x < messages.length; x++) {
messages[x].mark(Number.isInteger(index) && x >= index);
}
}
class Message {
#id;
#role;
#reasoning;
#text;
#files = [];
#tool;
#tags = [];
#statistics;
#error = false;
#editing = false;
#state = false;
#_diff;
#pending = {};
#patching = {};
#_message;
#_tags;
#_files;
#_reasoning;
#_text;
#_edit;
#_tool;
#_statistics;
constructor(role, reasoning, text, files = [], collapsed = false) {
this.#id = uid();
this.#role = role;
this.#reasoning = reasoning || "";
this.#text = text || "";
this.#_diff = document.createElement("div");
this.#build(collapsed);
this.#render();
for (const file of files) {
this.addFile(file);
}
messages.push(this);
if (this.#reasoning || this.#text) {
this.#save();
}
}
#build(collapsed) {
// main message div
this.#_message = make("div", "message", this.#role, collapsed ? "collapsed" : "");
// message role (wrapper)
const _wrapper = make("div", "role", this.#role);
this.#_message.appendChild(_wrapper);
// message role
const _role = make("div");
_role.textContent = this.#role;
_wrapper.appendChild(_role);
// message tags
this.#_tags = make("div", "tags");
_wrapper.appendChild(this.#_tags);
const _body = make("div", "body");
this.#_message.appendChild(_body);
// message files
this.#_files = make("div", "files");
_body.appendChild(this.#_files);
// message reasoning (wrapper)
const _reasoning = make("div", "reasoning");
_body.appendChild(_reasoning);
// message reasoning (toggle)
const _toggle = make("button", "toggle");
_toggle.textContent = "Reasoning";
_reasoning.appendChild(_toggle);
_toggle.addEventListener("click", () => {
_reasoning.classList.toggle("expanded");
if (_reasoning.classList.contains("expanded")) {
this.#updateReasoningHeight();
}
updateScrollButton();
});
// message reasoning (content)
this.#_reasoning = make("div", "reasoning-text", "markdown");
_reasoning.appendChild(this.#_reasoning);
// message content
this.#_text = make("div", "text", "markdown");
_body.appendChild(this.#_text);
// message edit textarea
this.#_edit = make("textarea", "text");
_body.appendChild(this.#_edit);
this.#_edit.addEventListener("keydown", event => {
if (event.ctrlKey && event.key === "Enter") {
this.toggleEdit();
} else if (event.key === "Escape") {
this.#_edit.value = this.#text;
this.toggleEdit();
}
});
this.#_edit.addEventListener("input", () => {
this.updateEditHeight();
});
// message tool
this.#_tool = make("div", "tool");
_body.appendChild(this.#_tool);
// tool call
const _call = make("div", "call");
this.#_tool.appendChild(_call);
_call.addEventListener("click", () => {
this.#_tool.classList.toggle("expanded");
updateScrollButton();
});
// tool call name
const _callName = make("div", "name");
_call.appendChild(_callName);
// tool call arguments
const _callArguments = make("div", "arguments");
_call.appendChild(_callArguments);
// tool call cost
const _callCost = make("div", "cost");
_callCost.title = "Cost of this tool call";
this.#_tool.appendChild(_callCost);
// tool call result
const _callResult = make("div", "result", "markdown");
this.#_tool.appendChild(_callResult);
// message options
const _opts = make("div", "options");
this.#_message.appendChild(_opts);
// collapse option
const _optCollapse = make("button", "collapse");
_optCollapse.title = "Collapse/Expand message";
_opts.appendChild(_optCollapse);
_optCollapse.addEventListener("click", () => {
this.#_message.classList.toggle("collapsed");
this.#save();
});
// copy option
const _optCopy = make("button", "copy");
_optCopy.title = "Copy message content";
_opts.appendChild(_optCopy);
let timeout;
_optCopy.addEventListener("click", () => {
this.stopEdit();
clearTimeout(timeout);
navigator.clipboard.writeText(this.#text);
_optCopy.classList.add("copied");
timeout = setTimeout(() => {
_optCopy.classList.remove("copied");
}, 1000);
});
// retry option
const _assistant = this.#role === "assistant",
_retryLabel = _assistant ? "Delete message and messages after this one and try again" : "Delete messages after this one and try again";
const _optRetry = make("button", "retry");
_optRetry.title = _retryLabel;
_opts.appendChild(_optRetry);
_optRetry.addEventListener("mouseenter", () => {
const index = this.index(!_assistant ? 1 : 0);
mark(index);
});
_optRetry.addEventListener("mouseleave", () => {
mark(false);
});
_optRetry.addEventListener("click", () => {
const index = this.index(!_assistant ? 1 : 0);
if (index === false) {
return;
}
this.stopEdit();
while (messages.length > index) {
messages[messages.length - 1].delete();
}
mark(false);
generate(false, true);
});
// 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();
});
// statistics
this.#_statistics = make("div", "statistics");
this.#_message.appendChild(this.#_statistics);
// add to dom
$messages.appendChild(this.#_message);
scroll();
}
#handleImages(element) {
element.querySelectorAll("img:not(.image)").forEach(img => {
img.classList.add("image");
img.addEventListener("load", () => {
scroll();
});
});
}
#updateReasoningHeight() {
this.#_reasoning.parentNode.style.setProperty("--height", `${this.#_reasoning.scrollHeight}px`);
}
#updateToolHeight() {
const result = this.#_tool.querySelector(".result");
this.#_tool.style.setProperty("--height", `${result.scrollHeight}px`);
}
#morph(from, to) {
morphdom(from, to, {
childrenOnly: true,
onBeforeElUpdated: (fromEl, toEl) => {
return !fromEl.isEqualNode || !fromEl.isEqualNode(toEl);
},
});
}
#patch(name, element, md, after = false) {
if (!element.firstChild) {
element.innerHTML = render(md);
this.#handleImages(element);
after?.();
return;
}
this.#pending[name] = md;
if (this.#patching[name]) {
return;
}
this.#patching[name] = true;
schedule(() => {
const html = render(this.#pending[name]);
this.#patching[name] = false;
this.#_diff.innerHTML = html;
this.#morph(element, this.#_diff);
this.#_diff.innerHTML = "";
this.#handleImages(element);
after?.();
});
}
#render(only = false, noScroll = false) {
if (!only || only === "tags") {
const tags = this.#tags.map(tag => `<div class="tag-${tag}" title="${tag}"></div>`);
this.#_tags.innerHTML = tags.join("");
this.#_message.classList.toggle("has-tags", this.#tags.length > 0);
}
if (!only || only === "tool") {
if (this.#tool) {
const { name, args, result, cost, invalid } = this.#tool;
const _name = this.#_tool.querySelector(".name"),
_arguments = this.#_tool.querySelector(".arguments"),
_cost = this.#_tool.querySelector(".cost"),
_result = this.#_tool.querySelector(".result");
_name.title = `Show ${name} call result`;
_name.textContent = name;
_arguments.title = args;
_arguments.textContent = args;
_cost.textContent = cost ? `${formatMoney(cost)}` : "";
_result.classList.toggle("error", result?.startsWith("error: "));
_result.innerHTML = render(result || "*processing*");
this.#_tool.classList.toggle("invalid", !!invalid);
this.#_tool.setAttribute("data-tool", name);
} else {
this.#_tool.removeAttribute("data-tool");
}
this.#_message.classList.toggle("has-tool", !!this.#tool);
this.#updateToolHeight();
noScroll || scroll();
updateScrollButton();
}
if (!only || only === "statistics") {
let html = "";
if (this.#statistics) {
const { provider, model, ttft, time, input, output, cost } = this.#statistics;
const tps = output / (time / 1000);
html = [
provider ? `<div class="provider">${provider} (${model.split("/").pop()})</div>` : "",
`<div class="ttft">${formatMilliseconds(ttft)}</div>`,
`<div class="tps">${fixed(tps, 2)} t/s</div>`,
`<div class="tokens">
<div class="input">${input}</div>
+
<div class="output">${output}</div>
=
<div class="total">${input + output}t</div>
</div>`,
`<div class="cost">${formatMoney(cost)}</div>`,
].join("");
}
this.#_statistics.innerHTML = html;
this.#_message.classList.toggle("has-statistics", !!html);
}
if (this.#error) {
return;
}
if (!only || only === "reasoning") {
this.#patch("reasoning", this.#_reasoning, this.#reasoning, () => {
this.#updateReasoningHeight();
noScroll || scroll();
updateScrollButton();
});
this.#_message.classList.toggle("has-reasoning", !!this.#reasoning);
}
if (!only || only === "text") {
let text = this.#text;
if (text && this.#tags.includes("json")) {
text = `\`\`\`json\n${text}\n\`\`\``;
}
this.#patch("text", this.#_text, text, () => {
noScroll || scroll();
updateScrollButton();
});
this.#_message.classList.toggle("has-text", !!this.#text);
}
}
#save() {
storeValue("messages", messages.map(message => message.getData(true)).filter(Boolean));
}
isUser() {
return this.#role === "user";
}
index(offset = 0) {
const index = messages.findIndex(message => message.#id === this.#id);
if (index === -1) {
return false;
}
return index + offset;
}
mark(state = false) {
this.#_message.classList.toggle("marked", state);
}
getData(full = false) {
const data = {
role: this.#role,
text: this.#text,
};
if (this.#files.length) {
data.files = this.#files.map(file => ({
name: file.name,
content: file.content,
}));
}
if (this.#tool) {
data.tool = this.#tool;
}
if (this.#reasoning && full) {
data.reasoning = this.#reasoning;
}
if (this.#error && full) {
data.error = this.#error;
}
if (this.#tags.length && full) {
data.tags = this.#tags;
}
if (this.#statistics && full) {
data.statistics = this.#statistics;
}
if (this.#_message.classList.contains("collapsed") && full) {
data.collapsed = true;
}
if (!data.files?.length && !data.reasoning && !data.text && !data.tool) {
return false;
}
return data;
}
setStatistics(statistics) {
this.#statistics = statistics;
this.#render("statistics");
this.#save();
}
async loadGenerationData(generationID, retrying = false) {
if (!generationID) {
return;
}
try {
const response = await fetch(`/-/stats/${generationID}`),
data = await response.json();
if (!data || data.error) {
throw new Error(data?.error || response.statusText);
}
this.setStatistics(data);
totalCost += data.cost;
updateTotalCost();
} catch (err) {
console.error(err);
if (!retrying && err.message.includes("not found")) {
setTimeout(this.loadGenerationData.bind(this), 1500, generationID, true);
}
}
}
addTag(tag) {
if (this.#tags.includes(tag)) {
return;
}
this.#tags.push(tag);
this.#render("tags");
if (tag === "json") {
this.#render("text");
}
this.#save();
}
addFile(file) {
this.#files.push(file);
this.#_files.appendChild(
buildFileElement(file, el => {
const index = this.#files.findIndex(attachment => attachment.id === file.id);
if (index === -1) {
return;
}
this.#files.splice(index, 1);
el.remove();
this.#_files.classList.toggle("has-files", !!this.#files.length);
this.#_message.classList.toggle("has-files", !!this.#files.length);
this.#save();
})
);
this.#_files.classList.add("has-files");
this.#_message.classList.add("has-files");
this.#save();
}
setState(state) {
if (this.#state === state) {
return;
}
if (this.#state) {
this.#_message.classList.remove(this.#state);
}
if (state) {
this.#_message.classList.add(state);
} else {
if (this.#tool && !this.#tool.result) {
this.#tool.result = "failed to run tool";
this.#render("tool");
}
}
this.#state = state;
}
setTool(tool) {
this.#tool = tool;
this.#render("tool");
this.#save();
}
addReasoning(chunk) {
this.#reasoning += chunk;
this.#render("reasoning");
this.#save();
}
addText(text) {
this.#text += text;
this.#render("text");
this.#save();
}
showError(error) {
this.#error = error;
this.#_message.classList.add("errored");
const _err = make("div", "error");
_err.textContent = this.#error;
this.#_text.appendChild(_err);
this.#save();
}
stopEdit() {
if (!this.#editing) {
return;
}
this.toggleEdit();
}
updateEditHeight() {
this.#_edit.style.height = "";
this.#_edit.style.height = `${Math.max(100, this.#_edit.scrollHeight + 2)}px`;
}
toggleEdit() {
this.#editing = !this.#editing;
if (this.#editing) {
activeMessage = this;
this.#_edit.value = this.#text;
this.setState("editing");
this.updateEditHeight();
this.#_edit.focus();
} else {
activeMessage = null;
this.#text = this.#_edit.value;
this.setState(false);
this.#render(false, true);
this.#save();
}
}
delete() {
const index = messages.findIndex(msg => msg.#id === this.#id);
if (index === -1) {
return;
}
this.#_message.remove();
messages.splice(index, 1);
this.#save();
$messages.dispatchEvent(new Event("scroll"));
}
}
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) {
let aborted;
try {
const response = await fetch(url, options);
if (!response.ok) {
const err = await response.json();
if (err?.error === "unauthorized") {
showLogin();
}
throw new Error(err?.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") {
aborted = true;
return;
}
callback({
type: "error",
text: err.message,
});
} finally {
callback(aborted ? "aborted" : "done");
}
}
let chatController;
function generate(cancel = false, noPush = false) {
if (chatController) {
chatController.abort();
if (cancel) {
$chat.classList.remove("completing");
return;
}
}
let temperature = parseFloat($temperature.value);
if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) {
temperature = 0.85;
$temperature.value = temperature;
$temperature.classList.remove("invalid");
}
let iterations = parseInt($iterations.value);
if (Number.isNaN(iterations) || iterations < 1 || iterations > 50) {
iterations = 3;
$iterations.value = iterations;
$iterations.classList.remove("invalid");
}
const effort = $reasoningEffort.value;
let tokens = parseInt($reasoningTokens.value);
if (!effort && (Number.isNaN(tokens) || tokens <= 0 || tokens > 1024 * 1024)) {
tokens = 1024;
$reasoningTokens.value = tokens;
$reasoningTokens.classList.remove("invalid");
}
if (!noPush) {
pushMessage();
}
chatController = new AbortController();
$chat.classList.add("completing");
const body = {
prompt: $prompt.value,
model: $model.value,
temperature: temperature,
iterations: iterations,
tools: {
json: jsonMode,
search: searchTool,
},
reasoning: {
effort: effort,
tokens: tokens || 0,
},
metadata: {
timezone: timezone,
platform: platform,
},
messages: messages.map(message => message.getData()).filter(Boolean),
};
let message, generationID;
function finish(aborted = false) {
if (!message) {
return;
}
message.setState(false);
if (!aborted) {
setTimeout(message.loadGenerationData.bind(message), 1000, generationID);
}
message = null;
generationID = null;
}
function start() {
message = new Message("assistant", "", "");
message.setState("waiting");
if (jsonMode) {
message.addTag("json");
}
if (searchTool) {
message.addTag("search");
}
}
start();
stream(
"/-/chat",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: chatController.signal,
},
chunk => {
if (chunk === "aborted") {
chatController = null;
finish(true);
return;
} else if (chunk === "done") {
chatController = null;
finish();
$chat.classList.remove("completing");
if (!chatTitle && !titleController) {
refreshTitle();
}
return;
}
if (!message && chunk.type !== "end") {
start();
}
switch (chunk.type) {
case "end":
finish();
break;
case "id":
generationID = chunk.text;
break;
case "tool":
message.setState("tooling");
message.setTool(chunk.text);
if (chunk.text.done) {
totalCost += chunk.text.cost || 0;
finish();
}
break;
case "reason":
message.setState("reasoning");
message.addReasoning(chunk.text);
break;
case "text":
message.setState("receiving");
message.addText(chunk.text);
break;
case "error":
message.showError(chunk.text);
break;
}
}
);
}
let titleController;
async function refreshTitle() {
if (titleController) {
titleController.abort();
}
titleController = new AbortController();
const body = {
title: chatTitle || null,
messages: messages.map(message => message.getData()).filter(Boolean),
};
if (!body.messages.length) {
updateTitle();
return;
}
$title.classList.add("refreshing");
try {
const response = await fetch("/-/title", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: titleController.signal,
}),
result = await response.json();
if (result.cost) {
totalCost += result.cost;
updateTotalCost();
}
if (!response.ok || !result?.title) {
throw new Error(result?.error || response.statusText);
}
chatTitle = result.title;
} catch (err) {
if (err.name === "AbortError") {
return;
}
notify(err);
}
titleController = null;
updateTitle();
$title.classList.remove("refreshing");
}
async function login() {
const username = $username.value.trim(),
password = $password.value.trim();
if (!username || !password) {
throw new Error("missing username or password");
}
const data = await fetch("/-/auth", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: username,
password: password,
}),
}).then(response => response.json());
if (!data?.authenticated) {
throw new Error(data.error || "authentication failed");
}
}
function showLogin() {
$password.value = "";
$authentication.classList.add("open");
}
async function loadData() {
const data = await json("/-/data");
if (!data) {
notify("Failed to load data.", true);
return;
}
// start icon preload
preloadIcons(data.icons);
// render total cost
totalCost = loadValue("total-cost", 0);
updateTotalCost();
// render version
if (data.version === "dev") {
$version.remove();
} else {
$version.innerHTML = `<a href="https://github.com/coalaura/whiskr" target="_blank">whiskr</a> <a href="https://github.com/coalaura/whiskr/releases/tag/${data.version}" target="_blank">${data.version}</a>`;
}
// update search availability
searchAvailable = data.search;
// show login modal
if (data.authentication && !data.authenticated) {
$authentication.classList.add("open");
}
// render models
fillSelect($model, data.models, (el, model) => {
el.value = model.id;
el.title = model.description;
el.textContent = model.name;
el.dataset.tags = (model.tags || []).join(",");
models[model.id] = model;
modelList.push(model);
});
dropdown($model, 4);
// render prompts
data.prompts.unshift({
key: "",
name: "No Prompt",
});
fillSelect($prompt, data.prompts, (el, prompt) => {
el.value = prompt.key;
el.textContent = prompt.name;
promptList.push(prompt);
});
dropdown($prompt);
}
function clearMessages() {
while (messages.length) {
messages[0].delete();
}
}
function restore() {
$message.value = loadValue("message", "");
$role.value = loadValue("role", "user");
$model.value = loadValue("model", modelList.length ? modelList[0].id : "");
$prompt.value = loadValue("prompt", promptList.length ? promptList[0].key : "");
$temperature.value = loadValue("temperature", 0.85);
$iterations.value = loadValue("iterations", 3);
$reasoningEffort.value = loadValue("reasoning-effort", "medium");
$reasoningTokens.value = loadValue("reasoning-tokens", 1024);
const files = loadValue("attachments", []);
for (const file of files) {
pushAttachment(file);
}
if (loadValue("json")) {
$json.click();
}
if (loadValue("search")) {
$search.click();
}
if (loadValue("scrolling")) {
$scrolling.click();
}
loadValue("messages", []).forEach(message => {
const obj = new Message(message.role, message.reasoning, message.text, message.files || [], message.collapsed);
if (message.error) {
obj.showError(message.error);
}
if (message.tags) {
message.tags.forEach(tag => obj.addTag(tag));
}
if (message.tool) {
obj.setTool(message.tool);
}
if (message.statistics) {
obj.setStatistics(message.statistics);
}
});
chatTitle = loadValue("title");
updateTitle();
scroll();
// small fix, sometimes when hard reloading we don't scroll all the way
setTimeout(scroll, 250);
}
let attachments = [];
function buildFileElement(file, callback) {
// file wrapper
const _file = make("div", "file");
// file name
const _name = make("div", "name");
_name.title = `FILE ${JSON.stringify(file.name)} LINES ${lines(file.content)}`;
_name.textContent = file.name;
_file.appendChild(_name);
// remove button
const _remove = make("button", "remove");
_remove.title = "Remove attachment";
_file.appendChild(_remove);
_remove.addEventListener("click", () => {
callback(_file);
});
return _file;
}
function pushAttachment(file) {
file.id = uid();
if (activeMessage?.isUser()) {
activeMessage.addFile(file);
return;
}
attachments.push(file);
storeValue("attachments", attachments);
$attachments.appendChild(
buildFileElement(file, el => {
const index = attachments.findIndex(attachment => attachment.id === file.id);
if (index === -1) {
return;
}
attachments.splice(index, 1);
storeValue("attachments", attachments);
el.remove();
$attachments.classList.toggle("has-files", !!attachments.length);
})
);
$attachments.classList.add("has-files");
}
function clearAttachments() {
attachments = [];
$attachments.innerHTML = "";
$attachments.classList.remove("has-files");
storeValue("attachments", []);
}
function pushMessage() {
const text = $message.value.trim();
if (!text && !attachments.length) {
return false;
}
$message.value = "";
storeValue("message", "");
const message = new Message($role.value, "", text, attachments);
clearAttachments();
updateTitle();
return message;
}
$total.addEventListener("auxclick", event => {
if (event.button !== 1) {
return;
}
totalCost = 0;
updateTotalCost();
});
$titleRefresh.addEventListener("click", () => {
refreshTitle();
});
$messages.addEventListener("scroll", () => {
updateScrollButton();
});
$bottom.addEventListener("click", () => {
$messages.scroll({
top: $messages.scrollHeight,
behavior: "smooth",
});
});
$top.addEventListener("click", () => {
$messages.scroll({
top: 0,
behavior: "smooth",
});
});
$resizeBar.addEventListener("mousedown", event => {
const isAtBottom = $messages.scrollHeight - ($messages.scrollTop + $messages.offsetHeight) <= 10;
if (event.button === 1) {
$chat.style.height = "";
storeValue("resized", false);
scroll(isAtBottom, true);
return;
} else if (event.button !== 0) {
return;
}
isResizing = true;
scrollResize = isAtBottom;
document.body.classList.add("resizing");
});
$role.addEventListener("change", () => {
storeValue("role", $role.value);
});
$model.addEventListener("change", () => {
const model = $model.value,
data = model ? models[model] : null,
tags = data?.tags || [];
storeValue("model", model);
if (tags.includes("reasoning")) {
$reasoningEffort.parentNode.classList.remove("none");
$reasoningTokens.parentNode.classList.toggle("none", !!$reasoningEffort.value);
} else {
$reasoningEffort.parentNode.classList.add("none");
$reasoningTokens.parentNode.classList.add("none");
}
const hasJson = tags.includes("json"),
hasSearch = searchAvailable && tags.includes("tools");
$json.classList.toggle("none", !hasJson);
$search.classList.toggle("none", !hasSearch);
$search.parentNode.classList.toggle("none", !hasJson && !hasSearch);
});
$prompt.addEventListener("change", () => {
storeValue("prompt", $prompt.value);
});
$temperature.addEventListener("input", () => {
const value = $temperature.value,
temperature = parseFloat(value);
storeValue("temperature", value);
$temperature.classList.toggle("invalid", Number.isNaN(temperature) || temperature < 0 || temperature > 2);
});
$iterations.addEventListener("input", () => {
const value = $iterations.value,
iterations = parseFloat(value);
storeValue("iterations", value);
$iterations.classList.toggle("invalid", Number.isNaN(iterations) || iterations < 1 || iterations > 50);
});
$reasoningEffort.addEventListener("change", () => {
const effort = $reasoningEffort.value;
storeValue("reasoning-effort", effort);
$reasoningTokens.parentNode.classList.toggle("none", !!effort);
});
$reasoningTokens.addEventListener("input", () => {
const value = $reasoningTokens.value,
tokens = parseInt(value);
storeValue("reasoning-tokens", value);
$reasoningTokens.classList.toggle("invalid", Number.isNaN(tokens) || tokens <= 0 || tokens > 1024 * 1024);
});
$json.addEventListener("click", () => {
jsonMode = !jsonMode;
storeValue("json", jsonMode);
$json.classList.toggle("on", jsonMode);
});
$search.addEventListener("click", () => {
searchTool = !searchTool;
storeValue("search", searchTool);
$search.classList.toggle("on", searchTool);
});
$message.addEventListener("input", () => {
storeValue("message", $message.value);
});
$upload.addEventListener("click", async () => {
const files = await selectFile(
// the ultimate list
"text/*",
true,
file => {
if (!file.name) {
file.name = "unknown.txt";
} else if (file.name.length > 512) {
throw new Error("File name too long (max 512 characters)");
}
if (!file.content) {
throw new Error("File is empty");
} else if (file.content.includes("\0")) {
throw new Error("File is not a text file");
} else if (file.content.length > 4 * 1024 * 1024) {
throw new Error("File is too big (max 4MB)");
}
},
notify
);
if (!files.length) {
return;
}
for (const file of files) {
pushAttachment(file);
}
});
$add.addEventListener("click", () => {
pushMessage();
});
$clear.addEventListener("click", () => {
if (!confirm("Are you sure you want to delete all messages?")) {
return;
}
clearMessages();
chatTitle = false;
updateTitle();
});
$export.addEventListener("click", () => {
const data = JSON.stringify({
title: chatTitle,
message: $message.value,
attachments: attachments,
role: $role.value,
model: $model.value,
prompt: $prompt.value,
temperature: $temperature.value,
iterations: $iterations.value,
reasoning: {
effort: $reasoningEffort.value,
tokens: $reasoningTokens.value,
},
json: jsonMode,
search: searchTool,
messages: messages.map(message => message.getData()).filter(Boolean),
});
download("chat.json", "application/json", data);
});
$import.addEventListener("click", async () => {
if (!modelList.length) {
return;
}
const file = await selectFile(
"application/json",
false,
file => {
file.content = JSON.parse(file.content);
},
notify
),
data = file?.content;
if (!data) {
return;
}
clearMessages();
storeValue("title", data.title);
storeValue("message", data.message);
storeValue("attachments", data.attachments);
storeValue("role", data.role);
storeValue("model", data.model);
storeValue("prompt", data.prompt);
storeValue("temperature", data.temperature);
storeValue("iterations", data.iterations);
storeValue("reasoning-effort", data.reasoning?.effort);
storeValue("reasoning-tokens", data.reasoning?.tokens);
storeValue("json", data.json);
storeValue("search", data.search);
storeValue("messages", data.messages);
restore();
});
$scrolling.addEventListener("click", () => {
autoScrolling = !autoScrolling;
if (autoScrolling) {
$scrolling.title = "Turn off auto-scrolling";
$scrolling.classList.add("on");
scroll();
} else {
$scrolling.title = "Turn on auto-scrolling";
$scrolling.classList.remove("on");
}
storeValue("scrolling", autoScrolling);
});
$send.addEventListener("click", () => {
generate(true);
});
$login.addEventListener("click", async () => {
$authentication.classList.remove("errored");
$authentication.classList.add("loading");
try {
await login();
$authentication.classList.remove("open");
} catch (err) {
$authError.textContent = `Error: ${err.message}`;
$authentication.classList.add("errored");
$password.value = "";
}
$authentication.classList.remove("loading");
});
$username.addEventListener("input", () => {
$authentication.classList.remove("errored");
});
$password.addEventListener("input", () => {
$authentication.classList.remove("errored");
});
$message.addEventListener("keydown", event => {
if (event.shiftKey) {
return;
}
if (event.ctrlKey && event.key === "Enter") {
$send.click();
}
});
addEventListener("mousemove", event => {
if (!isResizing) {
return;
}
const total = window.innerHeight,
height = clamp(window.innerHeight - event.clientY, 100, total - 240);
$chat.style.height = `${height}px`;
storeValue("resized", height);
scroll(scrollResize, true);
});
addEventListener("mouseup", () => {
isResizing = false;
document.body.classList.remove("resizing");
});
dropdown($role);
dropdown($reasoningEffort);
const resizedHeight = loadValue("resized");
if (resizedHeight) {
$chat.style.height = `${resizedHeight}px`;
}
loadData().then(() => {
restore();
document.body.classList.remove("loading");
});
})();