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

file attachments

This commit is contained in:
Laura
2025-08-18 03:47:37 +02:00
parent 860d029f2e
commit e0fdaa6cdf
9 changed files with 379 additions and 82 deletions

View File

@@ -121,6 +121,7 @@ body.loading #version {
height: 100%;
overflow-y: auto;
padding: 14px 12px;
padding-bottom: 20px;
}
#messages:empty::before {
@@ -307,11 +308,10 @@ body.loading #version {
display: none;
}
.message .tool,
.message:not(.has-tool):not(.has-text) .reasoning,
.message:not(.has-tool) .text {
.message .body {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
overflow: hidden;
}
.message.has-reasoning .text {
@@ -535,6 +535,64 @@ body.loading #version {
background: #24273a;
}
#chat:has(.has-files) {
padding-top: 50px;
}
#attachments {
position: absolute;
top: 2px;
left: 12px;
}
.files {
display: flex;
gap: 6px;
}
.files:not(.has-files) {
display: none;
}
.message .files {
background: #181926;
padding: 10px 12px;
}
.files .file {
position: relative;
display: flex;
gap: 4px;
align-items: center;
background: #24273a;
box-shadow: 0px 0px 10px 6px rgba(0, 0, 0, 0.1);
padding: 8px 10px;
padding-right: 14px;
border-radius: 6px;
border: 1px solid #363a4f;
}
.files .file::before {
content: "";
background-image: url(icons/file.svg);
}
.files .file button.remove {
content: "";
position: absolute;
background-image: url(icons/remove.svg);
width: 16px;
height: 16px;
top: 1px;
right: 1px;
opacity: 0;
transition: 150ms;
}
.files .file:hover button.remove {
opacity: 1;
}
#message {
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
@@ -609,6 +667,8 @@ body.loading #version,
.reasoning .toggle::before,
.reasoning .toggle::after,
#bottom,
.files .file::before,
.files .file .remove,
.message .role::before,
.message .tag-json,
.message .tag-search,
@@ -629,6 +689,7 @@ body.loading #version,
#import,
#export,
#clear,
#upload,
#add,
#send,
#chat .option label {
@@ -725,14 +786,14 @@ label[for="reasoning-tokens"] {
#bottom {
top: -38px;
left: 50%;
transform: translateX(-50%);
right: 20px;
width: 28px;
height: 28px;
background-image: url(icons/down.svg);
transition: 150ms;
}
#upload,
#add,
#send {
bottom: 4px;
@@ -744,11 +805,15 @@ label[for="reasoning-tokens"] {
}
#add {
bottom: 4px;
right: 52px;
background-image: url(icons/add.svg);
}
#upload {
right: 84px;
background-image: url(icons/attach.svg);
}
#json,
#search,
#scrolling,
@@ -794,6 +859,7 @@ label[for="reasoning-tokens"] {
background-image: url(icons/trash.svg);
}
.completing #upload,
.completing #add {
display: none;
}

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

@@ -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: 872 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: 578 B

View File

@@ -23,8 +23,11 @@
<div id="chat">
<button id="bottom" class="hidden" title="Scroll to bottom"></button>
<div id="attachments" class="files"></div>
<textarea id="message" placeholder="Type something..." autocomplete="off"></textarea>
<button id="upload" title="Add files to message"></button>
<button id="add" title="Add message to chat"></button>
<button id="send" title="Add message to chat and start completion"></button>

View File

@@ -4,6 +4,7 @@
$chat = document.getElementById("chat"),
$message = document.getElementById("message"),
$bottom = document.getElementById("bottom"),
$attachments = document.getElementById("attachments"),
$role = document.getElementById("role"),
$model = document.getElementById("model"),
$prompt = document.getElementById("prompt"),
@@ -12,6 +13,7 @@
$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"),
@@ -29,10 +31,12 @@
modelList = [];
let autoScrolling = false,
searchAvailable = false,
jsonMode = false,
searchTool = false;
let searchAvailable = false,
activeMessage;
function scroll(force = false) {
if (!autoScrolling && !force) {
return;
@@ -57,6 +61,7 @@
#role;
#reasoning;
#text;
#files = [];
#tool;
#tags = [];
@@ -73,13 +78,14 @@
#_message;
#_tags;
#_files;
#_reasoning;
#_text;
#_edit;
#_tool;
#_statistics;
constructor(role, reasoning, text) {
constructor(role, reasoning, text, files = []) {
this.#id = uid();
this.#role = role;
this.#reasoning = reasoning || "";
@@ -90,6 +96,10 @@
this.#build();
this.#render();
for (const file of files) {
this.addFile(file);
}
messages.push(this);
if (this.#reasoning || this.#text) {
@@ -118,10 +128,19 @@
_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");
this.#_message.appendChild(_reasoning);
_body.appendChild(_reasoning);
// message reasoning (toggle)
const _toggle = make("button", "toggle");
@@ -155,14 +174,14 @@
// message content
this.#_text = make("div", "text", "markdown");
this.#_message.appendChild(this.#_text);
_body.appendChild(this.#_text);
// message edit textarea
this.#_edit = make("textarea", "text");
this.#_message.appendChild(this.#_edit);
_body.appendChild(this.#_edit);
this.#_edit.addEventListener("keydown", (event) => {
this.#_edit.addEventListener("keydown", event => {
if (event.ctrlKey && event.key === "Enter") {
this.toggleEdit();
} else if (event.key === "Escape") {
@@ -175,7 +194,7 @@
// message tool
this.#_tool = make("div", "tool");
this.#_message.appendChild(this.#_tool);
_body.appendChild(this.#_tool);
// tool call
const _call = make("div", "call");
@@ -229,9 +248,7 @@
// 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";
_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");
@@ -299,7 +316,7 @@
}
#handleImages(element) {
element.querySelectorAll("img:not(.image)").forEach((img) => {
element.querySelectorAll("img:not(.image)").forEach(img => {
img.classList.add("image");
img.addEventListener("load", () => {
@@ -309,10 +326,7 @@
}
#updateReasoningHeight() {
this.#_reasoning.parentNode.style.setProperty(
"--height",
`${this.#_reasoning.scrollHeight}px`,
);
this.#_reasoning.parentNode.style.setProperty("--height", `${this.#_reasoning.scrollHeight}px`);
}
#updateToolHeight() {
@@ -368,9 +382,7 @@
#render(only = false, noScroll = false) {
if (!only || only === "tags") {
const tags = this.#tags.map(
(tag) => `<div class="tag-${tag}" title="${tag}"></div>`,
);
const tags = this.#tags.map(tag => `<div class="tag-${tag}" title="${tag}"></div>`);
this.#_tags.innerHTML = tags.join("");
@@ -409,12 +421,12 @@
let html = "";
if (this.#statistics) {
const { provider, ttft, time, input, output } = this.#statistics;
const { provider, model, ttft, time, input, output } = this.#statistics;
const tps = output / (time / 1000);
html = [
provider ? `<div class="provider">${provider}</div>` : "",
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">
@@ -462,14 +474,15 @@
}
#save() {
storeValue(
"messages",
messages.map((message) => message.getData(true)).filter(Boolean),
);
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);
const index = messages.findIndex(message => message.#id === this.#id);
if (index === -1) {
return false;
@@ -488,6 +501,10 @@
text: this.#text,
};
if (this.#files.length) {
data.files = this.#files;
}
if (this.#tool) {
data.tool = this.#tool;
}
@@ -557,6 +574,32 @@
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.#save();
})
);
this.#_files.classList.add("has-files");
this.#save();
}
setState(state) {
if (this.#state === state) {
return;
@@ -626,6 +669,8 @@
this.#editing = !this.#editing;
if (this.#editing) {
activeMessage = this;
this.#_edit.value = this.#text;
this.#_edit.style.height = `${this.#_text.offsetHeight}px`;
@@ -635,6 +680,8 @@
this.#_edit.focus();
} else {
activeMessage = null;
this.#text = this.#_edit.value;
this.setState(false);
@@ -645,7 +692,7 @@
}
delete() {
const index = messages.findIndex((msg) => msg.#id === this.#id);
const index = messages.findIndex(msg => msg.#id === this.#id);
if (index === -1) {
return;
@@ -769,10 +816,7 @@
const effort = $reasoningEffort.value,
tokens = parseInt($reasoningTokens.value);
if (
!effort &&
(Number.isNaN(tokens) || tokens <= 0 || tokens > 1024 * 1024)
) {
if (!effort && (Number.isNaN(tokens) || tokens <= 0 || tokens > 1024 * 1024)) {
return;
}
@@ -792,7 +836,7 @@
},
json: jsonMode,
search: searchTool,
messages: messages.map((message) => message.getData()).filter(Boolean),
messages: messages.map(message => message.getData()).filter(Boolean),
};
let message, generationID;
@@ -836,7 +880,7 @@
body: JSON.stringify(body),
signal: controller.signal,
},
(chunk) => {
chunk => {
if (!chunk) {
controller = null;
@@ -884,7 +928,7 @@
break;
}
},
}
);
}
@@ -905,7 +949,7 @@
username: username,
password: password,
}),
}).then((response) => response.json());
}).then(response => response.json());
if (!data?.authenticated) {
throw new Error(data.error || "authentication failed");
@@ -982,6 +1026,12 @@
$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();
}
@@ -994,15 +1044,15 @@
$scrolling.click();
}
loadValue("messages", []).forEach((message) => {
const obj = new Message(message.role, message.reasoning, message.text);
loadValue("messages", []).forEach(message => {
const obj = new Message(message.role, message.reasoning, message.text, message.files || []);
if (message.error) {
obj.showError(message.error);
}
if (message.tags) {
message.tags.forEach((tag) => obj.addTag(tag));
message.tags.forEach(tag => obj.addTag(tag));
}
if (message.tool) {
@@ -1020,6 +1070,75 @@
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);
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();
@@ -1030,12 +1149,15 @@
$message.value = "";
storeValue("message", "");
return new Message($role.value, "", text);
const message = new Message($role.value, "", text, attachments);
clearAttachments();
return message;
}
$messages.addEventListener("scroll", () => {
const bottom =
$messages.scrollHeight - ($messages.scrollTop + $messages.offsetHeight);
const bottom = $messages.scrollHeight - ($messages.scrollTop + $messages.offsetHeight);
if (bottom >= 80) {
$bottom.classList.remove("hidden");
@@ -1061,10 +1183,7 @@
if (tags.includes("reasoning")) {
$reasoningEffort.parentNode.classList.remove("none");
$reasoningTokens.parentNode.classList.toggle(
"none",
!!$reasoningEffort.value,
);
$reasoningTokens.parentNode.classList.toggle("none", !!$reasoningEffort.value);
} else {
$reasoningEffort.parentNode.classList.add("none");
$reasoningTokens.parentNode.classList.add("none");
@@ -1089,10 +1208,7 @@
storeValue("temperature", value);
$temperature.classList.toggle(
"invalid",
Number.isNaN(temperature) || temperature < 0 || temperature > 2,
);
$temperature.classList.toggle("invalid", Number.isNaN(temperature) || temperature < 0 || temperature > 2);
});
$reasoningEffort.addEventListener("change", () => {
@@ -1109,10 +1225,7 @@
storeValue("reasoning-tokens", value);
$reasoningTokens.classList.toggle(
"invalid",
Number.isNaN(tokens) || tokens <= 0 || tokens > 1024 * 1024,
);
$reasoningTokens.classList.toggle("invalid", Number.isNaN(tokens) || tokens <= 0 || tokens > 1024 * 1024);
});
$json.addEventListener("click", () => {
@@ -1135,6 +1248,38 @@
storeValue("message", $message.value);
});
$upload.addEventListener("click", async () => {
const file = await selectFile(
// the ultimate list
".adoc,.bash,.bashrc,.bat,.c,.cc,.cfg,.cjs,.cmd,.conf,.cpp,.cs,.css,.csv,.cxx,.dockerfile,.dockerignore,.editorconfig,.env,.fish,.fs,.fsx,.gitattributes,.gitignore,.go,.gradle,.groovy,.h,.hh,.hpp,.htm,.html,.ini,.ipynb,.java,.jl,.js,.json,.jsonc,.jsx,.kt,.kts,.less,.log,.lua,.m,.makefile,.markdown,.md,.mjs,.mk,.mm,.php,.phtml,.pl,.pm,.profile,.properties,.ps1,.psql,.py,.pyw,.r,.rb,.rs,.rst,.sass,.scala,.scss,.sh,.sql,.svelte,.swift,.t,.toml,.ts,.tsv,.tsx,.txt,.vb,.vue,.xhtml,.xml,.xsd,.xsl,.xslt,.yaml,.yml,.zig,.zsh",
false
);
if (!file) {
return;
}
try {
if (!file.name) {
file.name = "unknown.txt";
} else if (file.name.length > 512) {
throw new Error("File name too long (max 512 characters)");
}
if (typeof file.content !== "string") {
throw new Error("File is not a text file");
} else if (!file.content) {
throw new Error("File is empty");
} else if (file.content.length > 4 * 1024 * 1024) {
throw new Error("File is too big (max 4MB)");
}
pushAttachment(file);
} catch(err) {
alert(err.message);
}
});
$add.addEventListener("click", () => {
pushMessage();
});
@@ -1150,6 +1295,7 @@
$export.addEventListener("click", () => {
const data = JSON.stringify({
message: $message.value,
attachments: attachments,
role: $role.value,
model: $model.value,
prompt: $prompt.value,
@@ -1160,7 +1306,7 @@
},
json: jsonMode,
search: searchTool,
messages: messages.map((message) => message.getData()).filter(Boolean),
messages: messages.map(message => message.getData()).filter(Boolean),
});
download("chat.json", "application/json", data);
@@ -1171,7 +1317,8 @@
return;
}
const data = await selectFile("application/json");
const file = await selectFile("application/json", true),
data = file?.content;
if (!data) {
return;
@@ -1180,6 +1327,7 @@
clearMessages();
storeValue("message", data.message);
storeValue("attachments", data.attachments);
storeValue("role", data.role);
storeValue("model", data.model);
storeValue("prompt", data.prompt);
@@ -1221,8 +1369,8 @@
await login();
$authentication.classList.remove("open");
} catch(err) {
$authError.textContent =`Error: ${err.message}`;
} catch (err) {
$authError.textContent = `Error: ${err.message}`;
$authentication.classList.add("errored");
$password.value = "";
@@ -1239,7 +1387,7 @@
$authentication.classList.remove("errored");
});
$message.addEventListener("keydown", (event) => {
$message.addEventListener("keydown", event => {
if (!event.ctrlKey || event.key !== "Enter") {
return;
}

View File

@@ -55,10 +55,7 @@ function make(tag, ...classes) {
}
function escapeHtml(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function formatMilliseconds(ms) {
@@ -101,8 +98,26 @@ function download(name, type, data) {
URL.revokeObjectURL(url);
}
function selectFile(accept) {
return new Promise((resolve) => {
function lines(text) {
let count = 0,
index = 0;
while (index < text.length) {
index = text.indexOf("\n", index);
if (index === -1) {
break;
}
count++;
index++;
}
return count + 1;
}
function selectFile(accept, asJson = false) {
return new Promise(resolve => {
const input = make("input");
input.type = "file";
@@ -120,13 +135,22 @@ function selectFile(accept) {
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result);
let content = reader.result;
resolve(data);
} catch {
resolve(false);
if (asJson) {
try {
content = JSON.parse(content);
} catch {
resolve(false);
return;
}
}
resolve({
name: file.name,
content: content,
});
};
reader.onerror = () => resolve(false);