Compare commits

..

2 Commits

Author SHA1 Message Date
Radu Macocian (admac) b1a9293ac3 added model switcher 2026-06-15 13:28:08 +02:00
Radu Macocian (admac) ed04207b20 fixed db init 2026-06-12 17:12:50 +02:00
8 changed files with 291 additions and 66 deletions
+25 -13
View File
@@ -107,28 +107,40 @@ func defaultConfig() *Config {
Key: ""}, Key: ""},
LLMConfig{Model: ""}, LLMConfig{Model: ""},
[]LogConfig{{Level: slog.LevelInfo.Level(), Format: "text"}}, []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) { func overrideFromEnv(cfg *Config) {
val := os.Getenv(ENV_PREFIX + "api__url") overrides := []struct {
if val != "" { conf *string
cfg.Api.Url = val 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) {
if val != "" { val := os.Getenv(override.key)
cfg.Api.Key = val if val != "" {
} *override.conf = val
val = os.Getenv(ENV_PREFIX + "llm__model") }
if val != "" {
cfg.Llm.Model = val
} }
} }
func (cfg Config) validate() error { func (cfg Config) validate() error {
if cfg.Api.Key == "" {
return errors.New("No API key configured")
}
if cfg.Api.Url == "" { if cfg.Api.Url == "" {
return errors.New("No API url configured") return errors.New("No API url configured")
} }
+38 -8
View File
@@ -1,6 +1,7 @@
package history package history
import ( import (
"log/slog"
"net" "net"
"net/url" "net/url"
@@ -11,6 +12,7 @@ import (
type DbClient struct { type DbClient struct {
Cfg config.DbConfig Cfg config.DbConfig
Logger slog.Logger
} }
type Conversation struct { type Conversation struct {
@@ -27,24 +29,52 @@ func (client DbClient) init() error {
const createMessageTable = ` const createMessageTable = `
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role TEXT NOT NULL role TEXT NOT NULL,
content TEXT NOT NULL content TEXT NOT NULL,
conversation_id UUID conversation_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)` )`
const createToolCallTable = ` const createToolCallTable = `
CREATE TABLE IF NOT EXISTS tool_calls ( CREATE TABLE IF NOT EXISTS tool_calls (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
type TEXT NOT NULL type TEXT NOT NULL,
functionName TEXT NOT NULL functionName TEXT NOT NULL,
functionArgs TEXT NOT NULL functionArgs TEXT NOT NULL,
message_id UUID message_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)` )`
conn := pgx.MustOpen(client.connectionString()) conn := pgx.MustOpen(client.connectionString())
defer conn.Close() defer conn.Close()
tables := []struct {
tableName string
script string
}{
{"conversations", createConversationTable},
{"messages", createMessageTable},
{"tool_calls", createToolCallTable},
}
for _, table := range tables {
var exists bool
if err := conn.QueryRow(`SELECT EXISTS
(SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name =$1)`, table.tableName).Scan(&exists); err != nil {
client.Logger.Error("Error while checking table", "table", table.tableName)
return err
}
if exists {
client.Logger.Debug("Table found", "table", table.tableName)
} else {
client.Logger.Debug("Table not found. Creating", "table", table.tableName)
if _, err := conn.Exec(table.script); err != nil {
client.Logger.Error("Could not create table", "table", table.tableName)
return err
}
}
}
if _, err := conn.Exec(createToolCallTable); err != nil { if _, err := conn.Exec(createToolCallTable); err != nil {
return err return err
} }
+1
View File
@@ -14,6 +14,7 @@ require (
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.4.3 // indirect github.com/jackc/pgx/v5 v5.4.3 // indirect
github.com/jackc/puddle/v2 v2.2.1 // 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/crypto v0.9.0 // indirect
golang.org/x/text v0.9.0 // indirect golang.org/x/text v0.9.0 // indirect
) )
+2
View File
@@ -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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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/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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+61 -15
View File
@@ -13,8 +13,12 @@ import (
"git.estatecloud.org/radumaco/souvenir/model" "git.estatecloud.org/radumaco/souvenir/model"
) )
const COMPLETIONS_API = "/v1/chat/completions"
const MODELS_API = "/v1/models"
type LLMClient struct { type LLMClient struct {
Cfg config.Config Cfg config.Config
Logger slog.Logger
} }
// Request Types // Request Types
@@ -48,9 +52,49 @@ type Usage struct {
TotalTokens int `json:"total_tokens"` 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) { 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{ requestBody := ChatRequest{
Model: client.Cfg.Llm.Model, Model: client.Cfg.Llm.Model,
Messages: messages, Messages: messages,
@@ -62,29 +106,31 @@ func (client LLMClient) Call(query string, messages []model.Message) ([]model.Me
} }
body := bytes.NewBuffer(serialized) body := bytes.NewBuffer(serialized)
logger.Debug("Executing llm call", "query", query, "messages", messages, "body", serialized) req, err := http.NewRequest("POST", client.Cfg.Api.Url + COMPLETIONS_API, body)
req, err := http.NewRequest("POST", client.Cfg.Api.Url, body)
if err != nil { 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 return []model.Message{}, err
} }
req.Header.Set("Content-Type", "application/json") 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) resp, err := http.DefaultClient.Do(req)
if err != nil { 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 return []model.Message{}, err
} }
logger.Debug("LLM Call returned", "response", resp) client.Logger.Debug("LLM Call returned", "response", resp)
defer resp.Body.Close() defer resp.Body.Close()
data, err := io.ReadAll(resp.Body) data, err := io.ReadAll(resp.Body)
if err != nil { 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 return []model.Message{}, err
} }
if resp.StatusCode != 200 && resp.StatusCode != 202 { 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, "statusCode", resp.StatusCode,
"status", resp.Status) "status", resp.Status)
return []model.Message{}, return []model.Message{},
@@ -93,20 +139,20 @@ func (client LLMClient) Call(query string, messages []model.Message) ([]model.Me
resp.Status) resp.Status)
} }
logger.Debug("Received data", "data", data) client.Logger.Debug("Received data", "data", data)
var response ChatResponse var response ChatResponse
if err := json.Unmarshal(data, &response); err != nil { 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 return []model.Message{}, err
} }
var result []model.Message var result []model.Message
if len(response.Choices) == 0 { 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") return []model.Message{}, errors.New("The call returned an empty response")
} }
for _, choice := range response.Choices { 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) result = append(result, choice.Message)
} }
+91 -27
View File
@@ -15,18 +15,26 @@ import (
"git.estatecloud.org/radumaco/souvenir/model" "git.estatecloud.org/radumaco/souvenir/model"
) )
var logger *slog.Logger type focusState int
const (
focusChat focusState = iota
focusOverlay
)
type uiModel struct { type uiModel struct {
logger slog.Logger
viewport viewport.Model viewport viewport.Model
messages []model.Message messages []model.Message
textarea textarea.Model textarea textarea.Model
focus focusState
picker modelPicker
senderStyle lipgloss.Style senderStyle lipgloss.Style
agentStyle lipgloss.Style agentStyle lipgloss.Style
errorStyle lipgloss.Style errorStyle lipgloss.Style
client llm.LLMClient client llm.LLMClient
width int width int
height int height int
waiting bool waiting bool
err error err error
} }
@@ -36,8 +44,12 @@ type agentResponseMessage struct {
err error err error
} }
type modelsResponseMessage struct {
models []string
err error
}
func InitialModel(config config.Config) uiModel { func InitialModel(config config.Config) uiModel {
logger = slog.Default().With("Component", "UI")
ta := textarea.New() ta := textarea.New()
ta.Placeholder = "Send a message..." ta.Placeholder = "Send a message..."
ta.SetVirtualCursor(false) ta.SetVirtualCursor(false)
@@ -62,16 +74,20 @@ func InitialModel(config config.Config) uiModel {
ta.KeyMap.InsertNewline.SetEnabled(false) ta.KeyMap.InsertNewline.SetEnabled(false)
return uiModel{ model := uiModel{
textarea: ta, textarea: ta,
messages: []model.Message{}, messages: []model.Message{},
viewport: vp, viewport: vp,
senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("5")), senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("5")),
errorStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("9")), errorStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("9")),
agentStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("86")), 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, err: nil,
} }
model.picker = newModelPicker(&model.focus)
return model
} }
func (m uiModel) Init() tea.Cmd { 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) { func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.logger.Debug("Received message", "msg", msg)
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
logger.Debug("Received WindowSizeMsg", "width", msg.Width, "height", msg.Height)
m.viewport.SetWidth(msg.Width * 80 / 100) m.viewport.SetWidth(msg.Width * 80 / 100)
m.textarea.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.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.picker.SetSize(msg.Width, msg.Height)
var messages = m.renderMessages() var messages = m.renderMessages()
@@ -95,16 +113,24 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else { } else {
m.viewport.SetContent(renderLanding(m.viewport.Width(), m.viewport.Height())) 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(), "vp width", m.viewport.Width(), "vp height", m.viewport.Height(),
"ta width", m.textarea.Width(), "ta height", m.textarea.Height()) "ta width", m.textarea.Width(), "ta height", m.textarea.Height())
m.viewport.GotoBottom() m.viewport.GotoBottom()
case tea.KeyPressMsg: 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() { switch msg.String() {
case "ctrl+c", "esc": case "esc":
logger.Debug("Received exit sequence. Quiting") if m.focus == focusOverlay {
m.focus = focusChat
}
return m, nil
case "ctrl+c":
fmt.Println(m.textarea.Value()) fmt.Println(m.textarea.Value())
return m, tea.Quit return m, tea.Quit
case "shift+enter", "ctrl+j", "alt+enter": case "shift+enter", "ctrl+j", "alt+enter":
@@ -116,13 +142,24 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Content: input, Content: input,
Role: "user", Role: "user",
}) })
logger.Debug("Received enter. Creating new message", "message", input) m.logger.Debug("Received enter. Creating new message", "message", input)
var messages = m.renderMessages() switch input {
m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages)) case "/models":
m.textarea.Reset() m.logger.Debug("Received models sequence")
m.viewport.GotoBottom() m.focus = focusOverlay
m.waiting = true return m, m.getModels()
return m, m.callAgent(input, m.messages) 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: default:
var cmd tea.Cmd var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg) 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: case cursor.BlinkMsg:
logger.Debug("Received BlinkMsg") m.logger.Debug("Received BlinkMsg")
var cmd tea.Cmd var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg) m.textarea, cmd = m.textarea.Update(msg)
return m, cmd return m, cmd
case agentResponseMessage: case agentResponseMessage:
logger.Debug("Received agentResponseMessage") m.logger.Debug("Received agentResponseMessage")
m.waiting = false m.waiting = false
resp := msg.response resp := msg.response
if msg.err != nil { if msg.err != nil {
logger.Error("Message is error", "error", msg.err) m.logger.Error("Message is error", "error", msg.err)
resp = []model.Message{{ resp = []model.Message{{
Role: "Error", Role: "Error",
Content: msg.err.Error(), Content: msg.err.Error(),
@@ -148,28 +185,46 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
for _, r := range resp { 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) m.messages = append(m.messages, r)
} }
var messages = m.renderMessages() var messages = m.renderMessages()
m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages)) m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages))
m.viewport.GotoBottom() m.viewport.GotoBottom()
return m, nil 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 return m, nil
} }
func (m uiModel) View() tea.View { 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( ui := lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
m.viewport.View(), m.viewport.View(),
"model: " + m.client.Cfg.Llm.Model + "; url: " + m.client.Cfg.Api.Url,
m.textarea.View()) m.textarea.View())
c := m.textarea.Cursor() c := m.textarea.Cursor()
if c != nil { if c != nil {
c.Y += lipgloss.Height(m.viewport.View()) c.Y += lipgloss.Height(m.viewport.View()) + 1
gap := m.width - lipgloss.Width(ui) 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 := tea.NewView(lipgloss.PlaceHorizontal(m.width, lipgloss.Center, ui))
view.Cursor = c view.Cursor = c
@@ -178,13 +233,22 @@ func (m uiModel) View() tea.View {
} }
func (m uiModel) callAgent(input string, messages []model.Message) tea.Cmd { 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 { return func() tea.Msg {
resp, err := m.client.Call(input, messages) resp, err := m.client.Call(input, messages)
return agentResponseMessage{response: resp, err: err} 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 { func (m uiModel) renderMessages() string {
var result strings.Builder var result strings.Builder
var width = m.viewport.Width() var width = m.viewport.Width()
@@ -198,7 +262,7 @@ func (m uiModel) renderMessages() string {
case "assistant": case "assistant":
result.WriteString(m.agentStyle.Render("Agent: ")) result.WriteString(m.agentStyle.Render("Agent: "))
result.WriteString(message.Content) result.WriteString(message.Content)
case "error": case "Error":
result.WriteString(m.errorStyle.Render("Error:")) result.WriteString(m.errorStyle.Render("Error:"))
result.WriteString(message.Content) result.WriteString(message.Content)
} }
+12 -3
View File
@@ -38,9 +38,18 @@ const Wordmark = `
\__ \ (_) | |_| |\ V / __/ | | | | | \__ \ (_) | |_| |\ V / __/ | | | | |
|___/\___/ \__,_| \_/ \___|_| |_|_|_|` |___/\___/ \__,_| \_/ \___|_| |_|_|_|`
const Commands = `
/models
/exit
`
func renderLanding(width int, height int) string { func renderLanding(width int, height int) string {
return lipgloss.PlaceVertical(height, 0.7, return lipgloss.PlaceVertical(height, 0.7,
lipgloss.PlaceHorizontal(width, lipgloss.JoinVertical(lipgloss.Center,
lipgloss.Center, lipgloss.PlaceHorizontal(width,
lipgloss.JoinHorizontal(lipgloss.Center, Logo, Wordmark))) lipgloss.Center,
lipgloss.JoinHorizontal(lipgloss.Center, Logo, Wordmark)),
lipgloss.PlaceHorizontal(width,
lipgloss.Center,
Commands)))
} }
+61
View File
@@ -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()
}