From 2342c56aed8425bc7f8c87363e25d988925cd229 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Tue, 20 May 2025 13:10:09 +0300 Subject: [PATCH] Fix: show color; add bot [WIP] --- broker/sse.go | 30 ++---- components/addbotbtn.html | 10 ++ components/room.html | 6 ++ config/config.go | 6 ++ handlers/actions.go | 5 +- handlers/elements.go | 27 ++++- handlers/handlers.go | 4 +- llmapi/main.go | 200 ++++++++++++++++++++++++++++++++++++++ main.go | 1 + models/main.go | 9 +- todos.md | 5 +- 11 files changed, 267 insertions(+), 36 deletions(-) create mode 100644 components/addbotbtn.html create mode 100644 llmapi/main.go diff --git a/broker/sse.go b/broker/sse.go index 3b4ab79..0ff5da6 100644 --- a/broker/sse.go +++ b/broker/sse.go @@ -39,29 +39,13 @@ func NewBroker() (broker *Broker) { } } -// func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { -// w.Header().Set("Content-Type", "text/event-stream") -// w.Header().Set("Cache-Control", "no-cache") -// w.Header().Set("Connection", "keep-alive") -// w.Header().Set("Access-Control-Allow-Origin", "*") -// // Each connection registers its own message channel with the Broker's connections registry -// messageChan := make(NotifierChan) -// // Signal the broker that we have a new connection -// broker.newClients <- messageChan -// // Remove this client from the map of connected clients -// // when this handler exits. -// defer func() { -// broker.closingClients <- messageChan -// }() -// // c.Stream(func(w io.Writer) bool { -// for { -// // Emit Server Sent Events compatible -// event := <-messageChan -// fmt.Fprintf(w, "event:%s; data:%s\n", event.EventName, event.Payload) -// // c.SSEvent(event.EventName, event.Payload) -// w.(http.Flusher).Flush() -// } -// } +var Notifier *Broker + +// for use in different packages +func init() { + Notifier = NewBroker() + go Notifier.Listen() +} func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Headers (keep these as-is) diff --git a/components/addbotbtn.html b/components/addbotbtn.html new file mode 100644 index 0000000..889647b --- /dev/null +++ b/components/addbotbtn.html @@ -0,0 +1,10 @@ +{{ define "addbot" }} +
+ + +
+
+ + +
+{{end}} diff --git a/components/room.html b/components/room.html index 0ef6702..9945eaf 100644 --- a/components/room.html +++ b/components/room.html @@ -27,6 +27,12 @@ {{if .Room.IsRunning}} {{template "cardcounter" .Room}} {{end}} + +
+ {{if and (eq .State.Username .Room.CreatorName) (not .Room.IsRunning)}} + {{template "addbot" .}} + {{end}} +
{{template "teamlist" .Room.BlueTeam}} diff --git a/config/config.go b/config/config.go index f15b65e..416137d 100644 --- a/config/config.go +++ b/config/config.go @@ -12,6 +12,7 @@ type Config struct { SessionLifetime int `toml:"SESSION_LIFETIME_SECONDS"` DBURI string `toml:"DBURI"` CookieSecret string `toml:"COOKIE_SECRET"` + LLMConfig LLMConfig `toml:"LLM"` } type ServerConfig struct { @@ -19,6 +20,11 @@ type ServerConfig struct { Port string `toml:"PORT"` } +type LLMConfig struct { + URL string `toml:"LLM_URL"` + TOKEN string `toml:"LLM_TOKEN"` +} + func LoadConfigOrDefault(fn string) *Config { if fn == "" { fn = "config.toml" diff --git a/handlers/actions.go b/handlers/actions.go index 383dfa6..0c70821 100644 --- a/handlers/actions.go +++ b/handlers/actions.go @@ -260,5 +260,8 @@ func loadCards(room *models.Room) { fmt.Println("failed to load cards", "error", err) } room.Cards = cards - + room.WCMap = make(map[string]models.WordColor) + for _, card := range room.Cards { + room.WCMap[card.Word] = card.Color + } } diff --git a/handlers/elements.go b/handlers/elements.go index 39eaabd..a601062 100644 --- a/handlers/elements.go +++ b/handlers/elements.go @@ -2,6 +2,7 @@ package handlers import ( "errors" + "golias/llmapi" "golias/models" "html/template" "net/http" @@ -55,7 +56,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) { abortWithError(w, "wait for the clue") return } - color, exists := roundWords[word] + color, exists := fi.Room.WCMap[word] log.Debug("got show-color request", "word", word, "color", color) if !exists { abortWithError(w, "word is not found") @@ -63,7 +64,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) { } cardword := models.WordCard{ Word: word, - Color: models.StrToWordColor(color), + Color: color, Revealed: true, } fi.Room.RevealSpecificWord(word) @@ -71,14 +72,14 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) { action := models.Action{ Actor: fi.State.Username, ActorColor: string(fi.State.Team), - WordColor: color, + WordColor: string(color), Action: "guessed", Word: word, } fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) // if opened card is of color of opp team, change turn oppositeColor := fi.Room.GetOppositeTeamColor() - switch color { + switch string(color) { case "black": // game over fi.Room.IsRunning = false @@ -122,3 +123,21 @@ func HandleActionHistory(w http.ResponseWriter, r *http.Request) { } tmpl.ExecuteTemplate(w, "actionhistory", fi.Room.ActionHistory) } + +func HandleAddBot(w http.ResponseWriter, r *http.Request) { + // get team; // get role; make up a name + team := r.URL.Query().Get("team") + role := r.URL.Query().Get("role") + fi, err := getFullInfoByCtx(r.Context()) + if err != nil { + abortWithError(w, err.Error()) + return + } + bot, err := llmapi.NewBot(role, team, "bot1", fi.Room.ID, cfg) + if err != nil { + abortWithError(w, err.Error()) + return + } + bot.StartBot() + notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "") +} diff --git a/handlers/handlers.go b/handlers/handlers.go index b8baf26..2c65eba 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -25,8 +25,8 @@ func init() { })) memcache = cache.MemCache cfg = config.LoadConfigOrDefault("") - Notifier = broker.NewBroker() - go Notifier.Listen() + Notifier = broker.Notifier + // go Notifier.Listen() } var roundWords = map[string]string{ diff --git a/llmapi/main.go b/llmapi/main.go new file mode 100644 index 0000000..879cad1 --- /dev/null +++ b/llmapi/main.go @@ -0,0 +1,200 @@ +package llmapi + +import ( + "encoding/json" + "errors" + "fmt" + "golias/config" + "golias/models" + "golias/pkg/cache" + "io" + "log/slog" + "net/http" + "strings" +) + +// TODO: config for url and token +// completion prompt +// MIME: llm needs to know all the cards, colors and previous actions +// GUESSER: llm needs to know all the cards and previous actions + +type Bot struct { + Role string // gueeser | mime + Team string + cfg *config.Config + RoomID string // can we get a room from here? + BotName string + log *slog.Logger + // channels for communicaton + SignalsCh chan bool + DoneCh chan bool +} + +// StartBot +func (b *Bot) StartBot() { + for { + select { + case <-b.SignalsCh: + // get room cards and actions + room, err := getRoomByID(b.RoomID) + if err != nil { + b.log.Error("bot loop", "error", err) + return + } + // form prompt + prompt := b.BuildPrompt(room) + b.log.Debug("got prompt", "prompt", prompt) + // call llm + if err := b.CallLLM(prompt); err != nil { + b.log.Error("bot loop", "error", err) + return + } + // parse response + // if mime -> give clue + // if guesser -> open card (does opening one card prompting new loop?) + // send notification to sse broker + case <-b.DoneCh: + b.log.Debug("got done signal", "bot-name", b.BotName) + return + } + } +} + +// EndBot + +func NewBot(role, team, name, roomID string, cfg *config.Config) (*Bot, error) { + bot := &Bot{ + Role: role, + RoomID: roomID, + BotName: name, + Team: team, + cfg: cfg, + } + // add to room + room, err := getRoomByID(bot.RoomID) + if err != nil { + return nil, err + } + // check if not running + if role == "mime" && room.IsRunning { + return nil, errors.New("cannot join after game started") + } + room.PlayerList = append(room.PlayerList, name) + switch team { + case "red": + if role == "mime" { + room.RedTeam.Mime = name + } else if role == "guesser" { + room.RedTeam.Guessers = append(room.RedTeam.Guessers, name) + } else { + return nil, fmt.Errorf("uknown role: %s", role) + } + case "blue": + if role == "mime" { + room.BlueTeam.Mime = name + } else if role == "guesser" { + room.BlueTeam.Guessers = append(room.BlueTeam.Guessers, name) + } else { + return nil, fmt.Errorf("uknown role: %s", role) + } + default: + return nil, fmt.Errorf("uknown team: %s", team) + } + if err := saveRoom(room); err != nil { + return nil, err + } + go bot.StartBot() // run bot routine + return bot, nil +} + +func getRoomByID(roomID string) (*models.Room, error) { + roomBytes, err := cache.MemCache.Get(models.CacheRoomPrefix + roomID) + if err != nil { + return nil, err + } + resp := &models.Room{} + if err := json.Unmarshal(roomBytes, &resp); err != nil { + return nil, err + } + return resp, nil +} + +func saveRoom(room *models.Room) error { + key := models.CacheRoomPrefix + room.ID + data, err := json.Marshal(room) + if err != nil { + return err + } + cache.MemCache.Set(key, data) + return nil +} + +func (b *Bot) BuildPrompt(room *models.Room) string { + if b.Role == "" { + return "" + } + toText := make(map[string]any) + toText["backlog"] = room.ActionHistory + // mime sees all colors; + // guesser sees only revealed ones + if b.Role == models.UserRoleMime { + toText["cards"] = room.Cards + } + if b.Role == models.UserRoleGuesser { + copiedCards := []models.WordCard{} + copy(copiedCards, room.Cards) + for i, card := range copiedCards { + if !card.Revealed { + copiedCards[i].Color = models.WordColorUknown + } + } + toText["cards"] = copiedCards + } + data, err := json.MarshalIndent(toText, "", " ") + if err != nil { + // log + return "" + } + return string(data) +} + +func (b *Bot) CallLLM(prompt string) error { + method := "POST" + payload := strings.NewReader(fmt.Sprintf(`{ + "model": "deepseek-chat", + "prompt": "%s", + "echo": false, + "frequency_penalty": 0, + "logprobs": 0, + "max_tokens": 1024, + "presence_penalty": 0, + "stop": null, + "stream": false, + "stream_options": null, + "suffix": null, + "temperature": 1, + "top_p": 1 +}`, prompt)) + client := &http.Client{} + req, err := http.NewRequest(method, b.cfg.LLMConfig.URL, payload) + if err != nil { + fmt.Println(err) + return err + } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", b.cfg.LLMConfig.TOKEN)) + res, err := client.Do(req) + if err != nil { + fmt.Println(err) + return err + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + fmt.Println(err) + return err + } + fmt.Println(string(body)) + return nil +} diff --git a/main.go b/main.go index c545629..cba93e9 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,7 @@ func ListenToRequests(port string) error { mux.HandleFunc("GET /room/hideform", handlers.HandleHideCreateForm) mux.HandleFunc("GET /word/show-color", handlers.HandleShowColor) mux.HandleFunc("POST /check/name", handlers.HandleNameCheck) + mux.HandleFunc("GET /add-bot", handlers.HandleAddBot) // sse mux.Handle("GET /sub/sse", handlers.Notifier) slog.Info("Listening", "addr", port) diff --git a/models/main.go b/models/main.go index 34c5d1a..b785f2d 100644 --- a/models/main.go +++ b/models/main.go @@ -41,7 +41,7 @@ type Team struct { type Action struct { Actor string ActorColor string - Action string // clue | guess + Action WordColor // clue | guess Word string WordColor string Number string // for clue @@ -60,6 +60,7 @@ type Room struct { RedTeam Team BlueTeam Team Cards []WordCard + WCMap map[string]WordColor Result uint8 // 0 for unknown; 1 is win for red; 2 if for blue; BlueCounter uint8 RedCounter uint8 @@ -169,9 +170,9 @@ func (r *Room) RevealSpecificWord(word string) { } type WordCard struct { - Word string - Color WordColor - Revealed bool + Word string `json:"word"` + Color WordColor `json:"color"` + Revealed bool `json:"revealed"` } type GameSettings struct { diff --git a/todos.md b/todos.md index 1ce9847..146cb08 100644 --- a/todos.md +++ b/todos.md @@ -5,6 +5,7 @@ - mark cards (instead of opening them (right click?); - invite link; - login with invite link; +- add html icons of whos turn it is (like an image of big ? when mime is thinking); #### sse points - clue sse update; @@ -18,6 +19,6 @@ ### issues - after the game started (isrunning) players should be able join guessers, but not switch team, or join as a mime; -- do not let wrong team press buttons; - cleanup backlog after new game is started; -- guesser: word is not found +- guessers should not be able to open more cards, than mime gave them +1; +- sse hangs / fails connection which causes to wait for cards to open a few seconds (on local machine);