json & web search
@@ -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
|
- Search field with fuzzy matching to quickly find models
|
||||||
- Models are listed newest -> oldest
|
- Models are listed newest -> oldest
|
||||||
- Reasoning effort control
|
- Reasoning effort control
|
||||||
|
- Web search tool
|
||||||
|
- Structured JSON output
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- Retry button for assistant messages
|
- Retry button for assistant messages
|
||||||
- Import and export of chats
|
- Import and export of chats
|
||||||
- Web search tool
|
|
||||||
- Image and file attachments
|
- Image and file attachments
|
||||||
|
|
||||||
## Built With
|
## Built With
|
||||||
|
14
chat.go
@@ -25,6 +25,8 @@ type Request struct {
|
|||||||
Prompt string `json:"prompt"`
|
Prompt string `json:"prompt"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
|
JSON bool `json:"json"`
|
||||||
|
Search bool `json:"search"`
|
||||||
Reasoning Reasoning `json:"reasoning"`
|
Reasoning Reasoning `json:"reasoning"`
|
||||||
Messages []Message `json:"messages"`
|
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)
|
prompt, err := BuildPrompt(r.Prompt, model)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
2
main.go
@@ -42,7 +42,7 @@ func cache(next http.Handler) http.Handler {
|
|||||||
path := strings.ToLower(r.URL.Path)
|
path := strings.ToLower(r.URL.Path)
|
||||||
ext := filepath.Ext(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")
|
w.Header().Set("Cache-Control", "public, max-age=3024000, immutable")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
22
models.go
@@ -15,6 +15,7 @@ type Model struct {
|
|||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
|
||||||
Reasoning bool `json:"-"`
|
Reasoning bool `json:"-"`
|
||||||
|
JSON bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var ModelMap = make(map[string]*Model)
|
var ModelMap = make(map[string]*Model)
|
||||||
@@ -40,7 +41,7 @@ func LoadModels() ([]*Model, error) {
|
|||||||
name = name[index+2:]
|
name = name[index+2:]
|
||||||
}
|
}
|
||||||
|
|
||||||
tags, reasoning := GetModelTags(model)
|
tags, reasoning, json := GetModelTags(model)
|
||||||
|
|
||||||
m := &Model{
|
m := &Model{
|
||||||
ID: model.ID,
|
ID: model.ID,
|
||||||
@@ -49,6 +50,7 @@ func LoadModels() ([]*Model, error) {
|
|||||||
Tags: tags,
|
Tags: tags,
|
||||||
|
|
||||||
Reasoning: reasoning,
|
Reasoning: reasoning,
|
||||||
|
JSON: json,
|
||||||
}
|
}
|
||||||
|
|
||||||
models[index] = m
|
models[index] = m
|
||||||
@@ -59,19 +61,25 @@ func LoadModels() ([]*Model, error) {
|
|||||||
return models, nil
|
return models, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetModelTags(model openrouter.Model) ([]string, bool) {
|
func GetModelTags(model openrouter.Model) ([]string, bool, bool) {
|
||||||
var (
|
var (
|
||||||
reasoning bool
|
reasoning bool
|
||||||
|
json bool
|
||||||
tags []string
|
tags []string
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, parameter := range model.SupportedParameters {
|
for _, parameter := range model.SupportedParameters {
|
||||||
if parameter == "reasoning" {
|
switch parameter {
|
||||||
|
case "reasoning":
|
||||||
reasoning = true
|
reasoning = true
|
||||||
}
|
|
||||||
|
|
||||||
if parameter == "reasoning" || parameter == "tools" {
|
tags = append(tags, "reasoning")
|
||||||
tags = append(tags, parameter)
|
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)
|
sort.Strings(tags)
|
||||||
|
|
||||||
return tags, reasoning
|
return tags, reasoning, json
|
||||||
}
|
}
|
||||||
|
@@ -137,21 +137,43 @@ body {
|
|||||||
|
|
||||||
.message .role {
|
.message .role {
|
||||||
position: absolute;
|
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-family: "Comic Code", ui-monospace, "Cascadia Mono", "Segoe UI Mono", "Ubuntu Mono", "Roboto Mono", Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 12px;
|
line-height: 12px;
|
||||||
top: 8px;
|
top: 6px;
|
||||||
left: 6px;
|
left: 6px;
|
||||||
padding-left: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#messages .message .role::before {
|
.message .tags::before {
|
||||||
content: "";
|
content: "";
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -1px;
|
top: 7px;
|
||||||
left: 0;
|
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 {
|
.message.user .role::before {
|
||||||
@@ -187,15 +209,28 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .reasoning,
|
#messages .message .reasoning,
|
||||||
.message div.text {
|
#messages .message div.text {
|
||||||
background: #24273a;
|
background: #24273a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message textarea.text {
|
#messages .message textarea.text {
|
||||||
background: #181926;
|
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 {
|
.reasoning-text pre {
|
||||||
background: #1b1d2a;
|
background: #1b1d2a;
|
||||||
}
|
}
|
||||||
@@ -387,14 +422,18 @@ select {
|
|||||||
.reasoning .toggle::before,
|
.reasoning .toggle::before,
|
||||||
.reasoning .toggle::after,
|
.reasoning .toggle::after,
|
||||||
#bottom,
|
#bottom,
|
||||||
.message .role::before,
|
#messages .message .role::before,
|
||||||
|
#messages .message .tag-json,
|
||||||
|
#messages .message .tag-search,
|
||||||
|
#json,
|
||||||
|
#search,
|
||||||
#scrolling,
|
#scrolling,
|
||||||
#clear,
|
#clear,
|
||||||
#add,
|
#add,
|
||||||
#send,
|
#send,
|
||||||
.pre-copy,
|
.pre-copy,
|
||||||
.message .copy,
|
#messages .message .copy,
|
||||||
.message .edit,
|
#messages .message .edit,
|
||||||
.message .delete,
|
.message .delete,
|
||||||
#chat .option label {
|
#chat .option label {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -405,8 +444,20 @@ select {
|
|||||||
background-repeat: no-repeat;
|
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,
|
.pre-copy,
|
||||||
.message .copy {
|
#messages .message .copy {
|
||||||
background-image: url(icons/copy.svg);
|
background-image: url(icons/copy.svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,11 +466,11 @@ select {
|
|||||||
background-image: url(icons/check.svg);
|
background-image: url(icons/check.svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .edit {
|
#messages .message .edit {
|
||||||
background-image: url(icons/edit.svg);
|
background-image: url(icons/edit.svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.editing .edit {
|
#messages .message.editing .edit {
|
||||||
background-image: url(icons/save.svg);
|
background-image: url(icons/save.svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,8 +545,14 @@ label[for="reasoning-tokens"] {
|
|||||||
background-image: url(icons/add.svg);
|
background-image: url(icons/add.svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#scrolling {
|
#json,
|
||||||
|
#search,
|
||||||
|
#scrolling,
|
||||||
|
#clear {
|
||||||
position: unset !important;
|
position: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scrolling {
|
||||||
background-image: url(icons/screen-slash.svg);
|
background-image: url(icons/screen-slash.svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,8 +560,23 @@ label[for="reasoning-tokens"] {
|
|||||||
background-image: url(icons/screen.svg);
|
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 {
|
#clear {
|
||||||
position: unset !important;
|
|
||||||
background-image: url(icons/trash.svg);
|
background-image: url(icons/trash.svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -106,6 +106,14 @@
|
|||||||
background-image: url(icons/tags/vision.svg)
|
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 {
|
.dropdown .search {
|
||||||
background: #2a2e41;
|
background: #2a2e41;
|
||||||
border-top: 2px solid #494d64;
|
border-top: 2px solid #494d64;
|
||||||
|
7
static/css/icons/json-mode.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 602 B |
7
static/css/icons/json-off.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 602 B |
7
static/css/icons/json-on.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 602 B |
7
static/css/icons/search-off.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 716 B |
7
static/css/icons/search-on.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 716 B |
7
static/css/icons/search-tool.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 716 B |
7
static/css/icons/tags/all.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 928 B |
7
static/css/icons/tags/json.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 602 B |
@@ -8,7 +8,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<link href="css/catppuccin.css" rel="stylesheet" />
|
<link href="lib/catppuccin.min.css" rel="stylesheet" />
|
||||||
<link href="css/dropdown.css" rel="stylesheet" />
|
<link href="css/dropdown.css" rel="stylesheet" />
|
||||||
<link href="css/markdown.css" rel="stylesheet" />
|
<link href="css/markdown.css" rel="stylesheet" />
|
||||||
<link href="css/chat.css" rel="stylesheet" />
|
<link href="css/chat.css" rel="stylesheet" />
|
||||||
@@ -63,6 +63,10 @@
|
|||||||
<label for="reasoning-tokens" title="Maximum amount of reasoning tokens"></label>
|
<label for="reasoning-tokens" title="Maximum amount of reasoning tokens"></label>
|
||||||
<input id="reasoning-tokens" type="number" min="2" max="1" step="0.05" value="0.85" />
|
<input id="reasoning-tokens" type="number" min="2" max="1" step="0.05" value="0.85" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="option group">
|
||||||
|
<button id="json" class="none" title="Turn on structured json output"></button>
|
||||||
|
<button id="search" title="Turn on web-search (openrouter built-in)"></button>
|
||||||
|
</div>
|
||||||
<div class="option">
|
<div class="option">
|
||||||
<button id="scrolling" title="Turn on auto-scrolling"></button>
|
<button id="scrolling" title="Turn on auto-scrolling"></button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -9,6 +9,8 @@
|
|||||||
$temperature = document.getElementById("temperature"),
|
$temperature = document.getElementById("temperature"),
|
||||||
$reasoningEffort = document.getElementById("reasoning-effort"),
|
$reasoningEffort = document.getElementById("reasoning-effort"),
|
||||||
$reasoningTokens = document.getElementById("reasoning-tokens"),
|
$reasoningTokens = document.getElementById("reasoning-tokens"),
|
||||||
|
$json = document.getElementById("json"),
|
||||||
|
$search = document.getElementById("search"),
|
||||||
$add = document.getElementById("add"),
|
$add = document.getElementById("add"),
|
||||||
$send = document.getElementById("send"),
|
$send = document.getElementById("send"),
|
||||||
$scrolling = document.getElementById("scrolling"),
|
$scrolling = document.getElementById("scrolling"),
|
||||||
@@ -18,6 +20,8 @@
|
|||||||
models = {};
|
models = {};
|
||||||
|
|
||||||
let autoScrolling = false,
|
let autoScrolling = false,
|
||||||
|
jsonMode = false,
|
||||||
|
searchTool = false,
|
||||||
interacted = false;
|
interacted = false;
|
||||||
|
|
||||||
function scroll(force = false) {
|
function scroll(force = false) {
|
||||||
@@ -27,7 +31,7 @@
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
$messages.scroll({
|
$messages.scroll({
|
||||||
top: $messages.scrollHeight + 200,
|
top: $messages.scrollHeight,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -39,6 +43,9 @@
|
|||||||
#reasoning;
|
#reasoning;
|
||||||
#text;
|
#text;
|
||||||
|
|
||||||
|
#tags = [];
|
||||||
|
#error = false;
|
||||||
|
|
||||||
#editing = false;
|
#editing = false;
|
||||||
#expanded = false;
|
#expanded = false;
|
||||||
#state = false;
|
#state = false;
|
||||||
@@ -48,7 +55,7 @@
|
|||||||
#patching = {};
|
#patching = {};
|
||||||
|
|
||||||
#_message;
|
#_message;
|
||||||
#_role;
|
#_tags;
|
||||||
#_reasoning;
|
#_reasoning;
|
||||||
#_text;
|
#_text;
|
||||||
#_edit;
|
#_edit;
|
||||||
@@ -75,10 +82,22 @@
|
|||||||
// main message div
|
// main message div
|
||||||
this.#_message = make("div", "message", this.#role);
|
this.#_message = make("div", "message", this.#role);
|
||||||
|
|
||||||
// message role
|
// message role (wrapper)
|
||||||
this.#_role = make("div", "role", this.#role);
|
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)
|
// message reasoning (wrapper)
|
||||||
const _reasoning = make("div", "reasoning");
|
const _reasoning = make("div", "reasoning");
|
||||||
@@ -233,8 +252,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#render(only = false, noScroll = false) {
|
#render(only = false, noScroll = false) {
|
||||||
if (!only || only === "role") {
|
if (!only || only === "tags") {
|
||||||
this.#_role.textContent = this.#role;
|
console.log(this.#tags);
|
||||||
|
this.#_tags.innerHTML = this.#tags
|
||||||
|
.map((tag) => `<div class="tag-${tag}" title="${tag}"></div>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
this.#_message.classList.toggle("has-tags", this.#tags.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#error) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!only || only === "reasoning") {
|
if (!only || only === "reasoning") {
|
||||||
@@ -251,7 +279,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!only || only === "text") {
|
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();
|
noScroll || scroll();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -262,23 +296,47 @@
|
|||||||
#save() {
|
#save() {
|
||||||
storeValue(
|
storeValue(
|
||||||
"messages",
|
"messages",
|
||||||
messages.map((message) => message.getData(true)),
|
messages.map((message) => message.getData(true)).filter(Boolean),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getData(includeReasoning = false) {
|
getData(full = false) {
|
||||||
const data = {
|
const data = {
|
||||||
role: this.#role,
|
role: this.#role,
|
||||||
text: this.#text,
|
text: this.#text,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.#reasoning && includeReasoning) {
|
if (this.#reasoning && full) {
|
||||||
data.reasoning = this.#reasoning;
|
data.reasoning = this.#reasoning;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.#error && full) {
|
||||||
|
data.error = this.#error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#tags.length && full) {
|
||||||
|
data.tags = this.#tags;
|
||||||
|
}
|
||||||
|
|
||||||
return data;
|
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) {
|
setState(state) {
|
||||||
if (this.#state === state) {
|
if (this.#state === state) {
|
||||||
return;
|
return;
|
||||||
@@ -309,6 +367,20 @@
|
|||||||
this.#save();
|
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() {
|
stopEdit() {
|
||||||
if (!this.#editing) {
|
if (!this.#editing) {
|
||||||
return;
|
return;
|
||||||
@@ -379,7 +451,9 @@
|
|||||||
const response = await fetch(url, options);
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
if (!response.ok) {
|
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(),
|
const reader = response.body.getReader(),
|
||||||
@@ -461,7 +535,7 @@
|
|||||||
models[model.id] = model;
|
models[model.id] = model;
|
||||||
}
|
}
|
||||||
|
|
||||||
dropdown($model);
|
dropdown($model, 4);
|
||||||
|
|
||||||
return modelList;
|
return modelList;
|
||||||
}
|
}
|
||||||
@@ -474,13 +548,29 @@
|
|||||||
$reasoningEffort.value = loadValue("reasoning-effort", "medium");
|
$reasoningEffort.value = loadValue("reasoning-effort", "medium");
|
||||||
$reasoningTokens.value = loadValue("reasoning-tokens", 1024);
|
$reasoningTokens.value = loadValue("reasoning-tokens", 1024);
|
||||||
|
|
||||||
|
if (loadValue("json")) {
|
||||||
|
$json.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadValue("search")) {
|
||||||
|
$search.click();
|
||||||
|
}
|
||||||
|
|
||||||
if (loadValue("scrolling")) {
|
if (loadValue("scrolling")) {
|
||||||
$scrolling.click();
|
$scrolling.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadValue("messages", []).forEach(
|
loadValue("messages", []).forEach((message) => {
|
||||||
(message) => new Message(message.role, message.reasoning, message.text),
|
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);
|
scroll(true);
|
||||||
|
|
||||||
@@ -537,6 +627,8 @@
|
|||||||
$reasoningEffort.parentNode.classList.add("none");
|
$reasoningEffort.parentNode.classList.add("none");
|
||||||
$reasoningTokens.parentNode.classList.add("none");
|
$reasoningTokens.parentNode.classList.add("none");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$json.classList.toggle("none", !data?.tags.includes("json"));
|
||||||
});
|
});
|
||||||
|
|
||||||
$prompt.addEventListener("change", () => {
|
$prompt.addEventListener("change", () => {
|
||||||
@@ -544,7 +636,15 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$temperature.addEventListener("input", () => {
|
$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", () => {
|
$reasoningEffort.addEventListener("change", () => {
|
||||||
@@ -555,8 +655,32 @@
|
|||||||
$reasoningTokens.parentNode.classList.toggle("none", !!effort);
|
$reasoningTokens.parentNode.classList.toggle("none", !!effort);
|
||||||
});
|
});
|
||||||
|
|
||||||
$reasoningTokens.addEventListener("change", () => {
|
$reasoningTokens.addEventListener("input", () => {
|
||||||
storeValue("reasoning-tokens", $reasoningTokens.value);
|
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", () => {
|
$message.addEventListener("input", () => {
|
||||||
@@ -589,6 +713,8 @@
|
|||||||
if (autoScrolling) {
|
if (autoScrolling) {
|
||||||
$scrolling.title = "Turn off auto-scrolling";
|
$scrolling.title = "Turn off auto-scrolling";
|
||||||
$scrolling.classList.add("on");
|
$scrolling.classList.add("on");
|
||||||
|
|
||||||
|
scroll();
|
||||||
} else {
|
} else {
|
||||||
$scrolling.title = "Turn on auto-scrolling";
|
$scrolling.title = "Turn on auto-scrolling";
|
||||||
$scrolling.classList.remove("on");
|
$scrolling.classList.remove("on");
|
||||||
@@ -612,7 +738,7 @@
|
|||||||
|
|
||||||
const temperature = parseFloat($temperature.value);
|
const temperature = parseFloat($temperature.value);
|
||||||
|
|
||||||
if (Number.isNaN(temperature) || temperature < 0 || temperature > 1) {
|
if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,6 +766,8 @@
|
|||||||
effort: effort,
|
effort: effort,
|
||||||
tokens: tokens || 0,
|
tokens: tokens || 0,
|
||||||
},
|
},
|
||||||
|
json: jsonMode,
|
||||||
|
search: searchTool,
|
||||||
messages: messages.map((message) => message.getData()),
|
messages: messages.map((message) => message.getData()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -647,6 +775,14 @@
|
|||||||
|
|
||||||
message.setState("waiting");
|
message.setState("waiting");
|
||||||
|
|
||||||
|
if (jsonMode) {
|
||||||
|
message.addTag("json");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchTool) {
|
||||||
|
message.addTag("search");
|
||||||
|
}
|
||||||
|
|
||||||
stream(
|
stream(
|
||||||
"/-/chat",
|
"/-/chat",
|
||||||
{
|
{
|
||||||
@@ -678,6 +814,10 @@
|
|||||||
message.setState("receiving");
|
message.setState("receiving");
|
||||||
message.addText(chunk.text);
|
message.addText(chunk.text);
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
message.showError(chunk.text);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@@ -5,13 +5,15 @@
|
|||||||
#_selected;
|
#_selected;
|
||||||
#_search;
|
#_search;
|
||||||
|
|
||||||
|
#maxTags = false;
|
||||||
#search = false;
|
#search = false;
|
||||||
#selected = false;
|
#selected = false;
|
||||||
#options = [];
|
#options = [];
|
||||||
|
|
||||||
constructor(el) {
|
constructor(el, maxTags = false) {
|
||||||
this.#_select = el;
|
this.#_select = el;
|
||||||
|
|
||||||
|
this.#maxTags = maxTags;
|
||||||
this.#search = "searchable" in el.dataset;
|
this.#search = "searchable" in el.dataset;
|
||||||
|
|
||||||
this.#_select.querySelectorAll("option").forEach((option) => {
|
this.#_select.querySelectorAll("option").forEach((option) => {
|
||||||
@@ -101,15 +103,23 @@
|
|||||||
_opt.appendChild(_label);
|
_opt.appendChild(_label);
|
||||||
|
|
||||||
// option tags (optional)
|
// option tags (optional)
|
||||||
if (option.tags?.length) {
|
const tags = option.tags;
|
||||||
|
|
||||||
|
if (option.tags.length) {
|
||||||
const _tags = make("div", "tags");
|
const _tags = make("div", "tags");
|
||||||
|
|
||||||
for (const tag of option.tags) {
|
_tags.title = `${this.#maxTags ? `${tags.length}/${this.#maxTags}: ` : ""}${tags.join(", ")}`;
|
||||||
const _tag = make("div", "tag", tag);
|
|
||||||
|
|
||||||
_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);
|
_opt.appendChild(_tags);
|
||||||
@@ -220,5 +230,5 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.dropdown = (el) => new Dropdown(el);
|
window.dropdown = (el, maxTags = false) => new Dropdown(el, maxTags);
|
||||||
})();
|
})();
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
function storeValue(key, value) {
|
/** biome-ignore-all lint/correctness/noUnusedVariables: utility */
|
||||||
if (!value) {
|
|
||||||
|
function storeValue(key, value = false) {
|
||||||
|
if (value === null || value === undefined || value === false) {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -11,14 +13,14 @@ function storeValue(key, value) {
|
|||||||
function loadValue(key, fallback = false) {
|
function loadValue(key, fallback = false) {
|
||||||
const raw = localStorage.getItem(key);
|
const raw = localStorage.getItem(key);
|
||||||
|
|
||||||
if (!raw) {
|
if (raw === null) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(raw);
|
const value = JSON.parse(raw);
|
||||||
|
|
||||||
if (!value) {
|
if (value === null) {
|
||||||
throw new Error("no value");
|
throw new Error("no value");
|
||||||
}
|
}
|
||||||
|
|
||||||
|