added model switcher
This commit is contained in:
+23
-11
@@ -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")
|
||||
for _, override := range(overrides) {
|
||||
val := os.Getenv(override.key)
|
||||
if val != "" {
|
||||
cfg.Api.Key = val
|
||||
*override.conf = val
|
||||
}
|
||||
val = os.Getenv(ENV_PREFIX + "llm__model")
|
||||
if val != "" {
|
||||
cfg.Llm.Model = 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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
+59
-13
@@ -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
|
||||
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")
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+82
-18
@@ -15,12 +15,20 @@ 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
|
||||
@@ -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)
|
||||
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,26 +185,44 @@ 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+10
-1
@@ -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.JoinVertical(lipgloss.Center,
|
||||
lipgloss.PlaceHorizontal(width,
|
||||
lipgloss.Center,
|
||||
lipgloss.JoinHorizontal(lipgloss.Center, Logo, Wordmark)))
|
||||
lipgloss.JoinHorizontal(lipgloss.Center, Logo, Wordmark)),
|
||||
lipgloss.PlaceHorizontal(width,
|
||||
lipgloss.Center,
|
||||
Commands)))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user