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

@@ -180,9 +180,13 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
}
if prompt != "" {
request.Messages = append(request.Messages, openrouter.SystemMessage(prompt))
prompt += "\n\n" + InternalGeneralPrompt
} else {
prompt = InternalGeneralPrompt
}
request.Messages = append(request.Messages, openrouter.SystemMessage(prompt))
if model.Tools && r.Tools.Search && env.Tokens.Exa != "" && r.Iterations > 1 {
request.Tools = GetSearchTools()
request.ToolChoice = "auto"

10
internal/general.txt Normal file
View File

@@ -0,0 +1,10 @@
To emit files (no tools), output blocks exactly as:
```
FILE "name"
<<CONTENT>>
<contents>
<<END>>
```
Repeat per file, no code fences, markdown or extra text.

44
internal/preview.html Normal file
View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="/lib/catppuccin.min.css" rel="stylesheet" />
<link href="/css/preview.css" rel="stylesheet" />
<title>File Preview</title>
<script>
const data = {{ . | json }};
</script>
</head>
<body>
<pre><code></code></pre>
<script src="/lib/highlight.min.js"></script>
<script>
const title = document.querySelector("title"),
body = document.querySelector("code");
title.innerText = data.name;
body.innerHTML = hljs.highlightAuto(data.content).value;
addEventListener("keydown", event => {
if ((event.ctrlKey || event.metaKey) && event.key === "S") {
event.preventDefault();
const el = document.createElement("a");
el.download = data.name;
el.href = `data:text/plain;base64,${btoa(data.content)}`;
el.click();
} else if (event.key === "Escape") {
window.close();
}
});
</script>
</body>
</html>

View File

@@ -1,6 +1,7 @@
package main
import (
_ "embed"
"io/fs"
"net/http"
"path/filepath"
@@ -63,6 +64,8 @@ func main() {
gr.Post("/-/dump", HandleDump)
gr.Post("/-/tokenize", HandleTokenize(tokenizer))
gr.Post("/-/preview", HandlePreview)
})
log.Println("Listening at http://localhost:3443/")

96
preview.go Normal file
View File

@@ -0,0 +1,96 @@
package main
import (
_ "embed"
"encoding/json"
"html/template"
"io"
"mime/multipart"
"net/http"
)
var (
//go:embed internal/preview.html
InternalPreview string
InternalPreviewTmpl *template.Template
)
type PreviewRequest struct {
Name string `json:"name"`
Content string `json:"content"`
}
func init() {
InternalPreviewTmpl = template.Must(template.New("internal-preview").Funcs(template.FuncMap{
"json": func(val any) template.JS {
b, err := json.Marshal(val)
if err != nil {
return template.JS("null")
}
return template.JS(b)
},
}).Parse(InternalPreview))
}
func HandlePreview(w http.ResponseWriter, r *http.Request) {
debug("parsing preview")
request, err := ReadPreviewRequest(r)
if err != nil {
RespondJson(w, http.StatusBadRequest, map[string]any{
"error": err.Error(),
})
return
}
debug("rendering preview")
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
InternalPreviewTmpl.Execute(w, request)
}
func ReadPreviewRequest(r *http.Request) (*PreviewRequest, error) {
reader, err := r.MultipartReader()
if err != nil {
return nil, err
}
var request PreviewRequest
for {
part, err := reader.NextPart()
if err != nil {
if err == io.EOF {
break
}
return nil, err
}
if part.FormName() == "name" {
request.Name, err = ReadFormPart(part)
} else if part.FormName() == "content" {
request.Content, err = ReadFormPart(part)
}
if err != nil {
return nil, err
}
}
return &request, nil
}
func ReadFormPart(part *multipart.Part) (string, error) {
b, err := io.ReadAll(part)
if err != nil {
return "", err
}
return string(b), nil
}

View File

@@ -34,6 +34,9 @@ var (
InternalToolsTmpl *template.Template
//go:embed internal/general.txt
InternalGeneralPrompt string
//go:embed internal/title.txt
InternalTitlePrompt string

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

@@ -216,3 +216,60 @@
.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 => {