commit 4b40053ce9ee60c8535942814a417eabd7186754 Author: Laura Date: Tue Aug 5 03:56:23 2025 +0200 initial commit 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(), + } +}