From 4b40053ce9ee60c8535942814a417eabd7186754 Mon Sep 17 00:00:00 2001 From: Laura Date: Tue, 5 Aug 2025 03:56:23 +0200 Subject: [PATCH] initial commit --- .example.env | 2 + .gitignore | 1 + chat.go | 131 +++++++++++ env.go | 18 ++ go.mod | 21 ++ go.sum | 44 ++++ http.go | 13 ++ main.go | 36 +++ models.go | 38 ++++ openrouter.go | 22 ++ static/chat.css | 326 +++++++++++++++++++++++++++ static/chat.js | 422 +++++++++++++++++++++++++++++++++++ static/icons/add.svg | 7 + static/icons/assistant.svg | 7 + static/icons/delete.svg | 7 + static/icons/down.svg | 7 + static/icons/edit.svg | 7 + static/icons/model.svg | 7 + static/icons/save.svg | 7 + static/icons/send.svg | 7 + static/icons/stop.svg | 7 + static/icons/system.svg | 7 + static/icons/temperature.svg | 7 + static/icons/trash.svg | 7 + static/icons/user.svg | 7 + static/index.html | 52 +++++ stream.go | 70 ++++++ 27 files changed, 1287 insertions(+) create mode 100644 .example.env create mode 100644 .gitignore create mode 100644 chat.go create mode 100644 env.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 http.go create mode 100644 main.go create mode 100644 models.go create mode 100644 openrouter.go create mode 100644 static/chat.css create mode 100644 static/chat.js create mode 100644 static/icons/add.svg create mode 100644 static/icons/assistant.svg create mode 100644 static/icons/delete.svg create mode 100644 static/icons/down.svg create mode 100644 static/icons/edit.svg create mode 100644 static/icons/model.svg create mode 100644 static/icons/save.svg create mode 100644 static/icons/send.svg create mode 100644 static/icons/stop.svg create mode 100644 static/icons/system.svg create mode 100644 static/icons/temperature.svg create mode 100644 static/icons/trash.svg create mode 100644 static/icons/user.svg create mode 100644 static/index.html create mode 100644 stream.go diff --git a/.example.env b/.example.env new file mode 100644 index 0000000..1d98b8d --- /dev/null +++ b/.example.env @@ -0,0 +1,2 @@ +# Your openrouter.ai token +OPENROUTER_TOKEN = "" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/chat.go b/chat.go new file mode 100644 index 0000000..88d791f --- /dev/null +++ b/chat.go @@ -0,0 +1,131 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/revrost/go-openrouter" +) + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type Request struct { + Model string `json:"model"` + Temperature float64 `json:"temperature"` + Messages []Message `json:"messages"` +} + +func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) { + var request openrouter.ChatCompletionRequest + + if _, ok := ModelMap[r.Model]; !ok { + return nil, fmt.Errorf("unknown model: %q", r.Model) + } + + request.Model = r.Model + + if r.Temperature < 0 || r.Temperature > 1 { + return nil, fmt.Errorf("invalid temperature (0-1): %f", r.Temperature) + } + + request.Temperature = float32(r.Temperature) + + 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) + } + + request.Messages = append(request.Messages, openrouter.ChatCompletionMessage{ + Role: message.Role, + Content: openrouter.Content{ + Text: message.Content, + }, + }) + } + + return &request, nil +} + +func HandleChat(w http.ResponseWriter, r *http.Request) { + var raw Request + + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + RespondJson(w, http.StatusBadRequest, map[string]any{ + "error": err.Error(), + }) + + return + } + + request, err := raw.Parse() + if err != nil { + RespondJson(w, http.StatusBadRequest, map[string]any{ + "error": err.Error(), + }) + + return + } + + request.Stream = true + + ctx := r.Context() + + stream, err := OpenRouterStartStream(ctx, *request) + if err != nil { + RespondJson(w, http.StatusBadRequest, map[string]any{ + "error": err.Error(), + }) + + return + } + + defer stream.Close() + + response, err := NewStream(w) + if err != nil { + RespondJson(w, http.StatusBadRequest, map[string]any{ + "error": err.Error(), + }) + + return + } + + for { + chunk, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + return + } + + response.Send(ErrorChunk(err)) + + return + } + + if len(chunk.Choices) == 0 { + continue + } + + choice := chunk.Choices[0] + + if choice.FinishReason == openrouter.FinishReasonContentFilter { + response.Send(ErrorChunk(errors.New("stopped due to content_filter"))) + + return + } + + content := choice.Delta.Content + + if content != "" { + response.Send(TextChunk(content)) + } else if choice.Delta.Reasoning != nil { + response.Send(ReasoningChunk(*choice.Delta.Reasoning)) + } + } +} diff --git a/env.go b/env.go new file mode 100644 index 0000000..ebc8fb8 --- /dev/null +++ b/env.go @@ -0,0 +1,18 @@ +package main + +import ( + "errors" + "os" + + "github.com/joho/godotenv" +) + +var OpenRouterToken string + +func init() { + log.MustPanic(godotenv.Load()) + + if OpenRouterToken = os.Getenv("OPENROUTER_TOKEN"); OpenRouterToken == "" { + log.Panic(errors.New("missing openrouter token")) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..57a02f8 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module chat + +go 1.24.5 + +require ( + github.com/coalaura/logger v1.5.1 + github.com/go-chi/chi/v5 v5.2.2 + github.com/joho/godotenv v1.5.1 + github.com/revrost/go-openrouter v0.1.11-0.20250804020417-b3d94f4f6b46 +) + +require ( + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gookit/color v1.5.4 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b65fe48 --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/coalaura/logger v1.5.1 h1:2no4UP1HYOKQBasAol7RP81V0emJ2sfJIIoKOtrATqM= +github.com/coalaura/logger v1.5.1/go.mod h1:npioUhSPFmjxOmLzYbl9X0G6sdZgvuMikTlmc6VitWo= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/revrost/go-openrouter v0.1.11-0.20250804020417-b3d94f4f6b46 h1:Ai/eskFY6VN+0kARZEE9l3ccbwvGB9CQ6/gJfafHQs0= +github.com/revrost/go-openrouter v0.1.11-0.20250804020417-b3d94f4f6b46/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/http.go b/http.go new file mode 100644 index 0000000..1690335 --- /dev/null +++ b/http.go @@ -0,0 +1,13 @@ +package main + +import ( + "encoding/json" + "net/http" +) + +func RespondJson(w http.ResponseWriter, code int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + + json.NewEncoder(w).Encode(data) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0a77802 --- /dev/null +++ b/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "net/http" + + "github.com/coalaura/logger" + adapter "github.com/coalaura/logger/http" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +var log = logger.New().DetectTerminal().WithOptions(logger.Options{ + NoLevel: true, +}) + +func main() { + models, err := LoadModels() + log.MustPanic(err) + + r := chi.NewRouter() + + r.Use(middleware.Recoverer) + r.Use(adapter.Middleware(log)) + + fs := http.FileServer(http.Dir("./static")) + r.Handle("/*", http.StripPrefix("/", fs)) + + r.Get("/-/models", func(w http.ResponseWriter, r *http.Request) { + RespondJson(w, http.StatusOK, models) + }) + + r.Post("/-/chat", HandleChat) + + log.Debug("Listening at http://localhost:3443/") + http.ListenAndServe(":3443", r) +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..50cfb4d --- /dev/null +++ b/models.go @@ -0,0 +1,38 @@ +package main + +import "context" + +type Model struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + SupportedParameters []string `json:"supported_parameters,omitempty"` +} + +var ModelMap = make(map[string]*Model) + +func LoadModels() ([]*Model, error) { + client := OpenRouterClient() + + list, err := client.ListUserModels(context.Background()) + if err != nil { + return nil, err + } + + models := make([]*Model, len(list)) + + for index, model := range list { + m := &Model{ + ID: model.ID, + Name: model.Name, + Description: model.Description, + SupportedParameters: model.SupportedParameters, + } + + models[index] = m + + ModelMap[model.ID] = m + } + + return models, nil +} diff --git a/openrouter.go b/openrouter.go new file mode 100644 index 0000000..5df1551 --- /dev/null +++ b/openrouter.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + + "github.com/revrost/go-openrouter" +) + +func OpenRouterClient() *openrouter.Client { + return openrouter.NewClient(OpenRouterToken) +} + +func OpenRouterStartStream(ctx context.Context, request openrouter.ChatCompletionRequest) (*openrouter.ChatCompletionStream, error) { + client := OpenRouterClient() + + stream, err := client.CreateChatCompletionStream(ctx, request) + if err != nil { + return nil, err + } + + return stream, nil +} diff --git a/static/chat.css b/static/chat.css new file mode 100644 index 0000000..e7d1bc0 --- /dev/null +++ b/static/chat.css @@ -0,0 +1,326 @@ +* { + box-sizing: border-box; +} + +html, +body { + font-family: "Nata Sans", sans-serif; + font-size: 15px; + background: #181926; + color: #cad3f5; + height: 100%; + width: 100%; + margin: 0; +} + +body { + display: flex; + overflow: hidden; +} + +#page { + display: flex; + flex-direction: column; + gap: 25px; + background: #1e2030; + box-shadow: 0px 0px 4px 4px #1e2030; + margin: auto; + margin-top: 30px; + width: 100%; + max-width: 1100px; + height: calc(100% - 30px); + border-top-left-radius: 6px; + border-top-right-radius: 6px; +} + +.hidden { + opacity: 0 !important; + pointer-events: none !important; +} + +#messages { + display: flex; + flex-direction: column; + gap: 65px; + height: 100%; + overflow-y: auto; + padding: 14px 12px; +} + +#messages:empty::before { + content: "no messages"; + color: #a5adcb; + font-style: italic; + align-self: center; +} + +#message, +.message { + border: none; + box-shadow: 0px 0px 4px 4px #24273a; + border-radius: 6px; + background: #24273a; + font: inherit; + color: inherit; +} + +.message { + position: relative; + max-width: 700px; + min-width: 200px; + width: max-content; +} + +.message.user { + align-self: end; +} + +.message.system { + align-self: center; +} + +.message .role { + position: absolute; + line-height: 12px; + font-size: 12px; + top: 4px; + left: 4px; + padding-left: 20px; +} + +#messages .message .role::before { + content: ""; + width: 16px; + height: 16px; + position: absolute; + top: -1px; + left: 0; +} + +.message.user .role::before { + background-image: url(icons/user.svg); +} + +.message.system .role::before { + background-image: url(icons/system.svg); +} + +.message.assistant .role::before { + background-image: url(icons/assistant.svg); +} + +.message .text { + display: block; + background: transparent; + padding: 10px 12px; + padding-top: 28px; + white-space: pre-line; + width: 100%; +} + +.message:not(.editing) textarea.text, +.message.editing div.text { + display: none; +} + +textarea { + border: none; + resize: none; + outline: none; + color: inherit; + font: inherit; +} + +#chat { + display: flex; + position: relative; + justify-content: center; + padding: 0 12px; + height: 240px; +} + +#message { + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + width: 100%; + height: 100%; + padding: 14px 16px; +} + +.message .options { + display: flex; + gap: 4px; + position: absolute; + top: 4px; + right: 4px; + opacity: 0; + pointer-events: none; + transition: 150ms; +} + +.message:hover .options { + opacity: 1; + pointer-events: all; +} + +.message.waiting .options, +.message.reasoning .options, +.message.receiving .options { + display: none; +} + +.message .text::before { + font-style: italic; +} + +.message.waiting .text::before { + content: "waiting..."; +} + +.message.reasoning .text::before { + content: "reasoning..."; +} + +.message:empty.receiving .text::before { + content: "receiving..."; +} + +button { + background: transparent; + border: none; + color: inherit; + cursor: pointer; +} + +#chat button { + position: absolute; +} + +input, +select { + border: none; + background: #363a4f; + font: inherit; + color: inherit; + outline: none; +} + +#chat .options { + position: absolute; + bottom: 4px; + left: 12px; + width: max-content; + display: flex; + align-items: center; + gap: 12px; +} + +#chat .option { + display: flex; + align-items: center; + gap: 4px; +} + +#chat .option+.option::before { + content: ""; + display: block; + width: 2px; + height: 12px; + background: #5b6078; + margin-right: 4px; +} + +#bottom, +.message .role::before, +#clear, +#add, +#send, +.message .edit, +.message .delete, +#chat .option label { + display: block; + width: 20px; + height: 20px; + background-position: center; + background-size: contain; + background-repeat: no-repeat; +} + +.message .edit { + background-image: url(icons/edit.svg); +} + +.message.editing .edit { + background-image: url(icons/save.svg); +} + +.message .delete { + background-image: url(icons/delete.svg); +} + +#chat .option label { + background-size: 18px; +} + +#model { + width: 180px; + padding: 2px 4px; + text-align: right; +} + +#temperature { + appearance: textfield; + width: 50px; + padding: 2px 4px; + text-align: right; +} + +label[for="role"] { + background-image: url(icons/user.svg); +} + +label[for="model"] { + background-image: url(icons/model.svg); +} + +label[for="temperature"] { + background-image: url(icons/temperature.svg); +} + +#bottom { + top: -38px; + left: 50%; + transform: translateX(-50%); + width: 28px; + height: 28px; + background-image: url(icons/down.svg); + transition: 150ms; +} + +#add, +#send { + bottom: 4px; + right: 12px; + width: 28px; + height: 28px; + background-image: url(icons/send.svg); +} + +#add { + bottom: 4px; + right: 44px; + background-image: url(icons/add.svg); +} + +#clear { + position: unset !important; + background-image: url(icons/trash.svg); +} + +.completing #add { + display: none; +} + +.completing #send { + background-image: url(icons/stop.svg); +} \ No newline at end of file diff --git a/static/chat.js b/static/chat.js new file mode 100644 index 0000000..8caed7a --- /dev/null +++ b/static/chat.js @@ -0,0 +1,422 @@ +(() => { + const $messages = document.getElementById("messages"), + $chat = document.getElementById("chat"), + $message = document.getElementById("message"), + $bottom = document.getElementById("bottom"), + $role = document.getElementById("role"), + $model = document.getElementById("model"), + $temperature = document.getElementById("temperature"), + $add = document.getElementById("add"), + $send = document.getElementById("send"), + $clear = document.getElementById("clear"); + + let controller; + + async function json(url) { + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(response.statusText); + } + + return await response.json(); + } catch (err) { + console.error(err); + + return false; + } + } + + async function stream(url, options, callback) { + try { + const response = await fetch(url, options); + + if (!response.ok) { + throw new Error(response.statusText); + } + + const reader = response.body.getReader(), + decoder = new TextDecoder(); + + let buffer = ""; + + while (true) { + const { value, done } = await reader.read(); + + if (done) break; + + buffer += decoder.decode(value, { + stream: true, + }); + + while (true) { + const idx = buffer.indexOf("\n\n"); + + if (idx === -1) { + break; + } + + const frame = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 2); + + if (!frame) { + continue; + } + + try { + const chunk = JSON.parse(frame); + + if (!chunk) { + throw new Error("invalid chunk"); + } + + callback(chunk); + } catch (err) { + console.warn("bad frame", frame); + console.warn(err); + } + } + } + } catch (err) { + if (err.name !== "AbortError") { + callback({ + type: "error", + text: err.message, + }); + } + } finally { + callback(false); + } + } + + async function loadModels() { + const models = await json("/-/models"); + + if (!models) { + alert("Failed to load models."); + + return; + } + + models.sort((a, b) => a.name > b.name); + + $model.innerHTML = ""; + + for (const model of models) { + const el = document.createElement("option"); + + el.value = model.id; + el.textContent = model.name; + + $model.appendChild(el); + } + } + + function restore(models) { + $role.value = localStorage.getItem("role") || "user"; + $model.value = localStorage.getItem("model") || models[0].id; + $temperature.value = localStorage.getItem("temperature") || 0.85; + + try { + const messages = JSON.parse(localStorage.getItem("messages") || "[]"); + + messages.forEach(addMessage); + } 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(); + + if (!text) { + 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; + } + + $messages.addEventListener("scroll", () => { + const bottom = + $messages.scrollHeight - ($messages.scrollTop + $messages.offsetHeight); + + if (bottom >= 80) { + $bottom.classList.remove("hidden"); + } else { + $bottom.classList.add("hidden"); + } + }); + + $bottom.addEventListener("click", () => { + $messages.scroll({ + top: $messages.scrollHeight, + behavior: "smooth", + }); + }); + + $role.addEventListener("change", () => { + localStorage.setItem("role", $role.value); + }); + + $model.addEventListener("change", () => { + localStorage.setItem("model", $model.value); + }); + + $temperature.addEventListener("input", () => { + localStorage.setItem("temperature", $temperature.value); + }); + + $message.addEventListener("input", () => { + localStorage.setItem("message", $message.value); + }); + + $add.addEventListener("click", () => { + pushMessage(); + }); + + $clear.addEventListener("click", () => { + if (!confirm("Are you sure you want to delete all messages?")) { + return; + } + + $messages.innerHTML = ""; + + saveMessages(); + }); + + $send.addEventListener("click", () => { + if (controller) { + controller.abort(); + + return; + } + + const temperature = parseFloat($temperature.value); + + if (Number.isNaN(temperature) || temperature < 0 || temperature > 1) { + return; + } + + pushMessage(); + saveMessages(); + + controller = new AbortController(); + + $chat.classList.add("completing"); + + const body = { + model: $model.value, + temperature: temperature, + messages: buildMessages(), + }; + + const result = { + role: "assistant", + content: "", + }; + + const message = addMessage(result); + + message.state("waiting"); + + stream( + "/-/chat", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: controller.signal, + }, + (chunk) => { + if (!chunk) { + controller = null; + + saveMessages(); + + $chat.classList.remove("completing"); + + return; + } + + switch (chunk.type) { + case "reason": + message.state("reasoning"); + + break; + case "text": + result.content += chunk.text; + + message.state("receive"); + message.set(result.content); + + break; + } + + saveMessages(); + }, + ); + }); + + $message.addEventListener("keydown", (event) => { + if (!event.ctrlKey || event.key !== "Enter") { + return; + } + + $send.click(); + }); + + loadModels().then(restore); +})(); diff --git a/static/icons/add.svg b/static/icons/add.svg new file mode 100644 index 0000000..aeb8826 --- /dev/null +++ b/static/icons/add.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/icons/assistant.svg b/static/icons/assistant.svg new file mode 100644 index 0000000..ffae5a5 --- /dev/null +++ b/static/icons/assistant.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/icons/delete.svg b/static/icons/delete.svg new file mode 100644 index 0000000..ebdc19e --- /dev/null +++ b/static/icons/delete.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/icons/down.svg b/static/icons/down.svg new file mode 100644 index 0000000..cc2afb5 --- /dev/null +++ b/static/icons/down.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/icons/edit.svg b/static/icons/edit.svg new file mode 100644 index 0000000..096cfb3 --- /dev/null +++ b/static/icons/edit.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/icons/model.svg b/static/icons/model.svg new file mode 100644 index 0000000..994afdd --- /dev/null +++ b/static/icons/model.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/icons/save.svg b/static/icons/save.svg new file mode 100644 index 0000000..e946af7 --- /dev/null +++ b/static/icons/save.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/icons/send.svg b/static/icons/send.svg new file mode 100644 index 0000000..e172ef4 --- /dev/null +++ b/static/icons/send.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/icons/stop.svg b/static/icons/stop.svg new file mode 100644 index 0000000..175a031 --- /dev/null +++ b/static/icons/stop.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/icons/system.svg b/static/icons/system.svg new file mode 100644 index 0000000..34ea85b --- /dev/null +++ b/static/icons/system.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/icons/temperature.svg b/static/icons/temperature.svg new file mode 100644 index 0000000..bab0a42 --- /dev/null +++ b/static/icons/temperature.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/icons/trash.svg b/static/icons/trash.svg new file mode 100644 index 0000000..505bcc2 --- /dev/null +++ b/static/icons/trash.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/icons/user.svg b/static/icons/user.svg new file mode 100644 index 0000000..c155964 --- /dev/null +++ b/static/icons/user.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..816dc96 --- /dev/null +++ b/static/index.html @@ -0,0 +1,52 @@ + + + + + + + + + + + + + chat + + +
+
+
+ + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/stream.go b/stream.go new file mode 100644 index 0000000..d970ee7 --- /dev/null +++ b/stream.go @@ -0,0 +1,70 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" +) + +type Chunk struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type Stream struct { + wr http.ResponseWriter + fl http.Flusher + en *json.Encoder +} + +func NewStream(w http.ResponseWriter) (*Stream, error) { + flusher, ok := w.(http.Flusher) + if !ok { + return nil, errors.New("failed to create flusher") + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + return &Stream{ + wr: w, + fl: flusher, + en: json.NewEncoder(w), + }, nil +} + +func (s *Stream) Send(ch Chunk) error { + if err := s.en.Encode(ch); err != nil { + return err + } + + if _, err := s.wr.Write([]byte("\n\n")); err != nil { + return err + } + + s.fl.Flush() + + return nil +} + +func ReasoningChunk(text string) Chunk { + return Chunk{ + Type: "reason", + Text: text, + } +} + +func TextChunk(text string) Chunk { + return Chunk{ + Type: "text", + Text: text, + } +} + +func ErrorChunk(err error) Chunk { + return Chunk{ + Type: "error", + Text: err.Error(), + } +}