diff --git a/.gitignore b/.gitignore index 2eea525..6bc37bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.env \ No newline at end of file +.env +debug.json \ No newline at end of file diff --git a/chat.go b/chat.go index 88d791f..c7c9d44 100644 --- a/chat.go +++ b/chat.go @@ -6,16 +6,18 @@ import ( "fmt" "io" "net/http" + "os" "github.com/revrost/go-openrouter" ) type Message struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Text string `json:"text"` } type Request struct { + Prompt string `json:"prompt"` Model string `json:"model"` Temperature float64 `json:"temperature"` Messages []Message `json:"messages"` @@ -24,7 +26,8 @@ type Request struct { func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { var request openrouter.ChatCompletionRequest - if _, ok := ModelMap[r.Model]; !ok { + model, ok := ModelMap[r.Model] + if !ok { return nil, fmt.Errorf("unknown model: %q", r.Model) } @@ -36,6 +39,15 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { request.Temperature = float32(r.Temperature) + prompt, err := BuildPrompt(r.Prompt, model) + if err != nil { + return nil, err + } + + if prompt != "" { + request.Messages = append(request.Messages, openrouter.SystemMessage(prompt)) + } + for index, message := range r.Messages { if message.Role != openrouter.ChatMessageRoleSystem && message.Role != openrouter.ChatMessageRoleAssistant && message.Role != openrouter.ChatMessageRoleUser { return nil, fmt.Errorf("[%d] invalid role: %q", index+1, message.Role) @@ -44,7 +56,7 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { request.Messages = append(request.Messages, openrouter.ChatCompletionMessage{ Role: message.Role, Content: openrouter.Content{ - Text: message.Content, + Text: message.Text, }, }) } @@ -74,6 +86,10 @@ func HandleChat(w http.ResponseWriter, r *http.Request) { request.Stream = true + // DEBUG + b, _ := json.MarshalIndent(request, "", "\t") + os.WriteFile("debug.json", b, 0755) + ctx := r.Context() stream, err := OpenRouterStartStream(ctx, *request) diff --git a/prompts.go b/prompts.go new file mode 100644 index 0000000..5fbac88 --- /dev/null +++ b/prompts.go @@ -0,0 +1,51 @@ +package main + +import ( + "bytes" + _ "embed" + "fmt" + "text/template" + "time" +) + +type PromptData struct { + Name string + Slug string + Date string +} + +var ( + //go:embed prompts/normal.txt + PromptNormal string + + PromptNormalTmpl = template.Must(template.New("normal").Parse(PromptNormal)) +) + +func BuildPrompt(name string, model *Model) (string, error) { + if name == "" { + return "", nil + } + + var tmpl *template.Template + + switch name { + case "normal": + tmpl = PromptNormalTmpl + default: + return "", fmt.Errorf("unknown prompt: %q", name) + } + + var buf bytes.Buffer + + err := tmpl.Execute(&buf, PromptData{ + Name: model.Name, + Slug: model.ID, + Date: time.Now().Format(time.RFC1123), + }) + + if err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/prompts/normal.txt b/prompts/normal.txt new file mode 100644 index 0000000..b1aa404 --- /dev/null +++ b/prompts/normal.txt @@ -0,0 +1,16 @@ +You are {{.Name}}, an AI assistant created to be helpful, harmless, and honest. Today's date is {{.Date}}. + +Your responses should be accurate and informative. When answering questions, be direct and helpful without unnecessary preambles or acknowledgments of instructions. + +Use markdown formatting when it improves clarity: +- **Bold** for emphasis +- *Italics* for softer emphasis +- `Code` for inline code +- Triple backticks for code blocks with language identifiers +- Tables using pipe syntax when presenting structured data + +Be conversational and adapt to the user's tone. Ask clarifying questions when requests are ambiguous. If you cannot answer something due to knowledge limitations, say so directly. + +When asked about your identity, you are {{.Name}}. You don't have access to real-time information or the ability to browse the internet, so for current events or time-sensitive information, acknowledge this limitation. + +Focus on being genuinely helpful while maintaining natural conversation flow. \ No newline at end of file diff --git a/static/chat.css b/static/chat.css index 7098681..3311454 100644 --- a/static/chat.css +++ b/static/chat.css @@ -23,7 +23,6 @@ body { flex-direction: column; gap: 25px; background: #1e2030; - box-shadow: 0px 0px 4px 4px #1e2030; margin: auto; margin-top: 30px; width: 100%; @@ -57,7 +56,6 @@ body { #message, .message { border: none; - box-shadow: 0px 0px 4px 4px #24273a; border-radius: 6px; background: #24273a; font: inherit; @@ -69,6 +67,7 @@ body { max-width: 700px; min-width: 200px; width: max-content; + padding-top: 22px; } .message.user { @@ -83,8 +82,8 @@ body { position: absolute; line-height: 12px; font-size: 12px; - top: 4px; - left: 4px; + top: 6px; + left: 6px; padding-left: 20px; } @@ -109,12 +108,11 @@ body { background-image: url(icons/assistant.svg); } +.message .reasoning, .message .text { display: block; background: transparent; - padding: 10px 12px; - padding-top: 28px; - white-space: pre-line; + padding: 8px 12px; width: 100%; } @@ -123,6 +121,62 @@ body { display: none; } +.message:not(.expanded) .reasoning-text { + height: 0; + overflow: hidden; +} + +.message.expanded .reasoning-text { + padding: 10px 12px; + background: #1e2030; + margin-top: 10px; + border-radius: 6px; +} + +.message:not(.has-reasoning) .reasoning { + display: none; +} + +.reasoning .toggle { + position: relative; + padding: 0 20px; + font-weight: 600; + font-size: 14px; + margin-top: 2px; +} + +.reasoning .toggle::after, +.reasoning .toggle::before { + content: ""; + background-image: url(icons/reasoning.svg); + position: absolute; + top: 0; + left: -2px; + width: 20px; + height: 20px; +} + +.reasoning .toggle::after { + background-image: url(icons/chevron.svg); + left: unset; + right: -2px; + top: 1px; + transition: 150ms; +} + +.message.expanded .reasoning .toggle::after { + transform: rotate(180deg); +} + +.markdown p { + margin: 0; + margin-bottom: 14px; +} + +.markdown p:last-child { + margin-bottom: 0; +} + textarea { border: none; resize: none; @@ -169,46 +223,47 @@ textarea { display: none; } +.message.reasoning .reasoning button.toggle::before { + animation: rotating 2s linear infinite; + background-image: url(icons/loading.svg); +} + .message .text::before { font-style: italic; } +.message:empty.receiving .text::before, .message.waiting .text::before { - content: "waiting..."; + content: ". . ."; } -.message.reasoning .text::before { - content: "reasoning..."; -} - -.message:empty.receiving .text::before { - content: "receiving..."; +button, +input, +select { + border: none; + font: inherit; + color: inherit; + outline: none; } button { background: transparent; - border: none; - color: inherit; cursor: pointer; } +input, +select { + background: #363a4f; +} + #chat button { position: absolute; } -input, -select { - border: none; - background: #363a4f; - font: inherit; - color: inherit; - outline: none; -} - #chat .options { position: absolute; bottom: 4px; - left: 12px; + left: 20px; width: max-content; display: flex; align-items: center; @@ -230,11 +285,14 @@ select { margin-right: 4px; } +.reasoning .toggle::before, +.reasoning .toggle::after, #bottom, .message .role::before, #clear, #add, #send, +.message .copy, .message .edit, .message .delete, #chat .option label { @@ -246,6 +304,14 @@ select { background-repeat: no-repeat; } +.message .copy { + background-image: url(icons/copy.svg); +} + +.message .copy.copied { + background-image: url(icons/check.svg); +} + .message .edit { background-image: url(icons/edit.svg); } @@ -265,7 +331,6 @@ select { #model { width: 180px; padding: 2px 4px; - text-align: right; } #temperature { @@ -283,6 +348,10 @@ label[for="model"] { background-image: url(icons/model.svg); } +label[for="prompt"] { + background-image: url(icons/prompt.svg); +} + label[for="temperature"] { background-image: url(icons/temperature.svg); } @@ -300,7 +369,7 @@ label[for="temperature"] { #add, #send { bottom: 4px; - right: 12px; + right: 20px; width: 28px; height: 28px; background-image: url(icons/send.svg); @@ -308,7 +377,7 @@ label[for="temperature"] { #add { bottom: 4px; - right: 44px; + right: 52px; background-image: url(icons/add.svg); } @@ -323,4 +392,14 @@ label[for="temperature"] { .completing #send { background-image: url(icons/stop.svg); +} + +@keyframes rotating { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } } \ No newline at end of file diff --git a/static/chat.js b/static/chat.js index 8caed7a..c584a51 100644 --- a/static/chat.js +++ b/static/chat.js @@ -5,11 +5,286 @@ $bottom = document.getElementById("bottom"), $role = document.getElementById("role"), $model = document.getElementById("model"), + $prompt = document.getElementById("prompt"), $temperature = document.getElementById("temperature"), $add = document.getElementById("add"), $send = document.getElementById("send"), $clear = document.getElementById("clear"); + const messages = []; + + function uid() { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + } + + function make(tag, ...classes) { + const el = document.createElement(tag); + + el.classList.add(...classes); + + return el; + } + + function render(markdown) { + return marked.parse(DOMPurify.sanitize(markdown), { + gfm: true, + }); + } + + function scroll() { + $messages.scroll({ + top: $messages.scrollHeight + 200, + behavior: "smooth", + }); + } + + class Message { + #id; + #role; + #reasoning; + #text; + + #editing = false; + #expanded = false; + #state = false; + + #_message; + #_role; + #_reasoning; + #_text; + #_edit; + + constructor(role, reasoning, text) { + this.#id = uid(); + this.#role = role; + this.#reasoning = reasoning || ""; + this.#text = text || ""; + + this.#build(); + this.#render(); + + messages.push(this); + } + + #build() { + // main message div + this.#_message = make("div", "message", this.#role); + + // message role + this.#_role = make("div", "role", this.#role); + + this.#_message.appendChild(this.#_role); + + // message reasoning (wrapper) + const _reasoning = make("div", "reasoning", "markdown"); + + this.#_message.appendChild(_reasoning); + + // message reasoning (toggle) + const _toggle = make("button", "toggle"); + + _toggle.textContent = "Reasoning"; + + _reasoning.appendChild(_toggle); + + _toggle.addEventListener("click", () => { + this.#expanded = !this.#expanded; + + if (this.#expanded) { + this.#_message.classList.add("expanded"); + } else { + this.#_message.classList.remove("expanded"); + } + }); + + // message reasoning (content) + this.#_reasoning = make("div", "reasoning-text"); + + _reasoning.appendChild(this.#_reasoning); + + // message content + this.#_text = make("div", "text", "markdown"); + + this.#_message.appendChild(this.#_text); + + // message edit textarea + this.#_edit = make("textarea", "text"); + + this.#_message.appendChild(this.#_edit); + + // message options + const _opts = make("div", "options"); + + this.#_message.appendChild(_opts); + + // copy option + const _optCopy = make("button", "copy"); + + _optCopy.title = "Copy message content"; + + _opts.appendChild(_optCopy); + + let timeout; + + _optCopy.addEventListener("click", () => { + clearTimeout(timeout); + + navigator.clipboard.writeText(this.#text); + + _optCopy.classList.add("copied"); + + timeout = setTimeout(() => { + _optCopy.classList.remove("copied"); + }, 1000); + }); + + // 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(); + }); + + $messages.appendChild(this.#_message); + + scroll(); + } + + #render(only = false) { + if (!only || only === "role") { + this.#_role.textContent = this.#role; + } + + if (!only || only === "reasoning") { + this.#_reasoning.innerHTML = render(this.#reasoning); + + if (this.#reasoning) { + this.#_message.classList.add("has-reasoning"); + } else { + this.#_message.classList.remove("has-reasoning"); + } + } + + if (!only || only === "text") { + this.#_text.innerHTML = render(this.#text); + } + + scroll(); + } + + #save() { + const data = messages.map((message) => message.getData(true)); + + console.log("save", data); + + localStorage.setItem("messages", JSON.stringify(data)); + } + + getData(includeReasoning = false) { + const data = { + role: this.#role, + text: this.#text, + }; + + if (this.#reasoning && includeReasoning) { + data.reasoning = this.#reasoning; + } + + return data; + } + + setState(state) { + if (this.#state === state) { + return; + } + + if (this.#state) { + this.#_message.classList.remove(this.#state); + } + + if (state) { + this.#_message.classList.add(state); + } + + this.#state = state; + } + + addReasoning(chunk) { + this.#reasoning += chunk; + + this.#render("reasoning"); + this.#save(); + } + + addText(text) { + this.#text += text; + + this.#render("text"); + this.#save(); + } + + stopEdit() { + if (!this.#editing) { + return; + } + + this.toggleEdit(); + } + + toggleEdit() { + this.#editing = !this.#editing; + + if (this.#editing) { + this.#_edit.value = this.#text; + + this.#_edit.style.height = `${this.#_text.offsetHeight}px`; + this.#_edit.style.width = `${this.#_text.offsetWidth}px`; + + this.setState("editing"); + + this.#_edit.focus(); + } else { + this.#text = this.#_edit.value; + + this.setState(false); + + this.#render(); + this.#save(); + } + } + + delete() { + const index = messages.findIndex((msg) => msg.#id === this.#id); + + if (index === -1) { + return; + } + + console.log("delete", index); + + messages.splice(index, 1); + + this.#_message.remove(); + + this.#save(); + } + } + let controller; async function json(url) { @@ -116,134 +391,21 @@ function restore(models) { $role.value = localStorage.getItem("role") || "user"; $model.value = localStorage.getItem("model") || models[0].id; + $prompt.value = localStorage.getItem("prompt") || "normal"; $temperature.value = localStorage.getItem("temperature") || 0.85; try { - const messages = JSON.parse(localStorage.getItem("messages") || "[]"); - - messages.forEach(addMessage); + JSON.parse(localStorage.getItem("messages") || "[]").forEach( + (message) => + new Message( + message.role, + message.reasoning, + message.text, + ), + ); } catch {} } - function saveMessages() { - localStorage.setItem("messages", JSON.stringify(buildMessages(false))); - } - - function scrollMessages() { - $messages.scroll({ - top: $messages.scrollHeight + 200, - behavior: "smooth", - }); - } - - function toggleEditing(el) { - const text = el.querySelector("div.text"), - edit = el.querySelector("textarea.text"); - - if (el.classList.contains("editing")) { - text.textContent = edit.value.trim(); - - el.classList.remove("editing"); - - saveMessages(); - } else { - edit.value = text.textContent; - edit.style.height = `${text.offsetHeight}px`; - - el.classList.add("editing"); - - edit.focus(); - } - } - - function addMessage(message) { - const el = document.createElement("div"); - - el.classList.add("message", message.role); - - // message role - const role = document.createElement("div"); - - role.textContent = message.role; - role.classList.add("role"); - - el.appendChild(role); - - // message content - const text = document.createElement("div"); - - text.textContent = message.content; - text.classList.add("text"); - - el.appendChild(text); - - // message edit textarea - const edit = document.createElement("textarea"); - - edit.classList.add("text"); - - el.appendChild(edit); - - // message options - const opts = document.createElement("div"); - - opts.classList.add("options"); - - el.appendChild(opts); - - // edit option - const optEdit = document.createElement("button"); - - optEdit.title = "Edit message content"; - optEdit.classList.add("edit"); - - opts.appendChild(optEdit); - - optEdit.addEventListener("click", () => { - toggleEditing(el); - }); - - // delete option - const optDelete = document.createElement("button"); - - optDelete.title = "Delete message"; - optDelete.classList.add("delete"); - - opts.appendChild(optDelete); - - optDelete.addEventListener("click", () => { - el.remove(); - - saveMessages(); - }); - - // append to messages - $messages.appendChild(el); - - scrollMessages(); - - return { - set(content) { - text.textContent = content; - - scrollMessages(); - }, - state(state) { - if (state && el.classList.contains(state)) { - return; - } - - el.classList.remove("waiting", "reasoning", "receiving"); - - if (state) { - el.classList.add(state); - } - - scrollMessages(); - }, - }; - } - function pushMessage() { const text = $message.value.trim(); @@ -251,40 +413,9 @@ return false; } - addMessage({ - role: $role.value, - content: text, - }); - $message.value = ""; - saveMessages(); - - return true; - } - - function buildMessages(clean = true) { - const messages = []; - - $messages.querySelectorAll(".message").forEach((message) => { - if (clean && message.classList.contains("editing")) { - toggleEditing(message); - } - - const role = message.querySelector(".role"), - text = message.querySelector(".text"); - - if (!role || !text) { - return; - } - - messages.push({ - role: role.textContent.trim(), - content: text.textContent.trim().replace(/\r/g, ""), - }); - }); - - return messages; + return new Message($role.value, "", text); } $messages.addEventListener("scroll", () => { @@ -313,6 +444,10 @@ localStorage.setItem("model", $model.value); }); + $prompt.addEventListener("change", () => { + localStorage.setItem("prompt", $prompt.value); + }); + $temperature.addEventListener("input", () => { localStorage.setItem("temperature", $temperature.value); }); @@ -330,9 +465,9 @@ return; } - $messages.innerHTML = ""; - - saveMessages(); + for (const message of messages) { + message.delete(); + } }); $send.addEventListener("click", () => { @@ -349,26 +484,21 @@ } pushMessage(); - saveMessages(); controller = new AbortController(); $chat.classList.add("completing"); const body = { + prompt: $prompt.value, model: $model.value, temperature: temperature, - messages: buildMessages(), + messages: messages.map((message) => message.getData()), }; - const result = { - role: "assistant", - content: "", - }; + const message = new Message("assistant", "", ""); - const message = addMessage(result); - - message.state("waiting"); + message.setState("waiting"); stream( "/-/chat", @@ -384,28 +514,27 @@ if (!chunk) { controller = null; - saveMessages(); + message.setState(false); $chat.classList.remove("completing"); return; } + console.log(chunk); + switch (chunk.type) { case "reason": - message.state("reasoning"); + message.setState("reasoning"); + message.addReasoning(chunk.text); break; case "text": - result.content += chunk.text; - - message.state("receive"); - message.set(result.content); + message.setState("receiving"); + message.addText(chunk.text); break; } - - saveMessages(); }, ); }); diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..3b41041 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/icons/check.svg b/static/icons/check.svg new file mode 100644 index 0000000..d74fb29 --- /dev/null +++ b/static/icons/check.svg @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/static/icons/chevron.svg b/static/icons/chevron.svg new file mode 100644 index 0000000..291e4df --- /dev/null +++ b/static/icons/chevron.svg @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/static/icons/copy.svg b/static/icons/copy.svg new file mode 100644 index 0000000..d55f1c3 --- /dev/null +++ b/static/icons/copy.svg @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/static/icons/loading.svg b/static/icons/loading.svg new file mode 100644 index 0000000..207bcaf --- /dev/null +++ b/static/icons/loading.svg @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/static/icons/model.svg b/static/icons/model.svg index 994afdd..1ecd063 100644 --- a/static/icons/model.svg +++ b/static/icons/model.svg @@ -3,5 +3,5 @@ \ No newline at end of file diff --git a/static/icons/prompt.svg b/static/icons/prompt.svg new file mode 100644 index 0000000..916675b --- /dev/null +++ b/static/icons/prompt.svg @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/static/icons/reasoning.svg b/static/icons/reasoning.svg new file mode 100644 index 0000000..cec31f9 --- /dev/null +++ b/static/icons/reasoning.svg @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/static/index.html b/static/index.html index 816dc96..fd2d38e 100644 --- a/static/index.html +++ b/static/index.html @@ -36,6 +36,13 @@ +