diff --git a/.github/chat.png b/.github/chat.png index 98890f4..b5af642 100644 Binary files a/.github/chat.png and b/.github/chat.png differ diff --git a/README.md b/README.md index 33c16e4..1733750 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,10 @@ whiskr is a private, self-hosted web chat interface for interacting with AI mode - Tags indicate if a model supports **tools**, **vision**, or **reasoning** - Search field with fuzzy matching to quickly find models - Models are listed newest -> oldest +- Reasoning effort control ## TODO -- Reasoning effort control - Retry button for assistant messages - Import and export of chats - Web search tool diff --git a/chat.go b/chat.go index 6a3397b..5d4bc8c 100644 --- a/chat.go +++ b/chat.go @@ -16,10 +16,16 @@ type Message struct { Text string `json:"text"` } +type Reasoning struct { + Effort string `json:"effort"` + Tokens int `json:"tokens"` +} + type Request struct { Prompt string `json:"prompt"` Model string `json:"model"` Temperature float64 `json:"temperature"` + Reasoning Reasoning `json:"reasoning"` Messages []Message `json:"messages"` } @@ -39,6 +45,21 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { request.Temperature = float32(r.Temperature) + if model.Reasoning { + request.Reasoning = &openrouter.ChatCompletionReasoning{} + + switch r.Reasoning.Effort { + case "high", "medium", "low": + request.Reasoning.Effort = &r.Reasoning.Effort + default: + if r.Reasoning.Tokens <= 0 || r.Reasoning.Tokens > 1024*1024 { + return nil, fmt.Errorf("invalid reasoning tokens (1-1048576): %d", r.Reasoning.Tokens) + } + + request.Reasoning.MaxTokens = &r.Reasoning.Tokens + } + } + prompt, err := BuildPrompt(r.Prompt, model) if err != nil { return nil, err @@ -61,12 +82,6 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { }) } - h := "high" - - request.Reasoning = &openrouter.ChatCompletionReasoning{ - Effort: &h, - } - return &request, nil } diff --git a/models.go b/models.go index e3f6daf..3e759e2 100644 --- a/models.go +++ b/models.go @@ -13,6 +13,8 @@ type Model struct { Name string `json:"name"` Description string `json:"description"` Tags []string `json:"tags,omitempty"` + + Reasoning bool `json:"-"` } var ModelMap = make(map[string]*Model) @@ -38,11 +40,15 @@ func LoadModels() ([]*Model, error) { name = name[index+2:] } + tags, reasoning := GetModelTags(model) + m := &Model{ ID: model.ID, Name: name, Description: model.Description, - Tags: GetModelTags(model), + Tags: tags, + + Reasoning: reasoning, } models[index] = m @@ -53,10 +59,17 @@ func LoadModels() ([]*Model, error) { return models, nil } -func GetModelTags(model openrouter.Model) []string { - var tags []string +func GetModelTags(model openrouter.Model) ([]string, bool) { + var ( + reasoning bool + tags []string + ) for _, parameter := range model.SupportedParameters { + if parameter == "reasoning" { + reasoning = true + } + if parameter == "reasoning" || parameter == "tools" { tags = append(tags, parameter) } @@ -70,5 +83,5 @@ func GetModelTags(model openrouter.Model) []string { sort.Strings(tags) - return tags + return tags, reasoning } diff --git a/static/css/chat.css b/static/css/chat.css index e3185a2..b40afbd 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -87,6 +87,10 @@ body { pointer-events: none !important; } +.none { + display: none !important; +} + #messages { display: flex; flex-direction: column; @@ -432,9 +436,10 @@ select { padding: 2px 4px; } +#reasoning-tokens, #temperature { appearance: textfield; - width: 50px; + width: 48px; padding: 2px 4px; text-align: right; } @@ -455,6 +460,14 @@ label[for="temperature"] { background-image: url(icons/temperature.svg); } +label[for="reasoning-effort"] { + background-image: url(icons/reasoning.svg); +} + +label[for="reasoning-tokens"] { + background-image: url(icons/amount.svg); +} + #bottom { top: -38px; left: 50%; diff --git a/static/css/icons/amount.svg b/static/css/icons/amount.svg new file mode 100644 index 0000000..6713d4b --- /dev/null +++ b/static/css/icons/amount.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/index.html b/static/index.html index 914ddab..bce0e4c 100644 --- a/static/index.html +++ b/static/index.html @@ -23,12 +23,12 @@ - - + +
- +
- +
+
+ + +
+
+ + +
diff --git a/static/js/chat.js b/static/js/chat.js index b793d3a..1eca2cd 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -7,12 +7,15 @@ $model = document.getElementById("model"), $prompt = document.getElementById("prompt"), $temperature = document.getElementById("temperature"), + $reasoningEffort = document.getElementById("reasoning-effort"), + $reasoningTokens = document.getElementById("reasoning-tokens"), $add = document.getElementById("add"), $send = document.getElementById("send"), $scrolling = document.getElementById("scrolling"), $clear = document.getElementById("clear"); - const messages = []; + const messages = [], + models = {}; let autoScrolling = false, interacted = false; @@ -434,9 +437,9 @@ } async function loadModels() { - const models = await json("/-/models"); + const modelList = await json("/-/models"); - if (!models) { + if (!modelList) { alert("Failed to load models."); return []; @@ -444,7 +447,7 @@ $model.innerHTML = ""; - for (const model of models) { + for (const model of modelList) { const el = document.createElement("option"); el.value = model.id; @@ -454,18 +457,22 @@ el.dataset.tags = (model.tags || []).join(","); $model.appendChild(el); + + models[model.id] = model; } dropdown($model); - return models; + return modelList; } - function restore(models) { + function restore(modelList) { $role.value = loadValue("role", "user"); - $model.value = loadValue("model", models[0].id); + $model.value = loadValue("model", modelList[0].id); $prompt.value = loadValue("prompt", "normal"); $temperature.value = loadValue("temperature", 0.85); + $reasoningEffort.value = loadValue("reasoning-effort", "medium"); + $reasoningTokens.value = loadValue("reasoning-tokens", 1024); if (loadValue("scrolling")) { $scrolling.click(); @@ -512,7 +519,21 @@ }); $model.addEventListener("change", () => { - storeValue("model", $model.value); + const model = $model.value, + data = model ? models[model] : null; + + storeValue("model", model); + + if (data?.tags.includes("reasoning")) { + $reasoningEffort.parentNode.classList.remove("none"); + $reasoningTokens.parentNode.classList.toggle( + "none", + !!$reasoningEffort.value, + ); + } else { + $reasoningEffort.parentNode.classList.add("none"); + $reasoningTokens.parentNode.classList.add("none"); + } }); $prompt.addEventListener("change", () => { @@ -523,6 +544,18 @@ storeValue("temperature", $temperature.value); }); + $reasoningEffort.addEventListener("change", () => { + const effort = $reasoningEffort.value; + + storeValue("reasoning-effort", effort); + + $reasoningTokens.parentNode.classList.toggle("none", !!effort); + }); + + $reasoningTokens.addEventListener("change", () => { + storeValue("reasoning-tokens", $reasoningTokens.value); + }); + $message.addEventListener("input", () => { storeValue("message", $message.value); }); @@ -570,12 +603,26 @@ return; } + if (!$temperature.value) { + $temperature.value = 0.85; + } + const temperature = parseFloat($temperature.value); if (Number.isNaN(temperature) || temperature < 0 || temperature > 1) { return; } + const effort = $reasoningEffort.value, + tokens = parseInt($reasoningTokens.value); + + if ( + !effort && + (Number.isNaN(tokens) || tokens <= 0 || tokens > 1024 * 1024) + ) { + return; + } + pushMessage(); controller = new AbortController(); @@ -586,6 +633,10 @@ prompt: $prompt.value, model: $model.value, temperature: temperature, + reasoning: { + effort: effort, + tokens: tokens || 0, + }, messages: messages.map((message) => message.getData()), }; @@ -644,6 +695,7 @@ dropdown($role); dropdown($prompt); + dropdown($reasoningEffort); loadModels().then(restore); })();