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