From a8cbef7c7bdf46db498d78b10b24dfb542fd1302 Mon Sep 17 00:00:00 2001 From: Laura Date: Mon, 11 Aug 2025 00:15:58 +0200 Subject: [PATCH] json & web search --- README.md | 3 +- chat.go | 14 ++ main.go | 2 +- models.go | 22 ++- static/css/chat.css | 108 +++++++++-- static/css/dropdown.css | 8 + static/css/icons/json-mode.svg | 7 + static/css/icons/json-off.svg | 7 + static/css/icons/json-on.svg | 7 + static/css/icons/search-off.svg | 7 + static/css/icons/search-on.svg | 7 + static/css/icons/search-tool.svg | 7 + static/css/icons/tags/all.svg | 7 + static/css/icons/tags/json.svg | 7 + static/index.html | 6 +- static/js/chat.js | 180 ++++++++++++++++-- static/js/dropdown.js | 24 ++- static/js/lib.js | 10 +- .../catppuccin.css => lib/catppuccin.min.css} | 0 19 files changed, 374 insertions(+), 59 deletions(-) create mode 100644 static/css/icons/json-mode.svg create mode 100644 static/css/icons/json-off.svg create mode 100644 static/css/icons/json-on.svg create mode 100644 static/css/icons/search-off.svg create mode 100644 static/css/icons/search-on.svg create mode 100644 static/css/icons/search-tool.svg create mode 100644 static/css/icons/tags/all.svg create mode 100644 static/css/icons/tags/json.svg rename static/{css/catppuccin.css => lib/catppuccin.min.css} (100%) diff --git a/README.md b/README.md index 1733750..2e27b05 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,13 @@ whiskr is a private, self-hosted web chat interface for interacting with AI mode - Search field with fuzzy matching to quickly find models - Models are listed newest -> oldest - Reasoning effort control +- Web search tool +- Structured JSON output ## TODO - Retry button for assistant messages - Import and export of chats -- Web search tool - Image and file attachments ## Built With diff --git a/chat.go b/chat.go index 05acb3a..4e2c71a 100644 --- a/chat.go +++ b/chat.go @@ -25,6 +25,8 @@ type Request struct { Prompt string `json:"prompt"` Model string `json:"model"` Temperature float64 `json:"temperature"` + JSON bool `json:"json"` + Search bool `json:"search"` Reasoning Reasoning `json:"reasoning"` Messages []Message `json:"messages"` } @@ -60,6 +62,18 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { } } + if model.JSON && r.JSON { + request.ResponseFormat = &openrouter.ChatCompletionResponseFormat{ + Type: openrouter.ChatCompletionResponseFormatTypeJSONObject, + } + } + + if r.Search { + request.Plugins = append(request.Plugins, openrouter.ChatCompletionPlugin{ + ID: openrouter.PluginIDWeb, + }) + } + prompt, err := BuildPrompt(r.Prompt, model) if err != nil { return nil, err diff --git a/main.go b/main.go index c9d535e..8643a62 100644 --- a/main.go +++ b/main.go @@ -42,7 +42,7 @@ func cache(next http.Handler) http.Handler { path := strings.ToLower(r.URL.Path) ext := filepath.Ext(path) - if ext == ".svg" || ext == ".ttf" || strings.HasSuffix(path, ".min.js") { + if ext == ".svg" || ext == ".ttf" || strings.HasSuffix(path, ".min.js") || strings.HasSuffix(path, ".min.css") { w.Header().Set("Cache-Control", "public, max-age=3024000, immutable") } diff --git a/models.go b/models.go index 3e759e2..d17ee2e 100644 --- a/models.go +++ b/models.go @@ -15,6 +15,7 @@ type Model struct { Tags []string `json:"tags,omitempty"` Reasoning bool `json:"-"` + JSON bool `json:"-"` } var ModelMap = make(map[string]*Model) @@ -40,7 +41,7 @@ func LoadModels() ([]*Model, error) { name = name[index+2:] } - tags, reasoning := GetModelTags(model) + tags, reasoning, json := GetModelTags(model) m := &Model{ ID: model.ID, @@ -49,6 +50,7 @@ func LoadModels() ([]*Model, error) { Tags: tags, Reasoning: reasoning, + JSON: json, } models[index] = m @@ -59,19 +61,25 @@ func LoadModels() ([]*Model, error) { return models, nil } -func GetModelTags(model openrouter.Model) ([]string, bool) { +func GetModelTags(model openrouter.Model) ([]string, bool, bool) { var ( reasoning bool + json bool tags []string ) for _, parameter := range model.SupportedParameters { - if parameter == "reasoning" { + switch parameter { + case "reasoning": reasoning = true - } - if parameter == "reasoning" || parameter == "tools" { - tags = append(tags, parameter) + tags = append(tags, "reasoning") + case "response_format": + json = true + + tags = append(tags, "json") + case "tools": + tags = append(tags, "tools") } } @@ -83,5 +91,5 @@ func GetModelTags(model openrouter.Model) ([]string, bool) { sort.Strings(tags) - return tags, reasoning + return tags, reasoning, json } diff --git a/static/css/chat.css b/static/css/chat.css index b40afbd..f05e921 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -137,21 +137,43 @@ body { .message .role { position: absolute; + display: flex; + gap: 4px; + align-items: center; font-family: "Comic Code", ui-monospace, "Cascadia Mono", "Segoe UI Mono", "Ubuntu Mono", "Roboto Mono", Menlo, Monaco, Consolas, monospace; font-size: 12px; line-height: 12px; - top: 8px; + top: 6px; left: 6px; - padding-left: 20px; } -#messages .message .role::before { +.message .tags::before { content: ""; - width: 16px; - height: 16px; position: absolute; - top: -1px; - left: 0; + top: 7px; + left: -10px; + height: 2px; + width: 5px; + background: #939ab7; +} + +.message .tags { + display: flex; + gap: 4px; + position: relative; + margin-left: 12px; +} + +.message:not(.has-tags) .tags { + display: none; +} + +#messages .message .tag-json { + background-image: url(icons/json-mode.svg); +} + +#messages .message .tag-search { + background-image: url(icons/search-tool.svg); } .message.user .role::before { @@ -187,15 +209,28 @@ body { display: none; } -.message .reasoning, -.message div.text { +#messages .message .reasoning, +#messages .message div.text { background: #24273a; } -.message textarea.text { +#messages .message textarea.text { background: #181926; } +.message .text .error { + color: #ed8796; +} + +.message.errored { + border: 2px solid #ed8796; +} + +.message.errored .options .copy, +.message.errored .options .edit { + display: none; +} + .reasoning-text pre { background: #1b1d2a; } @@ -387,14 +422,18 @@ select { .reasoning .toggle::before, .reasoning .toggle::after, #bottom, -.message .role::before, +#messages .message .role::before, +#messages .message .tag-json, +#messages .message .tag-search, +#json, +#search, #scrolling, #clear, #add, #send, .pre-copy, -.message .copy, -.message .edit, +#messages .message .copy, +#messages .message .edit, .message .delete, #chat .option label { display: block; @@ -405,8 +444,20 @@ select { background-repeat: no-repeat; } +#messages .message .tag-json, +#messages .message .tag-search, +#messages .message .role::before { + content: ""; + width: 16px; + height: 16px; +} + +input.invalid { + border: 1px solid #ed8796; +} + .pre-copy, -.message .copy { +#messages .message .copy { background-image: url(icons/copy.svg); } @@ -415,11 +466,11 @@ select { background-image: url(icons/check.svg); } -.message .edit { +#messages .message .edit { background-image: url(icons/edit.svg); } -.message.editing .edit { +#messages .message.editing .edit { background-image: url(icons/save.svg); } @@ -494,8 +545,14 @@ label[for="reasoning-tokens"] { background-image: url(icons/add.svg); } -#scrolling { +#json, +#search, +#scrolling, +#clear { position: unset !important; +} + +#scrolling { background-image: url(icons/screen-slash.svg); } @@ -503,8 +560,23 @@ label[for="reasoning-tokens"] { background-image: url(icons/screen.svg); } +#json { + background-image: url(icons/json-off.svg); +} + +#json.on { + background-image: url(icons/json-on.svg); +} + +#search { + background-image: url(icons/search-off.svg); +} + +#search.on { + background-image: url(icons/search-on.svg); +} + #clear { - position: unset !important; background-image: url(icons/trash.svg); } diff --git a/static/css/dropdown.css b/static/css/dropdown.css index ec499e0..0c3b379 100644 --- a/static/css/dropdown.css +++ b/static/css/dropdown.css @@ -106,6 +106,14 @@ background-image: url(icons/tags/vision.svg) } +.tags .tag.json { + background-image: url(icons/tags/json.svg) +} + +.tags .tag.all { + background-image: url(icons/tags/all.svg) +} + .dropdown .search { background: #2a2e41; border-top: 2px solid #494d64; diff --git a/static/css/icons/json-mode.svg b/static/css/icons/json-mode.svg new file mode 100644 index 0000000..beaa289 --- /dev/null +++ b/static/css/icons/json-mode.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/css/icons/json-off.svg b/static/css/icons/json-off.svg new file mode 100644 index 0000000..7649332 --- /dev/null +++ b/static/css/icons/json-off.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/css/icons/json-on.svg b/static/css/icons/json-on.svg new file mode 100644 index 0000000..0f48917 --- /dev/null +++ b/static/css/icons/json-on.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/css/icons/search-off.svg b/static/css/icons/search-off.svg new file mode 100644 index 0000000..5b80b7e --- /dev/null +++ b/static/css/icons/search-off.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/css/icons/search-on.svg b/static/css/icons/search-on.svg new file mode 100644 index 0000000..88a85d6 --- /dev/null +++ b/static/css/icons/search-on.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/css/icons/search-tool.svg b/static/css/icons/search-tool.svg new file mode 100644 index 0000000..78a8835 --- /dev/null +++ b/static/css/icons/search-tool.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/css/icons/tags/all.svg b/static/css/icons/tags/all.svg new file mode 100644 index 0000000..ee3d73a --- /dev/null +++ b/static/css/icons/tags/all.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/css/icons/tags/json.svg b/static/css/icons/tags/json.svg new file mode 100644 index 0000000..45adcac --- /dev/null +++ b/static/css/icons/tags/json.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/index.html b/static/index.html index f3e6f50..f6ba374 100644 --- a/static/index.html +++ b/static/index.html @@ -8,7 +8,7 @@ - + @@ -63,6 +63,10 @@ +
+ + +
diff --git a/static/js/chat.js b/static/js/chat.js index b797f15..96e2a29 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -9,6 +9,8 @@ $temperature = document.getElementById("temperature"), $reasoningEffort = document.getElementById("reasoning-effort"), $reasoningTokens = document.getElementById("reasoning-tokens"), + $json = document.getElementById("json"), + $search = document.getElementById("search"), $add = document.getElementById("add"), $send = document.getElementById("send"), $scrolling = document.getElementById("scrolling"), @@ -18,6 +20,8 @@ models = {}; let autoScrolling = false, + jsonMode = false, + searchTool = false, interacted = false; function scroll(force = false) { @@ -27,7 +31,7 @@ setTimeout(() => { $messages.scroll({ - top: $messages.scrollHeight + 200, + top: $messages.scrollHeight, behavior: "smooth", }); }, 0); @@ -39,6 +43,9 @@ #reasoning; #text; + #tags = []; + #error = false; + #editing = false; #expanded = false; #state = false; @@ -48,7 +55,7 @@ #patching = {}; #_message; - #_role; + #_tags; #_reasoning; #_text; #_edit; @@ -75,10 +82,22 @@ // main message div this.#_message = make("div", "message", this.#role); - // message role - this.#_role = make("div", "role", this.#role); + // message role (wrapper) + const _wrapper = make("div", "role", this.#role); - this.#_message.appendChild(this.#_role); + this.#_message.appendChild(_wrapper); + + // message role + const _role = make("div"); + + _role.textContent = this.#role; + + _wrapper.appendChild(_role); + + // message tags + this.#_tags = make("div", "tags"); + + _wrapper.appendChild(this.#_tags); // message reasoning (wrapper) const _reasoning = make("div", "reasoning"); @@ -233,8 +252,17 @@ } #render(only = false, noScroll = false) { - if (!only || only === "role") { - this.#_role.textContent = this.#role; + if (!only || only === "tags") { + console.log(this.#tags); + this.#_tags.innerHTML = this.#tags + .map((tag) => `
`) + .join(""); + + this.#_message.classList.toggle("has-tags", this.#tags.length > 0); + } + + if (this.#error) { + return; } if (!only || only === "reasoning") { @@ -251,7 +279,13 @@ } if (!only || only === "text") { - this.#patch("text", this.#_text, this.#text, () => { + let text = this.#text; + + if (this.#tags.includes("json")) { + text = `\`\`\`json\n${text}\n\`\`\``; + } + + this.#patch("text", this.#_text, text, () => { noScroll || scroll(); }); @@ -262,23 +296,47 @@ #save() { storeValue( "messages", - messages.map((message) => message.getData(true)), + messages.map((message) => message.getData(true)).filter(Boolean), ); } - getData(includeReasoning = false) { + getData(full = false) { const data = { role: this.#role, text: this.#text, }; - if (this.#reasoning && includeReasoning) { + if (this.#reasoning && full) { data.reasoning = this.#reasoning; } + if (this.#error && full) { + data.error = this.#error; + } + + if (this.#tags.length && full) { + data.tags = this.#tags; + } + return data; } + addTag(tag) { + if (this.#tags.includes(tag)) { + return; + } + + this.#tags.push(tag); + + this.#render("tags"); + + if (tag === "json") { + this.#render("text"); + } + + this.#save(); + } + setState(state) { if (this.#state === state) { return; @@ -309,6 +367,20 @@ this.#save(); } + showError(error) { + this.#error = error; + + this.#_message.classList.add("errored"); + + const _err = make("div", "error"); + + _err.textContent = this.#error; + + this.#_text.appendChild(_err); + + this.#save(); + } + stopEdit() { if (!this.#editing) { return; @@ -379,7 +451,9 @@ const response = await fetch(url, options); if (!response.ok) { - throw new Error(response.statusText); + const err = await response.json(); + + throw new Error(err?.error || response.statusText); } const reader = response.body.getReader(), @@ -461,7 +535,7 @@ models[model.id] = model; } - dropdown($model); + dropdown($model, 4); return modelList; } @@ -474,13 +548,29 @@ $reasoningEffort.value = loadValue("reasoning-effort", "medium"); $reasoningTokens.value = loadValue("reasoning-tokens", 1024); + if (loadValue("json")) { + $json.click(); + } + + if (loadValue("search")) { + $search.click(); + } + if (loadValue("scrolling")) { $scrolling.click(); } - loadValue("messages", []).forEach( - (message) => new Message(message.role, message.reasoning, message.text), - ); + loadValue("messages", []).forEach((message) => { + const obj = new Message(message.role, message.reasoning, message.text); + + if (message.error) { + obj.showError(message.error); + } + + if (message.tags) { + message.tags.forEach((tag) => obj.addTag(tag)); + } + }); scroll(true); @@ -537,6 +627,8 @@ $reasoningEffort.parentNode.classList.add("none"); $reasoningTokens.parentNode.classList.add("none"); } + + $json.classList.toggle("none", !data?.tags.includes("json")); }); $prompt.addEventListener("change", () => { @@ -544,7 +636,15 @@ }); $temperature.addEventListener("input", () => { - storeValue("temperature", $temperature.value); + const value = $temperature.value, + temperature = parseFloat(value); + + storeValue("temperature", value); + + $temperature.classList.toggle( + "invalid", + Number.isNaN(temperature) || temperature < 0 || temperature > 2, + ); }); $reasoningEffort.addEventListener("change", () => { @@ -555,8 +655,32 @@ $reasoningTokens.parentNode.classList.toggle("none", !!effort); }); - $reasoningTokens.addEventListener("change", () => { - storeValue("reasoning-tokens", $reasoningTokens.value); + $reasoningTokens.addEventListener("input", () => { + const value = $reasoningTokens.value, + tokens = parseInt(value); + + storeValue("reasoning-tokens", value); + + $reasoningTokens.classList.toggle( + "invalid", + Number.isNaN(tokens) || tokens <= 0 || tokens > 1024 * 1024, + ); + }); + + $json.addEventListener("click", () => { + jsonMode = !jsonMode; + + storeValue("json", jsonMode); + + $json.classList.toggle("on", jsonMode); + }); + + $search.addEventListener("click", () => { + searchTool = !searchTool; + + storeValue("search", searchTool); + + $search.classList.toggle("on", searchTool); }); $message.addEventListener("input", () => { @@ -589,6 +713,8 @@ if (autoScrolling) { $scrolling.title = "Turn off auto-scrolling"; $scrolling.classList.add("on"); + + scroll(); } else { $scrolling.title = "Turn on auto-scrolling"; $scrolling.classList.remove("on"); @@ -612,7 +738,7 @@ const temperature = parseFloat($temperature.value); - if (Number.isNaN(temperature) || temperature < 0 || temperature > 1) { + if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) { return; } @@ -640,6 +766,8 @@ effort: effort, tokens: tokens || 0, }, + json: jsonMode, + search: searchTool, messages: messages.map((message) => message.getData()), }; @@ -647,6 +775,14 @@ message.setState("waiting"); + if (jsonMode) { + message.addTag("json"); + } + + if (searchTool) { + message.addTag("search"); + } + stream( "/-/chat", { @@ -678,6 +814,10 @@ message.setState("receiving"); message.addText(chunk.text); + break; + case "error": + message.showError(chunk.text); + break; } }, diff --git a/static/js/dropdown.js b/static/js/dropdown.js index 99c6d21..c394e51 100644 --- a/static/js/dropdown.js +++ b/static/js/dropdown.js @@ -5,13 +5,15 @@ #_selected; #_search; + #maxTags = false; #search = false; #selected = false; #options = []; - constructor(el) { + constructor(el, maxTags = false) { this.#_select = el; + this.#maxTags = maxTags; this.#search = "searchable" in el.dataset; this.#_select.querySelectorAll("option").forEach((option) => { @@ -101,15 +103,23 @@ _opt.appendChild(_label); // option tags (optional) - if (option.tags?.length) { + const tags = option.tags; + + if (option.tags.length) { const _tags = make("div", "tags"); - for (const tag of option.tags) { - const _tag = make("div", "tag", tag); + _tags.title = `${this.#maxTags ? `${tags.length}/${this.#maxTags}: ` : ""}${tags.join(", ")}`; - _tag.title = tag; + if (this.#maxTags && tags.length >= this.#maxTags) { + const _all = make("div", "tag", "all"); - _tags.appendChild(_tag); + _tags.appendChild(_all); + } else { + for (const tag of tags) { + const _tag = make("div", "tag", tag); + + _tags.appendChild(_tag); + } } _opt.appendChild(_tags); @@ -220,5 +230,5 @@ }); }); - window.dropdown = (el) => new Dropdown(el); + window.dropdown = (el, maxTags = false) => new Dropdown(el, maxTags); })(); diff --git a/static/js/lib.js b/static/js/lib.js index 2c1f222..1469f6a 100644 --- a/static/js/lib.js +++ b/static/js/lib.js @@ -1,5 +1,7 @@ -function storeValue(key, value) { - if (!value) { +/** biome-ignore-all lint/correctness/noUnusedVariables: utility */ + +function storeValue(key, value = false) { + if (value === null || value === undefined || value === false) { localStorage.removeItem(key); return; @@ -11,14 +13,14 @@ function storeValue(key, value) { function loadValue(key, fallback = false) { const raw = localStorage.getItem(key); - if (!raw) { + if (raw === null) { return fallback; } try { const value = JSON.parse(raw); - if (!value) { + if (value === null) { throw new Error("no value"); } diff --git a/static/css/catppuccin.css b/static/lib/catppuccin.min.css similarity index 100% rename from static/css/catppuccin.css rename to static/lib/catppuccin.min.css