1
0
mirror of https://github.com/coalaura/whiskr.git synced 2025-12-02 20:22:52 +00:00

file parsing and previewing

This commit is contained in:
Laura
2025-11-04 00:23:29 +01:00
parent 41210d0b36
commit 9f983f3034
13 changed files with 439 additions and 10 deletions

View File

@@ -1008,6 +1008,8 @@ body.loading #version,
#bottom,
.files .file::before,
.files .file .remove,
.markdown .inline-file::before,
.markdown .inline-file .download,
.message .role::before,
.message .tag-json,
.message .tag-search,

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: 651 B

View File

@@ -215,4 +215,61 @@
.markdown th>*:last-child,
.markdown>*:last-child {
margin-bottom: 0;
}
.markdown .inline-file {
position: relative;
display: flex;
gap: 4px;
align-items: center;
background: #24273a;
width: max-content;
max-width: 100%;
white-space: nowrap;
box-shadow: none;
cursor: pointer;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid #363a4f;
overflow: hidden;
}
.markdown .inline-file::before {
content: "";
background-image: url(icons/file.svg);
flex-shrink: 0;
}
.message .inline-file .name {
overflow: hidden;
text-overflow: ellipsis;
}
.markdown .inline-file .size {
flex-shrink: 0;
}
.markdown .inline-file .size sup {
position: relative;
top: -5px;
font-size: 10px;
font-family: "Comic Code", ui-monospace, "Cascadia Mono", "Segoe UI Mono", "Ubuntu Mono", "Roboto Mono", Menlo, Monaco, Consolas, monospace;
}
.markdown .inline-file .download {
background-image: url(icons/download.svg);
flex-shrink: 0;
}
.message.busy .markdown .inline-file.busy::after {
content: "";
display: block;
position: absolute;
width: 32px;
height: 3px;
background: #cad3f5;
animation: swivel 1.5s ease-in-out infinite;
opacity: 0.5;
bottom: -1px;
left: 0;
}

57
static/css/preview.css Normal file
View File

@@ -0,0 +1,57 @@
@font-face {
font-family: "Comic Code";
src: url(fonts/ComicCode-Regular.ttf);
font-weight: normal;
}
* {
box-sizing: border-box;
}
:-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: #cad3f5;
border: none;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: #cad3f5;
}
* {
scrollbar-width: thin;
scrollbar-color: #cad3f5 transparent;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
background: #181926;
color: #cad3f5;
}
pre,
code {
font-family: "Comic Code", ui-monospace, "Cascadia Mono", "Segoe UI Mono", "Ubuntu Mono", "Roboto Mono", Menlo, Monaco, Consolas, monospace;
font-size: 13px;
}
pre {
white-space: pre-wrap;
line-height: 18px;
overflow: hidden;
tab-size: 4;
margin: 0;
padding: 10px 14px;
}

View File

@@ -176,6 +176,7 @@
#editing = false;
#state = false;
#loading = false;
#inline = {};
#_diff;
#pending = {};
@@ -297,6 +298,18 @@
_body.appendChild(this.#_text);
this.#_text.addEventListener("click", event => {
this.#handlePreview(event);
});
this.#_text.addEventListener("auxclick", event => {
if (event.button !== 1) {
return;
}
this.#handlePreview(event);
});
// message edit textarea
this.#_edit = make("textarea", "text");
@@ -514,6 +527,62 @@
scroll();
}
#handlePreview(event) {
const inline = event.target.closest(".inline-file[data-id]"),
id = inline?.dataset?.id;
if (!id) {
return;
}
const file = this.#inline[id];
if (!file) {
notify(`Error: invalid file "${id}"`);
return;
}
if (event.target.classList.contains("download")) {
download(file.name, "text/plain", file.content);
return;
}
// build form
const form = make("form");
form.style.display = "none";
form.enctype = "multipart/form-data";
form.method = "post";
form.action = "/-/preview";
form.target = "_blank";
// add name field
const name = make("input");
name.name = "name";
name.value = file.name;
form.appendChild(name);
// add content field
const content = make("textarea");
content.name = "content";
content.value = file.content;
form.appendChild(content);
// send form
document.body.appendChild(form);
form.submit();
form.remove();
}
#handleImages(element) {
element.querySelectorAll("img:not(.image)").forEach(img => {
img.classList.add("image");
@@ -552,7 +621,11 @@
#patch(name, element, md, after = false) {
if (!element.firstChild) {
element.innerHTML = render(md);
const { html, files } = render(md);
element.innerHTML = html;
this.#inline = files;
this.#handleImages(element);
@@ -570,10 +643,12 @@
this.#patching[name] = true;
schedule(() => {
const html = render(this.#pending[name]);
const { html, files } = render(this.#pending[name]);
this.#patching[name] = false;
this.#inline = files;
this.#_diff.innerHTML = html;
this.#morph(element, this.#_diff);
@@ -645,7 +720,7 @@
_cost.textContent = cost ? `${formatMoney(cost)}` : "";
_result.classList.toggle("error", result?.startsWith("error: "));
_result.innerHTML = render(result ? wrapJSON(result) : "*processing*");
_result.innerHTML = render(result ? wrapJSON(result) : "*processing*").html;
this.#_tool.classList.toggle("invalid", !!invalid);
@@ -893,13 +968,15 @@
}
if (state) {
this.#_message.classList.add(state);
this.#_message.classList.add(state, "busy");
} else {
if (this.#tool && !this.#tool.result) {
this.#tool.result = "failed to run tool";
this.#render("tool");
}
this.#_message.classList.remove("busy");
}
this.#state = state;

View File

@@ -43,7 +43,7 @@ function wait(ms) {
}
function escapeHtml(text) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return text.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function formatMilliseconds(ms) {

View File

@@ -18,9 +18,7 @@
const { type, text } = token;
if (type === "html") {
token.text = token.text.replace(/&/g, "&amp;");
token.text = token.text.replace(/</g, "&lt;");
token.text = token.text.replace(/>/g, "&gt;");
token.text = escapeHtml(token.text);
return;
} else if (type !== "code") {
@@ -66,6 +64,77 @@
},
});
function generateID() {
return `${Math.random().toString(36).slice(2)}${"0".repeat(8)}`.slice(0, 8);
}
function escapeHtml(text) {
return text.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function formatBytes(bytes) {
if (!+bytes) {
return "0B";
}
const sizes = ["B", "kB", "MB", "GB", "TB"],
i = Math.floor(Math.log(bytes) / Math.log(1000));
const val = bytes / Math.pow(1000, i),
dec = i === 0 ? 0 : val < 10 ? 2 : 1;
return `${val.toFixed(dec)}${sizes[i]}`;
}
function parse(markdown) {
const starts = (markdown.match(/^FILE\s+"([^"]+)"(?:\s+LINES\s+\d+)?\s*\r?\n<<CONTENT>>\s*$/gm) || []).length,
ends = (markdown.match(/^<<END(?:ING)?>>$/gm) || []).length;
if (starts !== ends) {
markdown += "\n<<ENDING>>";
}
const files = [],
table = {};
markdown = markdown.replace(/^FILE\s+"([^"]+)"(?:\s+LINES\s+(\d+))?\s*\r?\n<<CONTENT>>\s*\r?\n([\s\S]*?)\r?\n<<END(ING)?>>$/gm, (_a, name, _b, content, ending) => {
const index = files.length,
id = generateID();
files.push({
id: id,
name: name,
size: content.length,
busy: !!ending,
});
table[id] = {
name: name,
content: content,
};
return `§|FILE|${index}`;
});
const html = marked.parse(markdown).replace(/(?:<p>\s*)§\|FILE\|(\d+)\|§(?:<\/p>\s*)/g, (match, index) => {
index = parseInt(index, 10);
if (index < files.length) {
const file = files[index],
name = escapeHtml(file.name);
return `<div class="inline-file ${file.busy ? "busy" : ""}" data-id="${file.id}"><div class="name" title="${name}">${name}</div><div class="size"><sup>${formatBytes(file.size)}</sup></div><button class="download" title="Download file"></button></div>`;
}
return match;
});
return {
html: html,
files: table,
};
}
addEventListener("click", event => {
const button = event.target,
header = button.closest(".pre-header"),
@@ -156,7 +225,7 @@
addEventListener("pointercancel", endScroll);
window.render = markdown => {
return marked.parse(markdown);
return parse(markdown);
};
window.renderInline = markdown => {