initial commit
2
.example.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Your openrouter.ai token
|
||||||
|
OPENROUTER_TOKEN = ""
|
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
131
chat.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
env.go
Normal file
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
21
go.mod
Normal file
@@ -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
|
||||||
|
)
|
44
go.sum
Normal file
@@ -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=
|
13
http.go
Normal file
@@ -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)
|
||||||
|
}
|
36
main.go
Normal file
@@ -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)
|
||||||
|
}
|
38
models.go
Normal file
@@ -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
|
||||||
|
}
|
22
openrouter.go
Normal file
@@ -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
|
||||||
|
}
|
326
static/chat.css
Normal file
@@ -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);
|
||||||
|
}
|
422
static/chat.js
Normal file
@@ -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);
|
||||||
|
})();
|
7
static/icons/add.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: 889 B |
7
static/icons/assistant.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: 668 B |
7
static/icons/delete.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: 699 B |
7
static/icons/down.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: 586 B |
7
static/icons/edit.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: 1001 B |
7
static/icons/model.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: 1.2 KiB |
7
static/icons/save.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: 1.1 KiB |
7
static/icons/send.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: 808 B |
7
static/icons/stop.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: 812 B |
7
static/icons/system.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: 2.2 KiB |
7
static/icons/temperature.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: 912 B |
7
static/icons/trash.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: 805 B |
7
static/icons/user.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: 918 B |
52
static/index.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Nata+Sans:wght@100..900&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
|
<link href="chat.css" rel="stylesheet" />
|
||||||
|
|
||||||
|
<title>chat</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="page">
|
||||||
|
<div id="messages"></div>
|
||||||
|
<div id="chat">
|
||||||
|
<button id="bottom" class="hidden" title="Scroll to bottom"></button>
|
||||||
|
|
||||||
|
<textarea id="message" placeholder="Type something..."></textarea>
|
||||||
|
|
||||||
|
<button id="add" title="Add message"></button>
|
||||||
|
<button id="send" title="Add message and start completion"></button>
|
||||||
|
|
||||||
|
<div class="options">
|
||||||
|
<div class="option">
|
||||||
|
<label for="role" title="Role"></label>
|
||||||
|
<select id="role">
|
||||||
|
<option value="user" selected>User</option>
|
||||||
|
<option value="assistant">Assistant</option>
|
||||||
|
<option value="system">System</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="option">
|
||||||
|
<label for="model" title="Model"></label>
|
||||||
|
<select id="model"></select>
|
||||||
|
</div>
|
||||||
|
<div class="option">
|
||||||
|
<label for="temperature" title="Temperature (0 - 1)"></label>
|
||||||
|
<input id="temperature" type="number" min="0" max="1" step="0.05" value="0.85" />
|
||||||
|
</div>
|
||||||
|
<div class="option">
|
||||||
|
<button id="clear" title="Clear the entire chat"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="chat.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
70
stream.go
Normal file
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|