From b1a9293ac3c180671cb554c36379e7589f403975 Mon Sep 17 00:00:00 2001 From: "Radu Macocian (admac)" Date: Mon, 15 Jun 2026 13:28:08 +0200 Subject: [PATCH] added model switcher --- config/config.go | 38 ++++++++++----- go.mod | 1 + go.sum | 2 + llm/client.go | 76 +++++++++++++++++++++++------ ui/chat.go | 118 +++++++++++++++++++++++++++++++++++----------- ui/landing.go | 15 ++++-- ui/modelPicker.go | 61 ++++++++++++++++++++++++ 7 files changed, 253 insertions(+), 58 deletions(-) create mode 100644 ui/modelPicker.go diff --git a/config/config.go b/config/config.go index 5ebced9..783ceb6 100644 --- a/config/config.go +++ b/config/config.go @@ -107,28 +107,40 @@ func defaultConfig() *Config { Key: ""}, LLMConfig{Model: ""}, []LogConfig{{Level: slog.LevelInfo.Level(), Format: "text"}}, + DbConfig{ + Url: "localhost", + User: "psql", + Port: "54321", + Password: "", + DbName: "souvenir", + History: HistoryConfig{Enabled: true}, + }, } } func overrideFromEnv(cfg *Config) { - val := os.Getenv(ENV_PREFIX + "api__url") - if val != "" { - cfg.Api.Url = val + overrides := []struct { + conf *string + key string + }{ + {conf: &cfg.Api.Url, key: ENV_PREFIX + "api__url"}, + {conf: &cfg.Api.Key, key: ENV_PREFIX + "api__key"}, + {conf: &cfg.Llm.Model, key: ENV_PREFIX + "llm__model"}, + {conf: &cfg.Db.DbName, key: ENV_PREFIX + "db__dbName"}, + {conf: &cfg.Db.Url, key: ENV_PREFIX + "db__url"}, + {conf: &cfg.Db.User, key: ENV_PREFIX + "db__user"}, + {conf: &cfg.Db.Password, key: ENV_PREFIX + "db__password"}, + {conf: &cfg.Db.Port, key: ENV_PREFIX + "db__port"}, } - val = os.Getenv(ENV_PREFIX + "api__key") - if val != "" { - cfg.Api.Key = val - } - val = os.Getenv(ENV_PREFIX + "llm__model") - if val != "" { - cfg.Llm.Model = val + for _, override := range(overrides) { + val := os.Getenv(override.key) + if val != "" { + *override.conf = val + } } } func (cfg Config) validate() error { - if cfg.Api.Key == "" { - return errors.New("No API key configured") - } if cfg.Api.Url == "" { return errors.New("No API url configured") } diff --git a/go.mod b/go.mod index f941808..90b63c3 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.4.3 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect golang.org/x/crypto v0.9.0 // indirect golang.org/x/text v0.9.0 // indirect ) diff --git a/go.sum b/go.sum index cbecd21..22d699f 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/llm/client.go b/llm/client.go index 5adfe12..30f5ac2 100644 --- a/llm/client.go +++ b/llm/client.go @@ -13,8 +13,12 @@ import ( "git.estatecloud.org/radumaco/souvenir/model" ) +const COMPLETIONS_API = "/v1/chat/completions" +const MODELS_API = "/v1/models" + type LLMClient struct { - Cfg config.Config + Cfg config.Config + Logger slog.Logger } // Request Types @@ -48,9 +52,49 @@ type Usage struct { TotalTokens int `json:"total_tokens"` } +func (client LLMClient) Models() ([]string, error) { + resp, err := http.Get(client.Cfg.Api.Url + MODELS_API) + if err != nil { + client.Logger.Error("There was an error during the network request", "error", err.Error()) + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + client.Logger.Error("There was an error reading the response body", "error", err.Error()) + return nil, err + } + + if resp.StatusCode != 200 && resp.StatusCode != 202 { + client.Logger.Error("Call returned non 200 status", + "statusCode", resp.StatusCode, + "status", resp.Status) + return nil, + errors.New("Call returned invalid status " + + strconv.Itoa(resp.StatusCode) + + resp.Status) + } + client.Logger.Debug("Received data", "data", data) + + var response struct{ + Data []struct{ + Id string + } + } + if err := json.Unmarshal(data, &response); err != nil { + client.Logger.Error("There was an error parsing the response", "error", err.Error()) + return nil, err + } + client.Logger.Debug("Unmarshal response", "data", response) + var ret []string + for _, m := range response.Data { + ret = append(ret, m.Id) + } + return ret, nil +} + func (client LLMClient) Call(query string, messages []model.Message) ([]model.Message, error) { - return []model.Message{{Role: "assistant", Content: "message 1"}, {Role: "assistant", Content: "message 2"}}, nil - var logger = slog.Default().With("Component", "LLM Client") requestBody := ChatRequest{ Model: client.Cfg.Llm.Model, Messages: messages, @@ -62,29 +106,31 @@ func (client LLMClient) Call(query string, messages []model.Message) ([]model.Me } body := bytes.NewBuffer(serialized) - logger.Debug("Executing llm call", "query", query, "messages", messages, "body", serialized) - req, err := http.NewRequest("POST", client.Cfg.Api.Url, body) + req, err := http.NewRequest("POST", client.Cfg.Api.Url + COMPLETIONS_API, body) if err != nil { - logger.Error("There was an error creating the request", "error", err.Error()) + client.Logger.Error("There was an error creating the request", "error", err.Error()) return []model.Message{}, err } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+client.Cfg.Api.Key) + if client.Cfg.Api.Key != "" { + req.Header.Set("Authorization", "Bearer "+client.Cfg.Api.Key) + } + client.Logger.Debug("Executing llm call", "req", req) resp, err := http.DefaultClient.Do(req) if err != nil { - logger.Error("There was an error during http call", "error", err.Error()) + client.Logger.Error("There was an error during http call", "error", err.Error()) return []model.Message{}, err } - logger.Debug("LLM Call returned", "response", resp) + client.Logger.Debug("LLM Call returned", "response", resp) defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { - logger.Error("There was an error reading the response body", "error", err.Error()) + client.Logger.Error("There was an error reading the response body", "error", err.Error()) return []model.Message{}, err } if resp.StatusCode != 200 && resp.StatusCode != 202 { - logger.Error("Call returned non 200 status", + client.Logger.Error("Call returned non 200 status", "statusCode", resp.StatusCode, "status", resp.Status) return []model.Message{}, @@ -93,20 +139,20 @@ func (client LLMClient) Call(query string, messages []model.Message) ([]model.Me resp.Status) } - logger.Debug("Received data", "data", data) + client.Logger.Debug("Received data", "data", data) var response ChatResponse if err := json.Unmarshal(data, &response); err != nil { - logger.Error("There was an error parsing the response", "error", err.Error()) + client.Logger.Error("There was an error parsing the response", "error", err.Error()) return []model.Message{}, err } var result []model.Message if len(response.Choices) == 0 { - logger.Error("The call returned an empty response") + client.Logger.Error("The call returned an empty response") return []model.Message{}, errors.New("The call returned an empty response") } for _, choice := range response.Choices { - logger.Error("Appending response message", "message", choice.Message) + client.Logger.Error("Appending response message", "message", choice.Message) result = append(result, choice.Message) } diff --git a/ui/chat.go b/ui/chat.go index 17801d3..500c314 100644 --- a/ui/chat.go +++ b/ui/chat.go @@ -15,18 +15,26 @@ import ( "git.estatecloud.org/radumaco/souvenir/model" ) -var logger *slog.Logger +type focusState int + +const ( + focusChat focusState = iota + focusOverlay +) type uiModel struct { + logger slog.Logger viewport viewport.Model messages []model.Message textarea textarea.Model + focus focusState + picker modelPicker senderStyle lipgloss.Style agentStyle lipgloss.Style errorStyle lipgloss.Style client llm.LLMClient - width int - height int + width int + height int waiting bool err error } @@ -36,8 +44,12 @@ type agentResponseMessage struct { err error } +type modelsResponseMessage struct { + models []string + err error +} + func InitialModel(config config.Config) uiModel { - logger = slog.Default().With("Component", "UI") ta := textarea.New() ta.Placeholder = "Send a message..." ta.SetVirtualCursor(false) @@ -62,16 +74,20 @@ func InitialModel(config config.Config) uiModel { ta.KeyMap.InsertNewline.SetEnabled(false) - return uiModel{ + model := uiModel{ textarea: ta, messages: []model.Message{}, viewport: vp, senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("5")), errorStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("9")), agentStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("86")), - client: llm.LLMClient{Cfg: config}, + client: llm.LLMClient{Cfg: config, Logger: *slog.Default().With("Component", "LLM")}, + focus: focusChat, + logger: *slog.Default().With("Component", "TUI"), err: nil, } + model.picker = newModelPicker(&model.focus) + return model } func (m uiModel) Init() tea.Cmd { @@ -79,14 +95,16 @@ func (m uiModel) Init() tea.Cmd { } func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m.logger.Debug("Received message", "msg", msg) + switch msg := msg.(type) { case tea.WindowSizeMsg: - logger.Debug("Received WindowSizeMsg", "width", msg.Width, "height", msg.Height) m.viewport.SetWidth(msg.Width * 80 / 100) m.textarea.SetWidth(msg.Width * 80 / 100) - m.viewport.SetHeight(msg.Height - lipgloss.Height(m.textarea.View())) + m.viewport.SetHeight(msg.Height - lipgloss.Height(m.textarea.View()) - 1) m.width = msg.Width m.height = msg.Height + m.picker.SetSize(msg.Width, msg.Height) var messages = m.renderMessages() @@ -95,16 +113,24 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.viewport.SetContent(renderLanding(m.viewport.Width(), m.viewport.Height())) } - logger.Debug("New sizes", + m.logger.Debug("New sizes", "vp width", m.viewport.Width(), "vp height", m.viewport.Height(), "ta width", m.textarea.Width(), "ta height", m.textarea.Height()) m.viewport.GotoBottom() case tea.KeyPressMsg: - logger.Debug("Received KeyPressMsg", "msg", msg) + if m.focus == focusOverlay { + picker, cmd := m.picker.Update(msg) + m.picker = picker + return m, cmd + } switch msg.String() { - case "ctrl+c", "esc": - logger.Debug("Received exit sequence. Quiting") + case "esc": + if m.focus == focusOverlay { + m.focus = focusChat + } + return m, nil + case "ctrl+c": fmt.Println(m.textarea.Value()) return m, tea.Quit case "shift+enter", "ctrl+j", "alt+enter": @@ -116,13 +142,24 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Content: input, Role: "user", }) - logger.Debug("Received enter. Creating new message", "message", input) - var messages = m.renderMessages() - m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages)) - m.textarea.Reset() - m.viewport.GotoBottom() - m.waiting = true - return m, m.callAgent(input, m.messages) + m.logger.Debug("Received enter. Creating new message", "message", input) + switch input { + case "/models": + m.logger.Debug("Received models sequence") + m.focus = focusOverlay + return m, m.getModels() + case "/exit": + m.logger.Debug("Received exit sequence. Quiting") + fmt.Println(m.textarea.Value()) + return m, tea.Quit + default: + var messages = m.renderMessages() + m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages)) + m.textarea.Reset() + m.viewport.GotoBottom() + m.waiting = true + return m, m.callAgent(input, m.messages) + } default: var cmd tea.Cmd m.textarea, cmd = m.textarea.Update(msg) @@ -130,17 +167,17 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case cursor.BlinkMsg: - logger.Debug("Received BlinkMsg") + m.logger.Debug("Received BlinkMsg") var cmd tea.Cmd m.textarea, cmd = m.textarea.Update(msg) return m, cmd case agentResponseMessage: - logger.Debug("Received agentResponseMessage") + m.logger.Debug("Received agentResponseMessage") m.waiting = false resp := msg.response if msg.err != nil { - logger.Error("Message is error", "error", msg.err) + m.logger.Error("Message is error", "error", msg.err) resp = []model.Message{{ Role: "Error", Content: msg.err.Error(), @@ -148,28 +185,46 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } for _, r := range resp { - logger.Debug("Appending response message", "message", r) + m.logger.Debug("Appending response message", "message", r) m.messages = append(m.messages, r) } var messages = m.renderMessages() m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages)) m.viewport.GotoBottom() return m, nil + + case modelChosenMsg: + m.logger.Debug("Received modelChosenMsg", "model", msg.id) + m.client.Cfg.Llm.Model = msg.id + m.waiting = false + m.focus = focusChat + } + + if m.focus == focusOverlay { + picker, cmd := m.picker.Update(msg) + m.picker = picker + return m, cmd } return m, nil } func (m uiModel) View() tea.View { + if m.focus == focusOverlay { + return tea.NewView(lipgloss.Place(m.width, m.height, + lipgloss.Center, lipgloss.Center, + m.picker.View())) + } ui := lipgloss.JoinVertical( lipgloss.Left, m.viewport.View(), + "model: " + m.client.Cfg.Llm.Model + "; url: " + m.client.Cfg.Api.Url, m.textarea.View()) c := m.textarea.Cursor() if c != nil { - c.Y += lipgloss.Height(m.viewport.View()) + c.Y += lipgloss.Height(m.viewport.View()) + 1 gap := m.width - lipgloss.Width(ui) - c.X += max(gap / 2, 0) + c.X += max(gap/2, 0) } view := tea.NewView(lipgloss.PlaceHorizontal(m.width, lipgloss.Center, ui)) view.Cursor = c @@ -178,13 +233,22 @@ func (m uiModel) View() tea.View { } func (m uiModel) callAgent(input string, messages []model.Message) tea.Cmd { - logger.Debug("Calling agent", "query", input, "messages", messages) + m.logger.Debug("Calling agent", "query", input, "messages", messages) return func() tea.Msg { resp, err := m.client.Call(input, messages) return agentResponseMessage{response: resp, err: err} } } +func (m uiModel) getModels() tea.Cmd { + m.logger.Debug("Querying models") + return func() tea.Msg { + resp, _ := m.client.Models() + m.logger.Debug("Received models from llm", "models", resp) + return modelsLoadedMsg{models: resp} + } +} + func (m uiModel) renderMessages() string { var result strings.Builder var width = m.viewport.Width() @@ -198,7 +262,7 @@ func (m uiModel) renderMessages() string { case "assistant": result.WriteString(m.agentStyle.Render("Agent: ")) result.WriteString(message.Content) - case "error": + case "Error": result.WriteString(m.errorStyle.Render("Error:")) result.WriteString(message.Content) } diff --git a/ui/landing.go b/ui/landing.go index 1fb9ab3..a8057a7 100644 --- a/ui/landing.go +++ b/ui/landing.go @@ -38,9 +38,18 @@ const Wordmark = ` \__ \ (_) | |_| |\ V / __/ | | | | | |___/\___/ \__,_| \_/ \___|_| |_|_|_|` +const Commands = ` +/models +/exit +` + func renderLanding(width int, height int) string { return lipgloss.PlaceVertical(height, 0.7, - lipgloss.PlaceHorizontal(width, - lipgloss.Center, - lipgloss.JoinHorizontal(lipgloss.Center, Logo, Wordmark))) + lipgloss.JoinVertical(lipgloss.Center, + lipgloss.PlaceHorizontal(width, + lipgloss.Center, + lipgloss.JoinHorizontal(lipgloss.Center, Logo, Wordmark)), + lipgloss.PlaceHorizontal(width, + lipgloss.Center, + Commands))) } diff --git a/ui/modelPicker.go b/ui/modelPicker.go new file mode 100644 index 0000000..25140d8 --- /dev/null +++ b/ui/modelPicker.go @@ -0,0 +1,61 @@ +package ui + +import ( + "log/slog" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" +) + +type modelPicker struct { + focus *focusState + logger slog.Logger + list list.Model +} + +type modelsLoadedMsg struct{ models []string } +type modelsErrMsg struct{ err error } +type modelChosenMsg struct{ id string } + +type modelItem struct{ name string } +func (i modelItem) FilterValue() string { return i.name } +func (i modelItem) Title() string { return i.name } +func (i modelItem) Description() string { return i.name } + +func newModelPicker(focus *focusState) modelPicker { + l := list.New(nil, list.NewDefaultDelegate(), 0, 0) + l.Title = "Choose a model" + return modelPicker{list: l, logger: *slog.Default().With("Component", "ModelPicker"), focus: focus} +} + +func (p *modelPicker) SetSize(w, h int) { + p.list.SetSize(w, h) +} + +func (p modelPicker) Update(msg tea.Msg) (modelPicker, tea.Cmd) { + switch msg := msg.(type) { + case modelsLoadedMsg: + p.logger.Debug("Received modelsLoadedMsg", "models", msg.models) + items := make([]list.Item, len(msg.models)) + for i, m := range msg.models { + items[i] = modelItem{ name: m} + } + return p, p.list.SetItems(items) + case tea.KeyPressMsg: + if msg.String() == "enter" { + p.logger.Debug("Selecting model", "model", p.list.SelectedItem()) + if it, ok := p.list.SelectedItem().(modelItem); ok { + *p.focus = focusChat + p.logger.Debug("Sending modelChosenMsg") + return p, func() tea.Msg { return modelChosenMsg{it.name} } + } + } + } + var cmd tea.Cmd + p.list, cmd = p.list.Update(msg) + return p, cmd +} + +func (p modelPicker) View() string { + return p.list.View() +}