From 3266c8787ec7940715f374c67a542bfa28c6d9f5 Mon Sep 17 00:00:00 2001 From: MrBounty Date: Mon, 5 Aug 2024 18:22:51 +0200 Subject: [PATCH] SSE to WebSocket I needed to ping pong the content of the message because it was too long for a SSE event. I swaped to websocket instead --- Chat.go | 123 ++---- MyUtils.go | 18 +- Request.go | 125 +++++-- go.mod | 32 +- go.sum | 36 ++ main.go | 91 ++--- static/dependencies/ws.js | 476 ++++++++++++++++++++++++ views/chat.html | 2 +- views/layouts/main.html | 2 +- views/partials/message-bot.html | 11 +- views/partials/model-selection-btn.html | 1 + views/partials/popover-settings.html | 4 +- views/partials/welcome-chat.html | 2 +- 13 files changed, 720 insertions(+), 203 deletions(-) create mode 100644 static/dependencies/ws.js diff --git a/Chat.go b/Chat.go index 68ef307..5a7c820 100644 --- a/Chat.go +++ b/Chat.go @@ -59,6 +59,7 @@ type TemplateMessage struct { Hidden bool Id string Name string + LLMID string Model string ModelID string } @@ -111,7 +112,7 @@ func generateChatHTML(c *fiber.Ctx) string { panic(err) } - htmlString := "
" + htmlString := "
" var templateMessages []TemplateMessage @@ -174,7 +175,7 @@ func GetUserMessageHandler(c *fiber.Ctx) error { messageUUID, _ := edgedb.ParseUUID(id) var selectedMessage Message - err := edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": c.Cookies("jade-edgedb-auth-token")}).QuerySingle(context.Background(), ` + err := edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": c.Cookies("jade-edgedb-auth-token")}).QuerySingle(edgeCtx, ` SELECT Message { content } @@ -199,12 +200,24 @@ func GetMessageContentHandler(c *fiber.Ctx) error { messageId := c.FormValue("id") onlyContent := c.FormValue("onlyContent") // To init the text area of the edit message form + out := GenerateMessageContentHTML(c.Cookies("jade-edgedb-auth-token"), messageId, onlyContent, false) + + return c.SendString(out) +} + +func GenerateMessageContentHTML(authCookie string, messageId string, onlyContent string, withDiv bool) string { + fmt.Println("Generating message for:", authCookie) + messageUUID, _ := edgedb.ParseUUID(messageId) var selectedMessage Message - err := edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": c.Cookies("jade-edgedb-auth-token")}).QuerySingle(context.Background(), ` + err := edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": authCookie}).QuerySingle(edgeCtx, ` SELECT Message { content, + area: { + id, + position, + }, llm : { name, modelInfo : { @@ -221,10 +234,18 @@ func GetMessageContentHandler(c *fiber.Ctx) error { } if onlyContent == "true" { - return c.SendString(markdownToHTML(selectedMessage.Content)) + return markdownToHTML(selectedMessage.Content) } - out := "
" + var out string + + if withDiv { + out = fmt.Sprintf("
", selectedMessage.Area.Position) + } else { + out = "" + } + + out += "
" out += "

" out += "" + selectedMessage.LLM.Name + " " + selectedMessage.LLM.Model.ModelID + "" out += "

" @@ -235,8 +256,12 @@ func GetMessageContentHandler(c *fiber.Ctx) error { out += " " out += "
" + if withDiv { + out += "
" + } + // Update the selected value of messages in the database - err = edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": c.Cookies("jade-edgedb-auth-token")}).Execute(edgeCtx, ` + err = edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": authCookie}).Execute(edgeCtx, ` WITH m := (SELECT Message FILTER .id = $0) UPDATE Message FILTER .area = m.area @@ -246,13 +271,13 @@ func GetMessageContentHandler(c *fiber.Ctx) error { panic(err) } - _ = edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": c.Cookies("jade-edgedb-auth-token")}).Execute(edgeCtx, ` + _ = edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": authCookie}).Execute(edgeCtx, ` UPDATE Message FILTER .id = $0 SET {selected := true}; `, messageUUID) - return c.SendString(out) + return out } func generateWelcomeChatHTML() string { @@ -550,62 +575,6 @@ func generateLimitReachedChatHTML(c *fiber.Ctx) string { return htmlString } -func GetSelectionBtnHandler(c *fiber.Ctx) error { - messageId := c.FormValue("id") - messageUUID, err := edgedb.ParseUUID(messageId) - if err != nil { - fmt.Println("Error parsing UUID") - panic(err) - } - - var message Message - err = edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": c.Cookies("jade-edgedb-auth-token")}).QuerySingle(edgeCtx, ` - SELECT Message { - id, - content, - area : { - id, - position - }, - llm : { - modelInfo : { - modelID, - name, - company : { - icon, - } - } - } - } - FILTER .id = $0; - `, &message, messageUUID) - if err != nil { - fmt.Println("Error getting message") - panic(err) - } - - templateMessage := TemplateMessage{ - Icon: message.LLM.Model.Company.Icon, - Content: message.Content, - Hidden: false, - Id: message.ID.String(), - Name: message.LLM.Model.Name, - ModelID: message.LLM.Model.ModelID, - } - - outBtn, err := selectBtnTmpl.Execute(map[string]interface{}{ - "message": templateMessage, - "ConversationAreaId": message.Area.Position, - }) - if err != nil { - fmt.Println("Error generating HTML content") - panic(err) - } - outBtn = strings.ReplaceAll(outBtn, "\n", "") - - return c.SendString(outBtn) -} - // Button actions func DeleteMessageHandler(c *fiber.Ctx) error { messageId := c.FormValue("id") @@ -826,8 +795,6 @@ func LoadUsageKPIHandler(c *fiber.Ctx) error { } func GenerateModelPopoverHTML(refresh bool, c *fiber.Ctx) string { - openaiExists, anthropicExists, mistralExists, groqExists, gooseaiExists, googleExists, perplexityExists, fireworksExists := getExistingKeys(c) - var llms []LLM err := edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": c.Cookies("jade-edgedb-auth-token")}).Query(edgeCtx, ` SELECT LLM { @@ -873,20 +840,11 @@ func GenerateModelPopoverHTML(refresh bool, c *fiber.Ctx) string { isPremium, _ := IsCurrentUserSubscribed(c) out, err := modelPopoverTmpl.Execute(pongo2.Context{ - "IsLogin": checkIfLogin(c), - "OpenaiExists": openaiExists, - "AnthropicExists": anthropicExists, - "MistralExists": mistralExists, - "GroqExists": groqExists, - "GooseaiExists": gooseaiExists, - "GoogleExists": googleExists, - "PerplexityExists": perplexityExists, - "FireworksExists": fireworksExists, - "AnyExists": fireworksExists || openaiExists || anthropicExists || mistralExists || groqExists || gooseaiExists || googleExists || perplexityExists, - "LLMs": llms, - "ModelInfos": modelInfos, - "DeleteUpdate": refresh, - "IsPremium": isPremium, + "IsLogin": checkIfLogin(c), + "LLMs": llms, + "ModelInfos": modelInfos, + "DeleteUpdate": refresh, + "IsPremium": isPremium, }) if err != nil { fmt.Println("Error generating model popover") @@ -973,7 +931,7 @@ func LoadSettingsHandler(c *fiber.Ctx) error { stripeSubLink := "https://billing.stripe.com/p/login/6oE6sc0PTfvq1Hi288?prefilled_email=" + user.Email - openaiExists, anthropicExists, mistralExists, groqExists, gooseaiExists, googleExists, perplexityExists, fireworksExists := getExistingKeys(c) + openaiExists, anthropicExists, mistralExists, groqExists, gooseaiExists, googleExists, perplexityExists, fireworksExists, nimExists := getExistingKeys(c) isPremium, isBasic := IsCurrentUserSubscribed(c) out, err := settingPopoverTmpl.Execute(pongo2.Context{ @@ -984,9 +942,10 @@ func LoadSettingsHandler(c *fiber.Ctx) error { "GroqExists": groqExists, "GooseaiExists": gooseaiExists, "GoogleExists": googleExists, + "NimExists": nimExists, "PerplexityExists": perplexityExists, "FireworksExists": fireworksExists, - "AnyExists": fireworksExists || openaiExists || anthropicExists || mistralExists || groqExists || gooseaiExists || googleExists || perplexityExists, + "AnyExists": fireworksExists || openaiExists || anthropicExists || mistralExists || groqExists || gooseaiExists || googleExists || perplexityExists || nimExists, "isPremium": isPremium, "isBasic": isBasic, "StripeSubLink": stripeSubLink, diff --git a/MyUtils.go b/MyUtils.go index dfa8ad5..0df1792 100644 --- a/MyUtils.go +++ b/MyUtils.go @@ -75,9 +75,9 @@ func addCodeHeader(htmlContent string, languages []string) string { return updatedHTML } -func getExistingKeys(c *fiber.Ctx) (bool, bool, bool, bool, bool, bool, bool, bool) { +func getExistingKeys(c *fiber.Ctx) (bool, bool, bool, bool, bool, bool, bool, bool, bool) { if edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": c.Cookies("jade-edgedb-auth-token")}) == nil { - return false, false, false, false, false, false, false, false + return false, false, false, false, false, false, false, false, false } var ( @@ -86,6 +86,7 @@ func getExistingKeys(c *fiber.Ctx) (bool, bool, bool, bool, bool, bool, bool, bo mistralExists bool groqExists bool gooseaiExists bool + nimExists bool googleExists bool perplexityExists bool fireworksExists bool @@ -179,7 +180,18 @@ func getExistingKeys(c *fiber.Ctx) (bool, bool, bool, bool, bool, bool, bool, bo panic(err) } - return openaiExists, anthropicExists, mistralExists, groqExists, gooseaiExists, googleExists, perplexityExists, fireworksExists + err = edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": c.Cookies("jade-edgedb-auth-token")}).QuerySingle(edgeCtx, ` + select exists ( + select global currentUser.setting.keys + filter .company.name = "nim" + ); + `, &nimExists) + if err != nil { + fmt.Println("Error checking if Fireworks key exists") + panic(err) + } + + return openaiExists, anthropicExists, mistralExists, groqExists, gooseaiExists, googleExists, perplexityExists, fireworksExists, nimExists } func Message2RequestMessage(messages []Message, context string) []RequestMessage { diff --git a/Request.go b/Request.go index 8fcf55a..1c577a7 100644 --- a/Request.go +++ b/Request.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "sync" "time" @@ -96,8 +97,6 @@ func GeneratePlaceholderHTML(c *fiber.Ctx, message string, selectedLLMIds []stri }) out += messageOut - // defer sendEvent("hide-placeholder", "") - return out } @@ -116,6 +115,8 @@ func GenerateMultipleMessagesHandler(c *fiber.Ctx) error { panic(err) } + authCookie := c.Cookies("jade-edgedb-auth-token") + // Create a wait group to synchronize the goroutines var wg sync.WaitGroup @@ -190,53 +191,40 @@ func GenerateMultipleMessagesHandler(c *fiber.Ctx) error { panic(err) } - // Check if the context's deadline is exceeded select { case <-ctx.Done(): - // The context's deadline was exceeded fmt.Printf("Goroutine %d timed out\n", idx) default: - // Send the index of the completed goroutine to the firstDone channel select { case firstDone <- idx: - //Add the message as selected - _ = edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": c.Cookies("jade-edgedb-auth-token")}).Execute(edgeCtx, ` - UPDATE Message - FILTER .id = $0 - SET {selected := true}; - `, messageID) - - // Generate the HTML content - outIcon := `User Image` - go func() { - // I do a ping because of sse size limit. Do see if it's possible to do without it TODO - sendEvent( - user.ID.String(), - "swapContent-"+fmt.Sprintf("%d", message.Area.Position), - ``, - ) - sendEvent( - user.ID.String(), - "swapSelectionBtn-"+selectedLLMs[idx].ID.String(), - ``, - ) - sendEvent( - user.ID.String(), - "swapIcon-"+fmt.Sprintf("%d", message.Area.Position), - outIcon, - ) - }() + sendEvent( + user.ID.String(), + "swapContent-"+fmt.Sprintf("%d", message.Area.Position), + GenerateMessageContentHTML( + authCookie, + message.ID.String(), + "false", + true, + ), + ) + sendEvent( + user.ID.String(), + "swapSelectionBtn-"+selectedLLMs[idx].ID.String(), + GenerateSelectionBtnHTML(authCookie, message.ID.String()), + ) + sendEvent( + user.ID.String(), + "swapIcon-"+fmt.Sprintf("%d", message.Area.Position), + outIcon, + ) default: - // Send Content event - go func() { - sendEvent( - user.ID.String(), - "swapSelectionBtn-"+selectedLLMs[idx].ID.String(), - ``, - ) - }() + sendEvent( + user.ID.String(), + "swapSelectionBtn-"+selectedLLMs[idx].ID.String(), + GenerateSelectionBtnHTML(authCookie, message.ID.String()), + ) } } }() @@ -250,6 +238,63 @@ func GenerateMultipleMessagesHandler(c *fiber.Ctx) error { return c.SendString("") } +func GenerateSelectionBtnHTML(authCookie string, messageID string) string { + messageUUID, err := edgedb.ParseUUID(messageID) + if err != nil { + fmt.Println("Error parsing UUID") + panic(err) + } + + var message Message + err = edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": authCookie}).QuerySingle(edgeCtx, ` + SELECT Message { + id, + content, + area : { + id, + position + }, + llm : { + id, + modelInfo : { + modelID, + name, + company : { + icon, + } + } + } + } + FILTER .id = $0; + `, &message, messageUUID) + if err != nil { + fmt.Println("Error getting message") + panic(err) + } + + templateMessage := TemplateMessage{ + Icon: message.LLM.Model.Company.Icon, + Content: message.Content, + Hidden: false, + Id: message.ID.String(), + LLMID: message.LLM.ID.String(), + Name: message.LLM.Model.Name, + ModelID: message.LLM.Model.ModelID, + } + + outBtn, err := selectBtnTmpl.Execute(map[string]interface{}{ + "message": templateMessage, + "ConversationAreaId": message.Area.Position, + }) + if err != nil { + fmt.Println("Error generating HTML content") + panic(err) + } + outBtn = strings.ReplaceAll(outBtn, "\n", "") + + return outBtn +} + func addUsage(c *fiber.Ctx, inputCost float32, outputCost float32, inputToken int32, outputToken int32, modelID string) { // Create a new usage err := edgeGlobalClient.WithGlobals(map[string]interface{}{"ext::auth::client_token": c.Cookies("jade-edgedb-auth-token")}).Execute(edgeCtx, ` diff --git a/go.mod b/go.mod index 3e04eb0..b1b33a0 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module github.com/MrBounty/JADE2.0 go 1.22.2 require ( - github.com/edgedb/edgedb-go v0.17.1 + github.com/edgedb/edgedb-go v0.17.2 github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3 - github.com/gofiber/fiber/v2 v2.52.4 + github.com/gofiber/fiber/v2 v2.52.5 github.com/gofiber/template/django/v3 v3.1.11 github.com/stripe/stripe-go v70.15.0+incompatible github.com/yuin/goldmark v1.7.1 @@ -14,29 +14,33 @@ require ( require ( github.com/alecthomas/chroma v0.10.0 // indirect - github.com/andybalholm/brotli v1.0.5 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect github.com/dlclark/regexp2 v1.4.0 // indirect + github.com/fasthttp/websocket v1.5.10 // indirect github.com/flosch/pongo2/v6 v6.0.0 // indirect + github.com/gofiber/contrib/websocket v1.3.2 // indirect github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/utils v1.1.0 // indirect - github.com/google/uuid v1.5.0 // indirect - github.com/klauspost/compress v1.17.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/fasthttp v1.55.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect github.com/xdg/scram v1.0.5 // indirect github.com/xdg/stringprep v1.0.3 // indirect - golang.org/x/crypto v0.22.0 // indirect + golang.org/x/crypto v0.25.0 // indirect golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea // indirect - golang.org/x/mod v0.10.0 // indirect - golang.org/x/net v0.24.0 // indirect - golang.org/x/sys v0.19.0 // indirect - golang.org/x/text v0.14.0 // indirect - golang.org/x/tools v0.9.3 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.23.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect ) diff --git a/go.sum b/go.sum index c750eec..a2e8945 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbf github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d h1:S2NE3iHSwP0XV47EEXL8mWmRdEfGscSJ+7EgePNgt0s= github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -11,12 +13,20 @@ github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/edgedb/edgedb-go v0.17.1 h1:nWVNWq61X1KyJziy5Zm+NfUwr7nXiCW7/qmH1zMSOpI= github.com/edgedb/edgedb-go v0.17.1/go.mod h1:J+llluepGAi/rIPNcUgIFEedCCISLKFG+VUEWnBhIqE= +github.com/edgedb/edgedb-go v0.17.2 h1:qp+HgwmLrT8d3agg4zZrjTJyVmoAuRvRPuGR6rwZ0ho= +github.com/edgedb/edgedb-go v0.17.2/go.mod h1:J+llluepGAi/rIPNcUgIFEedCCISLKFG+VUEWnBhIqE= +github.com/fasthttp/websocket v1.5.10 h1:bc7NIGyrg1L6sd5pRzCIbXpro54SZLEluZCu0rOpcN4= +github.com/fasthttp/websocket v1.5.10/go.mod h1:BwHeuXGWzCW1/BIKUKD3+qfCl+cTdsHu/f243NcAI/Q= github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3 h1:fmFk0Wt3bBxxwZnu48jqMdaOR/IZ4vdtJFuaFV8MpIE= github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3/go.mod h1:bJWSKrZyQvfTnb2OudyUjurSG4/edverV7n82+K3JiM= github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU= github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= +github.com/gofiber/contrib/websocket v1.3.2 h1:AUq5PYeKwK50s0nQrnluuINYeep1c4nRCJ0NWsV3cvg= +github.com/gofiber/contrib/websocket v1.3.2/go.mod h1:07u6QGMsvX+sx7iGNCl5xhzuUVArWwLQ3tBIH24i+S8= github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM= github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= +github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc= github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= github.com/gofiber/template/django/v3 v3.1.11 h1:wE5k/wWNKGKxfeopaeB6IBijMiEVAxKHJVf1WMH5iNw= @@ -25,10 +35,14 @@ github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -42,11 +56,17 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc= +github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 h1:aQKxg3+2p+IFXXg97McgDGT5zcMrQoi0EICZs8Pgchs= github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -59,6 +79,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= +github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/xdg/scram v1.0.5 h1:TuS0RFmt5Is5qm9Tm2SoD89OPqe4IRiFtyFY4iwWXsw= @@ -72,22 +94,36 @@ github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 h1:yHfZ github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4= golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM= golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/main.go b/main.go index f4bd610..06800ea 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,13 @@ package main import ( - "bufio" "fmt" "log" "os" "sync" "github.com/flosch/pongo2" + "github.com/gofiber/contrib/websocket" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/fiber/v2/middleware/recover" @@ -29,26 +29,55 @@ var ( explainLLMconvChatTmpl *pongo2.Template mu sync.Mutex app *fiber.App - userSSEChannels = make(map[string]chan SSE) + userWSChannels = make(map[string]*websocket.Conn) ) -// SSE event structure -type SSE struct { - Event string - Data string -} - // Function to send events to all clients func sendEvent(userID string, event string, data string) { + message := fmt.Sprintf(`{"event": "%s", "data": "%s"}`, event, data) + sendWSMessage(userID, message) +} + +func sendWSMessage(userID string, message string) { mu.Lock() defer mu.Unlock() - userEvents, ok := userSSEChannels[userID] + conn, ok := userWSChannels[userID] if !ok { return } - userEvents <- SSE{Event: event, Data: data} + if err := conn.WriteMessage(websocket.TextMessage, []byte(message)); err != nil { + fmt.Println("write:", err) + conn.Close() + delete(userWSChannels, userID) + } +} + +func handleWebSocket(c *websocket.Conn) { + userID := c.Query("userID") + if userID == "" { + c.Close() + return + } + + mu.Lock() + userWSChannels[userID] = c + mu.Unlock() + + defer func() { + mu.Lock() + delete(userWSChannels, userID) + mu.Unlock() + c.Close() + }() + + for { + if _, _, err := c.ReadMessage(); err != nil { + log.Println("read:", err) + break + } + } } func main() { @@ -99,7 +128,6 @@ func main() { app.Get("/userMessage", GetUserMessageHandler) app.Post("/editMessage", EditMessageHandler) app.Get("/help", generateHelpChatHandler) - app.Get("/selectionBtn", GetSelectionBtnHandler) // Settings routes app.Post("/addKeys", addKeys) @@ -136,7 +164,7 @@ func main() { return c.SendString("") }) - app.Get("/sse", handleSSE) + app.Get("/ws", websocket.New(handleWebSocket)) // Start server if err := app.Listen(":8080"); err != nil { @@ -144,45 +172,6 @@ func main() { } } -func handleSSE(c *fiber.Ctx) error { - userID := c.Query("userID") // Get userID from query parameter - if userID == "" { - return c.Status(fiber.StatusBadRequest).SendString("Missing userID") - } - - events := make(chan SSE, 500) - mu.Lock() - userSSEChannels[userID] = events - mu.Unlock() - - // Create a context copy to use in the goroutine - ctx := c.Context() - - go func() { - <-ctx.Done() - mu.Lock() - delete(userSSEChannels, userID) - mu.Unlock() - close(events) - }() - - c.Set("Content-Type", "text/event-stream") - c.Set("Cache-Control", "no-cache") - c.Set("Connection", "keep-alive") - - c.Context().SetBodyStreamWriter(func(w *bufio.Writer) { - for event := range events { - if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Event, event.Data); err != nil { - fmt.Println(err) - return - } - w.Flush() - } - }) - - return nil -} - func addKeys(c *fiber.Ctx) error { keys := map[string]string{ "openai": c.FormValue("openai_key"), diff --git a/static/dependencies/ws.js b/static/dependencies/ws.js new file mode 100644 index 0000000..c1e2962 --- /dev/null +++ b/static/dependencies/ws.js @@ -0,0 +1,476 @@ +/* +WebSockets Extension +============================ +This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions. +*/ + +(function () { + + /** @type {import("../htmx").HtmxInternalApi} */ + var api; + + htmx.defineExtension("ws", { + + /** + * init is called once, when this extension is first registered. + * @param {import("../htmx").HtmxInternalApi} apiRef + */ + init: function (apiRef) { + + // Store reference to internal API + api = apiRef; + + // Default function for creating new EventSource objects + if (!htmx.createWebSocket) { + htmx.createWebSocket = createWebSocket; + } + + // Default setting for reconnect delay + if (!htmx.config.wsReconnectDelay) { + htmx.config.wsReconnectDelay = "full-jitter"; + } + }, + + /** + * onEvent handles all events passed to this extension. + * + * @param {string} name + * @param {Event} evt + */ + onEvent: function (name, evt) { + var parent = evt.target || evt.detail.elt; + + switch (name) { + + // Try to close the socket when elements are removed + case "htmx:beforeCleanupElement": + + var internalData = api.getInternalData(parent) + + if (internalData.webSocket) { + internalData.webSocket.close(); + } + return; + + // Try to create websockets when elements are processed + case "htmx:beforeProcessNode": + forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) { + ensureWebSocket(child) + }); + forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) { + ensureWebSocketSend(child) + }); + } + } + }); + + function splitOnWhitespace(trigger) { + return trigger.trim().split(/\s+/); + } + + function getLegacyWebsocketURL(elt) { + var legacySSEValue = api.getAttributeValue(elt, "hx-ws"); + if (legacySSEValue) { + var values = splitOnWhitespace(legacySSEValue); + for (var i = 0; i < values.length; i++) { + var value = values[i].split(/:(.+)/); + if (value[0] === "connect") { + return value[1]; + } + } + } + } + + /** + * ensureWebSocket creates a new WebSocket on the designated element, using + * the element's "ws-connect" attribute. + * @param {HTMLElement} socketElt + * @returns + */ + function ensureWebSocket(socketElt) { + + // If the element containing the WebSocket connection no longer exists, then + // do not connect/reconnect the WebSocket. + if (!api.bodyContains(socketElt)) { + return; + } + + // Get the source straight from the element's value + var wssSource = api.getAttributeValue(socketElt, "ws-connect") + + if (wssSource == null || wssSource === "") { + var legacySource = getLegacyWebsocketURL(socketElt); + if (legacySource == null) { + return; + } else { + wssSource = legacySource; + } + } + + // Guarantee that the wssSource value is a fully qualified URL + if (wssSource.indexOf("/") === 0) { + var base_part = location.hostname + (location.port ? ':' + location.port : ''); + if (location.protocol === 'https:') { + wssSource = "wss://" + base_part + wssSource; + } else if (location.protocol === 'http:') { + wssSource = "ws://" + base_part + wssSource; + } + } + + var socketWrapper = createWebsocketWrapper(socketElt, function () { + return htmx.createWebSocket(wssSource) + }); + + socketWrapper.addEventListener('message', function (event) { + if (maybeCloseWebSocketSource(socketElt)) { + return; + } + + var response = event.data; + if (!api.triggerEvent(socketElt, "htmx:wsBeforeMessage", { + message: response, + socketWrapper: socketWrapper.publicInterface + })) { + return; + } + + api.withExtensions(socketElt, function (extension) { + response = extension.transformResponse(response, null, socketElt); + }); + + var settleInfo = api.makeSettleInfo(socketElt); + var fragment = api.makeFragment(response); + + if (fragment.children.length) { + var children = Array.from(fragment.children); + for (var i = 0; i < children.length; i++) { + api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo); + } + } + + api.settleImmediately(settleInfo.tasks); + api.triggerEvent(socketElt, "htmx:wsAfterMessage", { message: response, socketWrapper: socketWrapper.publicInterface }) + }); + + // Put the WebSocket into the HTML Element's custom data. + api.getInternalData(socketElt).webSocket = socketWrapper; + } + + /** + * @typedef {Object} WebSocketWrapper + * @property {WebSocket} socket + * @property {Array<{message: string, sendElt: Element}>} messageQueue + * @property {number} retryCount + * @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state + * @property {(message: string, sendElt: Element) => void} send + * @property {(event: string, handler: Function) => void} addEventListener + * @property {() => void} handleQueuedMessages + * @property {() => void} init + * @property {() => void} close + */ + /** + * + * @param socketElt + * @param socketFunc + * @returns {WebSocketWrapper} + */ + function createWebsocketWrapper(socketElt, socketFunc) { + var wrapper = { + socket: null, + messageQueue: [], + retryCount: 0, + + /** @type {Object} */ + events: {}, + + addEventListener: function (event, handler) { + if (this.socket) { + this.socket.addEventListener(event, handler); + } + + if (!this.events[event]) { + this.events[event] = []; + } + + this.events[event].push(handler); + }, + + sendImmediately: function (message, sendElt) { + if (!this.socket) { + api.triggerErrorEvent() + } + if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', { + message: message, + socketWrapper: this.publicInterface + })) { + this.socket.send(message); + sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', { + message: message, + socketWrapper: this.publicInterface + }) + } + }, + + send: function (message, sendElt) { + if (this.socket.readyState !== this.socket.OPEN) { + this.messageQueue.push({ message: message, sendElt: sendElt }); + } else { + this.sendImmediately(message, sendElt); + } + }, + + handleQueuedMessages: function () { + while (this.messageQueue.length > 0) { + var queuedItem = this.messageQueue[0] + if (this.socket.readyState === this.socket.OPEN) { + this.sendImmediately(queuedItem.message, queuedItem.sendElt); + this.messageQueue.shift(); + } else { + break; + } + } + }, + + init: function () { + if (this.socket && this.socket.readyState === this.socket.OPEN) { + // Close discarded socket + this.socket.close() + } + + // Create a new WebSocket and event handlers + /** @type {WebSocket} */ + var socket = socketFunc(); + + // The event.type detail is added for interface conformance with the + // other two lifecycle events (open and close) so a single handler method + // can handle them polymorphically, if required. + api.triggerEvent(socketElt, "htmx:wsConnecting", { event: { type: 'connecting' } }); + + this.socket = socket; + + socket.onopen = function (e) { + wrapper.retryCount = 0; + api.triggerEvent(socketElt, "htmx:wsOpen", { event: e, socketWrapper: wrapper.publicInterface }); + wrapper.handleQueuedMessages(); + } + + socket.onclose = function (e) { + // If socket should not be connected, stop further attempts to establish connection + // If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause. + if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) { + var delay = getWebSocketReconnectDelay(wrapper.retryCount); + setTimeout(function () { + wrapper.retryCount += 1; + wrapper.init(); + }, delay); + } + + // Notify client code that connection has been closed. Client code can inspect `event` field + // to determine whether closure has been valid or abnormal + api.triggerEvent(socketElt, "htmx:wsClose", { event: e, socketWrapper: wrapper.publicInterface }) + }; + + socket.onerror = function (e) { + api.triggerErrorEvent(socketElt, "htmx:wsError", { error: e, socketWrapper: wrapper }); + maybeCloseWebSocketSource(socketElt); + }; + + var events = this.events; + Object.keys(events).forEach(function (k) { + events[k].forEach(function (e) { + socket.addEventListener(k, e); + }) + }); + }, + + close: function () { + this.socket.close() + } + } + + wrapper.init(); + + wrapper.publicInterface = { + send: wrapper.send.bind(wrapper), + sendImmediately: wrapper.sendImmediately.bind(wrapper), + queue: wrapper.messageQueue + }; + + return wrapper; + } + + /** + * ensureWebSocketSend attaches trigger handles to elements with + * "ws-send" attribute + * @param {HTMLElement} elt + */ + function ensureWebSocketSend(elt) { + var legacyAttribute = api.getAttributeValue(elt, "hx-ws"); + if (legacyAttribute && legacyAttribute !== 'send') { + return; + } + + var webSocketParent = api.getClosestMatch(elt, hasWebSocket) + processWebSocketSend(webSocketParent, elt); + } + + /** + * hasWebSocket function checks if a node has webSocket instance attached + * @param {HTMLElement} node + * @returns {boolean} + */ + function hasWebSocket(node) { + return api.getInternalData(node).webSocket != null; + } + + /** + * processWebSocketSend adds event listeners to the
element so that + * messages can be sent to the WebSocket server when the form is submitted. + * @param {HTMLElement} socketElt + * @param {HTMLElement} sendElt + */ + function processWebSocketSend(socketElt, sendElt) { + var nodeData = api.getInternalData(sendElt); + var triggerSpecs = api.getTriggerSpecs(sendElt); + triggerSpecs.forEach(function (ts) { + api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) { + if (maybeCloseWebSocketSource(socketElt)) { + return; + } + + /** @type {WebSocketWrapper} */ + var socketWrapper = api.getInternalData(socketElt).webSocket; + var headers = api.getHeaders(sendElt, api.getTarget(sendElt)); + var results = api.getInputValues(sendElt, 'post'); + var errors = results.errors; + var rawParameters = results.values; + var expressionVars = api.getExpressionVars(sendElt); + var allParameters = api.mergeObjects(rawParameters, expressionVars); + var filteredParameters = api.filterValues(allParameters, sendElt); + + var sendConfig = { + parameters: filteredParameters, + unfilteredParameters: allParameters, + headers: headers, + errors: errors, + + triggeringEvent: evt, + messageBody: undefined, + socketWrapper: socketWrapper.publicInterface + }; + + if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) { + return; + } + + if (errors && errors.length > 0) { + api.triggerEvent(elt, 'htmx:validation:halted', errors); + return; + } + + var body = sendConfig.messageBody; + if (body === undefined) { + var toSend = Object.assign({}, sendConfig.parameters); + if (sendConfig.headers) + toSend['HEADERS'] = headers; + body = JSON.stringify(toSend); + } + + socketWrapper.send(body, elt); + + if (evt && api.shouldCancel(evt, elt)) { + evt.preventDefault(); + } + }); + }); + } + + /** + * getWebSocketReconnectDelay is the default easing function for WebSocket reconnects. + * @param {number} retryCount // The number of retries that have already taken place + * @returns {number} + */ + function getWebSocketReconnectDelay(retryCount) { + + /** @type {"full-jitter" | ((retryCount:number) => number)} */ + var delay = htmx.config.wsReconnectDelay; + if (typeof delay === 'function') { + return delay(retryCount); + } + if (delay === 'full-jitter') { + var exp = Math.min(retryCount, 6); + var maxDelay = 1000 * Math.pow(2, exp); + return maxDelay * Math.random(); + } + + logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"'); + } + + /** + * maybeCloseWebSocketSource checks to the if the element that created the WebSocket + * still exists in the DOM. If NOT, then the WebSocket is closed and this function + * returns TRUE. If the element DOES EXIST, then no action is taken, and this function + * returns FALSE. + * + * @param {*} elt + * @returns + */ + function maybeCloseWebSocketSource(elt) { + if (!api.bodyContains(elt)) { + api.getInternalData(elt).webSocket.close(); + return true; + } + return false; + } + + /** + * createWebSocket is the default method for creating new WebSocket objects. + * it is hoisted into htmx.createWebSocket to be overridden by the user, if needed. + * + * @param {string} url + * @returns WebSocket + */ + function createWebSocket(url) { + var sock = new WebSocket(url, []); + sock.binaryType = htmx.config.wsBinaryType; + return sock; + } + + /** + * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. + * + * @param {HTMLElement} elt + * @param {string} attributeName + */ + function queryAttributeOnThisOrChildren(elt, attributeName) { + + var result = [] + + // If the parent element also contains the requested attribute, then add it to the results too. + if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-ws")) { + result.push(elt); + } + + // Search all child nodes that match the requested attribute + elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function (node) { + result.push(node) + }) + + return result + } + + /** + * @template T + * @param {T[]} arr + * @param {(T) => void} func + */ + function forEach(arr, func) { + if (arr) { + for (var i = 0; i < arr.length; i++) { + func(arr[i]); + } + } + } + +})(); + diff --git a/views/chat.html b/views/chat.html index 4ce7b9e..3b89141 100644 --- a/views/chat.html +++ b/views/chat.html @@ -1,7 +1,7 @@
+ hx-ext="ws">
diff --git a/views/layouts/main.html b/views/layouts/main.html index dd63903..ae87665 100644 --- a/views/layouts/main.html +++ b/views/layouts/main.html @@ -12,7 +12,7 @@ - + diff --git a/views/partials/message-bot.html b/views/partials/message-bot.html index 78c5875..7c2e19a 100644 --- a/views/partials/message-bot.html +++ b/views/partials/message-bot.html @@ -5,9 +5,7 @@ {% if IsPlaceholder %} -
+
User Image @@ -83,8 +81,7 @@
+ overflow-y: hidden">

Waiting...

@@ -109,9 +106,7 @@ {% for selectedLLM in SelectedLLMs %}

@@ -223,7 +223,7 @@ // Do not take the id="save-field" var fields = document.querySelectorAll("#api-keys-form .field.has-addons"); var saveButton = document.getElementById('save-keys-button'); - var toggleIcon = this.querySelector('i'); + var toggleIcon = document.getElementById('toggle-keys-icon'); fields.forEach(function (field) { if (field.id == "save-field") return; diff --git a/views/partials/welcome-chat.html b/views/partials/welcome-chat.html index 3a18822..574444e 100644 --- a/views/partials/welcome-chat.html +++ b/views/partials/welcome-chat.html @@ -28,7 +28,7 @@

For example, a response from GPT-4 Omni can be used by Claude Haiku.

-Login +Try JADE now for free!