diff --git a/chat.go b/chat.go index 42da363..cf3482c 100644 --- a/chat.go +++ b/chat.go @@ -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" diff --git a/internal/general.txt b/internal/general.txt new file mode 100644 index 0000000..0957f6e --- /dev/null +++ b/internal/general.txt @@ -0,0 +1,10 @@ +To emit files (no tools), output blocks exactly as: + +``` +FILE "name" +<> + +<> +``` + +Repeat per file, no code fences, markdown or extra text. \ No newline at end of file diff --git a/internal/preview.html b/internal/preview.html new file mode 100644 index 0000000..2bc9b47 --- /dev/null +++ b/internal/preview.html @@ -0,0 +1,44 @@ + + + + + + + + + + File Preview + + + + +
+ + + + + \ No newline at end of file diff --git a/main.go b/main.go index 5e8bd18..ae64187 100644 --- a/main.go +++ b/main.go @@ -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/") diff --git a/preview.go b/preview.go new file mode 100644 index 0000000..874172c --- /dev/null +++ b/preview.go @@ -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 +} diff --git a/prompts.go b/prompts.go index 243b431..d009e4b 100644 --- a/prompts.go +++ b/prompts.go @@ -34,6 +34,9 @@ var ( InternalToolsTmpl *template.Template + //go:embed internal/general.txt + InternalGeneralPrompt string + //go:embed internal/title.txt InternalTitlePrompt string diff --git a/static/css/chat.css b/static/css/chat.css index ed95e49..250db4f 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -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, diff --git a/static/css/icons/download.svg b/static/css/icons/download.svg new file mode 100644 index 0000000..d28321a --- /dev/null +++ b/static/css/icons/download.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/css/markdown.css b/static/css/markdown.css index 151c6f7..6d56a07 100644 --- a/static/css/markdown.css +++ b/static/css/markdown.css @@ -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; } \ No newline at end of file diff --git a/static/css/preview.css b/static/css/preview.css new file mode 100644 index 0000000..db5793a --- /dev/null +++ b/static/css/preview.css @@ -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; +} \ No newline at end of file diff --git a/static/js/chat.js b/static/js/chat.js index e38dd58..0a9c1bf 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -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; diff --git a/static/js/lib.js b/static/js/lib.js index e8eb726..0e24da6 100644 --- a/static/js/lib.js +++ b/static/js/lib.js @@ -43,7 +43,7 @@ function wait(ms) { } function escapeHtml(text) { - return text.replace(/&/g, "&").replace(//g, ">"); + return text.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } function formatMilliseconds(ms) { diff --git a/static/js/markdown.js b/static/js/markdown.js index b77da43..67edf51 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -18,9 +18,7 @@ const { type, text } = token; if (type === "html") { - token.text = token.text.replace(/&/g, "&"); - token.text = token.text.replace(//g, ">"); + 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, "&").replace(/"/g, """).replace(//g, ">"); + } + + 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<>\s*$/gm) || []).length, + ends = (markdown.match(/^<>$/gm) || []).length; + + if (starts !== ends) { + markdown += "\n<>"; + } + + const files = [], + table = {}; + + markdown = markdown.replace(/^FILE\s+"([^"]+)"(?:\s+LINES\s+(\d+))?\s*\r?\n<>\s*\r?\n([\s\S]*?)\r?\n<>$/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(/(?:

\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 `

${name}
${formatBytes(file.size)}
`; + } + + 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 => {