Added message history saving

This commit is contained in:
Radu Macocian (admac)
2026-06-17 09:37:36 +02:00
parent b1a9293ac3
commit 8599de09de
10 changed files with 477 additions and 162 deletions
+199 -20
View File
@@ -1,49 +1,55 @@
package history package history
import ( import (
"errors"
"log/slog" "log/slog"
"net" "net"
"net/url" "net/url"
"strconv"
"strings"
"git.estatecloud.org/radumaco/souvenir/config" "git.estatecloud.org/radumaco/souvenir/config"
"git.estatecloud.org/radumaco/souvenir/model" "git.estatecloud.org/radumaco/souvenir/model"
"github.com/gopsql/db"
"github.com/gopsql/pgx" "github.com/gopsql/pgx"
) )
type DbClient struct { type DbClient struct {
Cfg config.DbConfig Cfg config.DbConfig
Logger slog.Logger Logger slog.Logger
} }
type Conversation struct { func (client DbClient) Init() error {
Id string
Messages []model.Message
}
func (client DbClient) init() error {
const createConversationTable = ` const createConversationTable = `
CREATE TABLE IF NOT EXISTS conversations ( CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT,
summary TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)` )`
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,
seq INTEGER NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
conversation_id UUID, conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (conversation_id, seq)
)` )`
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 DEFAULT gen_random_uuid(),
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 NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)` )`
if err := client.ensureDbExists(); err != nil {
return err
}
conn := pgx.MustOpen(client.connectionString()) conn := pgx.MustOpen(client.connectionString())
defer conn.Close() defer conn.Close()
tables := []struct { tables := []struct {
@@ -61,29 +67,202 @@ func (client DbClient) init() error {
(SELECT 1 FROM information_schema.tables (SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name =$1)`, table.tableName).Scan(&exists); err != nil { AND table_name =$1)`, table.tableName).Scan(&exists); err != nil {
client.Logger.Error("Error while checking table", "table", table.tableName) client.Logger.Error("Error while checking table", "table", table.tableName, "error", err)
return err return err
} }
if exists { if exists {
client.Logger.Debug("Table found", "table", table.tableName) client.Logger.Debug("Table found", "table", table.tableName)
} else { } else {
client.Logger.Debug("Table not found. Creating", "table", table.tableName) client.Logger.Debug("Table not found. Creating", "table", table.tableName)
if _, err := conn.Exec(table.script); err != nil { if _, err := conn.Exec(table.script); err != nil {
client.Logger.Error("Could not create table", "table", table.tableName) client.Logger.Error("Could not create table", "table", table.tableName, "error", err)
return err return err
} }
} }
} }
if _, err := conn.Exec(createToolCallTable); err != nil {
return err
}
if _, err := conn.Exec(createToolCallTable); err != nil {
return err
}
return nil return nil
} }
func (client DbClient) GetConversation(id string) (model.Conversation, error) {
conn := pgx.MustOpen(client.connectionString())
defer conn.Close()
client.Logger.Debug("Searching for conversation", "id", id)
var conv model.Conversation
if err := conn.QueryRow("SELECT id, name, summary FROM conversations WHERE id = $1", id).Scan(&conv.Id, &conv.Name, &conv.Summary); err != nil {
client.Logger.Error("Could not fetch conversation", "id", id, "error", err)
return conv, err
}
rows, err := conn.Query(`SELECT t.id, t.type, t.functionName, t.functionArgs, m.id, m.role, m.content, m.seq
FROM messages m
LEFT JOIN tool_calls t
ON m.id = t.message_id
WHERE m.conversation_id = $1 ORDER BY m.seq`, id)
if err != nil {
client.Logger.Error("Could not fetch messages", "conversation_id", id, "error", err)
return conv, err
}
defer rows.Close()
var messages []model.Message
for rows.Next() {
var (
tcId, tcType, tcName, tcArgs *string
msgId, role, content string
seq int
)
err = rows.Scan(&tcId, &tcType, &tcName, &tcArgs, &msgId, &role, &content, &seq)
if err != nil {
client.Logger.Error("Could not get message", "error", err)
}
if len(messages) == 0 || messages[len(messages) - 1].Id != msgId {
messages = append(messages, model.Message{
Id: msgId, Role: role, Content: content, Seq: seq,
})
}
if tcId != nil {
deref := func(s *string) string {
if s == nil {
return ""
}
return *s
}
messages[len(messages) - 1].ToolCalls = append(messages[len(messages)-1].ToolCalls, model.ToolCall{
Id: *tcId,
Type: deref(tcType),
FunctionName: deref(tcName),
FunctionArguments: deref(tcArgs),
})
}
}
conv.Messages = messages
client.Logger.Debug("Found conversation", "conversation", conv)
return conv, nil
}
func (client DbClient) GetConversations() ([]model.Conversation, error) {
client.Logger.Debug("Fetching conversations")
conn := pgx.MustOpen(client.connectionString())
defer conn.Close()
rows, err := conn.Query("SELECT id, name, summary FROM conversations")
if err != nil {
client.Logger.Error("Could not fetch conversations", "error", err)
return []model.Conversation{}, err
}
defer rows.Close()
conversations := []model.Conversation{}
for rows.Next() {
var conv model.Conversation
rows.Scan(&conv.Id, &conv.Name, &conv.Summary)
conversations = append(conversations, conv)
}
client.Logger.Debug("Found conversations", "count", len(conversations))
return conversations, nil
}
func (client DbClient) SaveConversation(conv model.Conversation) (model.Conversation, error) {
client.Logger.Debug("Saving conversation", "conversation", conv)
conn := pgx.MustOpen(client.connectionString())
defer conn.Close()
delta := 0
if conv.Id == "" {
if err := conn.QueryRow(`INSERT INTO conversations(name, summary) VALUES($1, $2) RETURNING id`,
conv.Name,
conv.Summary).Scan(&conv.Id); err != nil {
client.Logger.Error("Could not create conversation", "name", conv.Name, "error", err)
return conv, err
}
}
if err := conn.QueryRow(`SELECT COALESCE(MAX(seq) + 1, 1) FROM messages WHERE conversation_id = $1`, conv.Id).Scan(&delta); err != nil {
client.Logger.Warn("Could not get delta", "conversation_id", conv.Id, "error", err)
return conv, errors.New("Could not get delta")
}
client.Logger.Debug("Found delta.", "delta", delta, "Unsaved messages", len(conv.Messages[delta - 1:]))
if delta >= len(conv.Messages) {
return conv, nil
}
for _, message := range conv.Messages[delta - 1:] {
id, err := client.saveMessage(conv.Id, message, conn)
if err != nil {
client.Logger.Error("Could not save message", "message_id", message.Id, "seq", message.Seq, "error", err)
return conv, errors.New("Could not save message")
}
message.Id = id
}
return conv, nil
}
func (client DbClient) saveMessage(conversation_id string, message model.Message, conn db.DB) (string, error) {
var exists bool
if message.Id != "" {
if err := conn.QueryRow(`SELECT EXISTS
(SELECT 1 FROM messages
WHERE id = $1)`, message.Id).Scan(&exists); err != nil {
client.Logger.Warn("Error while scanning for message", "id", message.Id, "error", err)
return message.Id, err
}
}
if exists {
client.Logger.Warn("Message found. Skipping", "id", message.Id)
return message.Id, nil
}
if err := conn.QueryRow(`INSERT INTO messages (seq, role, content, conversation_id) VALUES ($1, $2, $3, $4) RETURNING id`,
message.Seq, message.Role, message.Content, conversation_id).Scan(&message.Id); err != nil {
client.Logger.Error("Could not save message", "seq", message.Seq, "error", err)
return message.Id, errors.New("Could not save message")
}
placeholder := 0
var placeholders strings.Builder
args := []any{}
const cols = 4
for _, call := range message.ToolCalls {
placeholders.WriteString("($")
placeholders.WriteString(strconv.Itoa(placeholder + 1))
placeholders.WriteString(",$")
placeholders.WriteString(strconv.Itoa(placeholder + 2))
placeholders.WriteString(",$")
placeholders.WriteString(strconv.Itoa(placeholder + 3))
placeholders.WriteString(",$")
placeholders.WriteString(strconv.Itoa(placeholder + 4))
placeholders.WriteString(")")
args = append(args, call.Type, call.FunctionName, call.FunctionArguments, message.Id)
placeholder += cols
}
if _, err := conn.Exec(`INSERT INTO tool_calls(type, functionName, functionArgs, message_id) VALUES `+placeholders.String(),
args...); err != nil && placeholder > 0 {
client.Logger.Warn("Could not insert tool_calls", "message_id", message.Id, "error", err)
}
return message.Id, nil
}
func (client DbClient) ensureDbExists() error {
maintenanceUrl := &url.URL{
Scheme: "postgres",
User: url.UserPassword(client.Cfg.User, client.Cfg.Password),
Host: net.JoinHostPort(client.Cfg.Url, client.Cfg.Port),
Path: "postgres",
}
conn := pgx.MustOpen(maintenanceUrl.String())
defer conn.Close()
var exists bool
if err := conn.QueryRow(`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`, client.Cfg.DbName).Scan(&exists); err != nil {
return err
}
if exists {
client.Logger.Debug("Database already exists", "name", client.Cfg.DbName)
return nil
}
client.Logger.Info("Creating database", "name", client.Cfg.DbName)
_, err := conn.Exec(`CREATE DATABASE "` + client.Cfg.DbName + `"`)
return err
}
func (client DbClient) connectionString() string { func (client DbClient) connectionString() string {
u := &url.URL{ u := &url.URL{
Scheme: "postgres", Scheme: "postgres",
+10 -1
View File
@@ -6,6 +6,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.estatecloud.org/radumaco/souvenir/config" "git.estatecloud.org/radumaco/souvenir/config"
"git.estatecloud.org/radumaco/souvenir/db/history"
ui "git.estatecloud.org/radumaco/souvenir/ui" ui "git.estatecloud.org/radumaco/souvenir/ui"
) )
@@ -18,7 +19,15 @@ func main() {
} }
var logger = slog.Default().With("Compoent", "Main") var logger = slog.Default().With("Compoent", "Main")
logger.Debug("Config initialized") logger.Debug("Config initialized")
p := tea.NewProgram(ui.InitialModel(*config))
db := history.DbClient{Cfg: config.Db, Logger: *slog.Default().With("Component", "History")};
err = db.Init()
if err != nil {
logger.Error("Error while initializing database", "message", err)
os.Exit(1)
}
p := tea.NewProgram(ui.InitialModel(*config, db))
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
logger.Error("Alas, there's been an error:", "message", err) logger.Error("Alas, there's been an error:", "message", err)
os.Exit(1) os.Exit(1)
+1 -1
View File
@@ -152,7 +152,7 @@ func (client LLMClient) Call(query string, messages []model.Message) ([]model.Me
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 {
client.Logger.Error("Appending response message", "message", choice.Message) client.Logger.Debug("Appending response message", "message", choice.Message)
result = append(result, choice.Message) result = append(result, choice.Message)
} }
+8
View File
@@ -0,0 +1,8 @@
package model
type Conversation struct {
Id string
Name string
Summary string
Messages []Message
}
+2 -7
View File
@@ -1,14 +1,9 @@
package model package model
type Message struct { type Message struct {
Id string `json:"-"`
Seq int `json:"-"`
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"`
} }
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
FunctionName string `json:"function"`
FunctionArguments string `json:"arguments"`
}
+8
View File
@@ -0,0 +1,8 @@
package model
type ToolCall struct {
Id string `json:"-"`
Type string `json:"type"`
FunctionName string `json:"function"`
FunctionArguments string `json:"arguments"`
}
+175 -72
View File
@@ -11,6 +11,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"git.estatecloud.org/radumaco/souvenir/config" "git.estatecloud.org/radumaco/souvenir/config"
"git.estatecloud.org/radumaco/souvenir/db/history"
llm "git.estatecloud.org/radumaco/souvenir/llm" llm "git.estatecloud.org/radumaco/souvenir/llm"
"git.estatecloud.org/radumaco/souvenir/model" "git.estatecloud.org/radumaco/souvenir/model"
) )
@@ -19,24 +20,27 @@ type focusState int
const ( const (
focusChat focusState = iota focusChat focusState = iota
focusOverlay focusModels
focusHistory
) )
type uiModel struct { type uiModel struct {
logger slog.Logger logger slog.Logger
viewport viewport.Model viewport viewport.Model
messages []model.Message conversation model.Conversation
textarea textarea.Model textarea textarea.Model
focus focusState focus focusState
picker modelPicker picker picker
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 errorMessage string
height int history history.DbClient
waiting bool width int
err error height int
waiting bool
err error
} }
type agentResponseMessage struct { type agentResponseMessage struct {
@@ -44,12 +48,12 @@ type agentResponseMessage struct {
err error err error
} }
type modelsResponseMessage struct { type conversationSavedMessage struct {
models []string conversation model.Conversation
err error err error
} }
func InitialModel(config config.Config) uiModel { func InitialModel(config config.Config, client history.DbClient) uiModel {
ta := textarea.New() ta := textarea.New()
ta.Placeholder = "Send a message..." ta.Placeholder = "Send a message..."
ta.SetVirtualCursor(false) ta.SetVirtualCursor(false)
@@ -75,18 +79,19 @@ func InitialModel(config config.Config) uiModel {
ta.KeyMap.InsertNewline.SetEnabled(false) ta.KeyMap.InsertNewline.SetEnabled(false)
model := uiModel{ model := uiModel{
textarea: ta, textarea: ta,
messages: []model.Message{}, conversation: model.Conversation{},
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, Logger: *slog.Default().With("Component", "LLM")}, client: llm.LLMClient{Cfg: config, Logger: *slog.Default().With("Component", "LLM")},
focus: focusChat, focus: focusChat,
logger: *slog.Default().With("Component", "TUI"), history: client,
err: nil, logger: *slog.Default().With("Component", "TUI"),
err: nil,
} }
model.picker = newModelPicker(&model.focus) model.picker = newPicker()
return model return model
} }
@@ -108,7 +113,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var messages = m.renderMessages() var messages = m.renderMessages()
if len(m.messages) > 0 { if len(m.conversation.Messages) > 0 {
m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages)) m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages))
} else { } else {
m.viewport.SetContent(renderLanding(m.viewport.Width(), m.viewport.Height())) m.viewport.SetContent(renderLanding(m.viewport.Width(), m.viewport.Height()))
@@ -119,14 +124,14 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.GotoBottom() m.viewport.GotoBottom()
case tea.KeyPressMsg: case tea.KeyPressMsg:
if m.focus == focusOverlay { if m.focus != focusChat {
picker, cmd := m.picker.Update(msg) picker, cmd := m.picker.Update(msg)
m.picker = picker m.picker = picker
return m, cmd return m, cmd
} }
switch msg.String() { switch msg.String() {
case "esc": case "esc":
if m.focus == focusOverlay { if m.focus != focusChat {
m.focus = focusChat m.focus = focusChat
} }
return m, nil return m, nil
@@ -137,28 +142,35 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.InsertRune('\n') m.textarea.InsertRune('\n')
return m, nil return m, nil
case "enter": case "enter":
m.clearErrorMessage()
input := m.textarea.Value() input := m.textarea.Value()
m.messages = append(m.messages, model.Message{ m.textarea.SetValue("")
Content: input,
Role: "user",
})
m.logger.Debug("Received enter. Creating new message", "message", input) m.logger.Debug("Received enter. Creating new message", "message", input)
switch input { switch input {
case "/history":
m.logger.Debug("Received history call")
m.focus = focusHistory
return m, m.getHistory()
case "/models": case "/models":
m.logger.Debug("Received models sequence") m.logger.Debug("Received models sequence")
m.focus = focusOverlay m.focus = focusModels
return m, m.getModels() return m, m.getModels()
case "/exit": case "/exit":
m.logger.Debug("Received exit sequence. Quiting") m.logger.Debug("Received exit sequence. Quiting")
fmt.Println(m.textarea.Value()) fmt.Println(m.textarea.Value())
return m, tea.Quit return m, tea.Quit
default: default:
m.conversation.Messages = append(m.conversation.Messages, model.Message{
Content: input,
Role: "user",
Seq: len(m.conversation.Messages) + 1,
})
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.textarea.Reset() m.textarea.Reset()
m.viewport.GotoBottom() m.viewport.GotoBottom()
m.waiting = true m.waiting = true
return m, m.callAgent(input, m.messages) return m, tea.Batch(m.callAgent(input, m.conversation.Messages), m.saveConversation())
} }
default: default:
var cmd tea.Cmd var cmd tea.Cmd
@@ -178,29 +190,56 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
resp := msg.response resp := msg.response
if msg.err != nil { if msg.err != nil {
m.logger.Error("Message is error", "error", msg.err) m.logger.Error("Message is error", "error", msg.err)
resp = []model.Message{{ m.setErrorMessage(msg.err.Error())
Role: "Error",
Content: msg.err.Error(),
}}
} }
for _, r := range resp { for _, r := range resp {
r.Seq = len(m.conversation.Messages) + 1
m.logger.Debug("Appending response message", "message", r) m.logger.Debug("Appending response message", "message", r)
m.messages = append(m.messages, r) m.conversation.Messages = append(m.conversation.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, m.saveConversation()
case modelChosenMsg: case pickerChosenMsg:
m.logger.Debug("Received modelChosenMsg", "model", msg.id) switch m.focus {
m.client.Cfg.Llm.Model = msg.id case focusModels:
m.logger.Debug("Received pickerChosenMsg", "model", msg.id)
m.client.Cfg.Llm.Model = msg.id
m.waiting = false
m.focus = focusChat
case focusHistory:
m.logger.Debug("Received pickerChosenMsg", "history", msg.id)
conv, err := m.history.GetConversation(msg.id)
if err != nil {
m.logger.Error("Could not retrieve conversation", "id", msg.id)
}
m.conversation = conv
m.waiting = false
m.focus = focusChat
var messages = m.renderMessages()
m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages))
m.viewport.GotoBottom()
}
case pickerDismissMsg:
m.logger.Debug("Received pickerDismissMsg")
m.waiting = false m.waiting = false
m.focus = focusChat m.focus = focusChat
case conversationSavedMessage:
m.logger.Debug("Received conversationSavedMessage", "conversation", msg.conversation)
if msg.err != nil {
m.logger.Error("Message is error", "error", msg.err)
m.setErrorMessage(msg.err.Error())
return m, nil
}
m.conversation = msg.conversation
} }
if m.focus == focusOverlay { if m.focus != focusChat {
picker, cmd := m.picker.Update(msg) picker, cmd := m.picker.Update(msg)
m.picker = picker m.picker = picker
return m, cmd return m, cmd
@@ -210,19 +249,28 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m uiModel) View() tea.View { func (m uiModel) View() tea.View {
if m.focus == focusOverlay { if m.focus != focusChat {
return tea.NewView(lipgloss.Place(m.width, m.height, return tea.NewView(lipgloss.Place(m.width, m.height,
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
m.picker.View())) m.picker.View()))
} }
parts := []string{m.viewport.View()}
if m.errorMessage != "" {
parts = append(parts, m.errorStyle.Render(m.errorMessage))
}
parts = append(parts, "model: "+m.client.Cfg.Llm.Model+"; url: "+m.client.Cfg.Api.Url)
parts = append(parts, m.textarea.View())
ui := lipgloss.JoinVertical( ui := lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
m.viewport.View(), parts...)
"model: " + m.client.Cfg.Llm.Model + "; url: " + m.client.Cfg.Api.Url,
m.textarea.View())
c := m.textarea.Cursor() c := m.textarea.Cursor()
if c != nil { if c != nil {
c.Y += lipgloss.Height(m.viewport.View()) + 1 c.Y += lipgloss.Height(m.viewport.View()) + 1
if m.errorMessage != "" {
c.Y++
}
gap := m.width - lipgloss.Width(ui) gap := m.width - lipgloss.Width(ui)
c.X += max(gap/2, 0) c.X += max(gap/2, 0)
} }
@@ -240,41 +288,96 @@ func (m uiModel) callAgent(input string, messages []model.Message) tea.Cmd {
} }
} }
func (m uiModel) getModels() tea.Cmd { func (m uiModel) saveConversation() tea.Cmd {
m.logger.Debug("Querying models") m.logger.Debug("Saving conversation", "conversation", m.conversation)
return func() tea.Msg { return func() tea.Msg {
resp, _ := m.client.Models() conv, err := m.history.SaveConversation(m.conversation)
m.logger.Debug("Received models from llm", "models", resp) return conversationSavedMessage{conversation: conv, err: err}
return modelsLoadedMsg{models: resp}
} }
} }
func (m uiModel) renderMessages() string { func (m uiModel) getModels() tea.Cmd {
m.logger.Debug("Querying models")
return func() tea.Msg {
resp, err := m.client.Models()
if err != nil {
m.logger.Error("Could not fetch models", "error", err)
}
m.logger.Debug("Received models from llm", "models", resp)
items := []modelItem{}
for _, model := range resp {
items = append(items, modelItem{name: model, description: model})
}
return modelsLoadedMsg{models: items, title: "Choose a model"}
}
}
func (m uiModel) getHistory() tea.Cmd {
m.logger.Debug("Querying history")
return func() tea.Msg {
resp, err := m.history.GetConversations()
if err != nil {
m.logger.Error("Could not fetch history", "error", err)
}
m.logger.Debug("Received conversations from psql", "conversations", resp)
items := []modelItem{}
for _, conv := range resp {
name := conv.Id
if conv.Name != "" {
name = conv.Name
}
items = append(items, modelItem{name: name, description: conv.Summary})
}
return modelsLoadedMsg{models: items, title: "Select a conversation to continue from where you left off"}
}
}
func (m *uiModel) renderMessages() string {
var result strings.Builder var result strings.Builder
var width = m.viewport.Width() var width = m.viewport.Width()
for _, message := range m.messages { for _, message := range m.conversation.Messages {
switch message.Role { switch message.Role {
case "user": case "user":
var line strings.Builder var line strings.Builder
line.WriteString(m.senderStyle.Render("You: ")) line.WriteString(m.senderStyle.Render("You: "))
line.WriteString(message.Content) line.WriteString(message.Content)
result.WriteString(lipgloss.PlaceHorizontal(width, lipgloss.Right, line.String())) result.WriteString(lipgloss.PlaceHorizontal(width, lipgloss.Right, line.String()))
result.WriteString("\n")
var sepStyle lipgloss.Style = lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
MarginTop(1).
MarginBottom(1)
sep := sepStyle.Render(strings.Repeat("┈", width))
result.WriteString(sep)
result.WriteString("\n")
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": result.WriteString("\n")
result.WriteString(m.errorStyle.Render("Error:"))
result.WriteString(message.Content)
}
result.WriteString("\n")
var sepStyle lipgloss.Style = lipgloss.NewStyle(). var sepStyle lipgloss.Style = lipgloss.NewStyle().
Foreground(lipgloss.Color("240")). Foreground(lipgloss.Color("240")).
MarginTop(1). MarginTop(1).
MarginBottom(1) MarginBottom(1)
sep := sepStyle.Render(strings.Repeat("┈", width)) sep := sepStyle.Render(strings.Repeat("┈", width))
result.WriteString(sep) result.WriteString(sep)
result.WriteString("\n") result.WriteString("\n")
}
} }
return result.String() return result.String()
} }
func (m *uiModel) setErrorMessage(message string) {
if m.errorMessage == "" {
m.viewport.SetHeight(m.viewport.Height() - 1)
}
m.errorMessage = message
}
func (m *uiModel) clearErrorMessage() {
if m.errorMessage != "" {
m.viewport.SetHeight(m.viewport.Height() + 1)
m.errorMessage = ""
}
}
+1
View File
@@ -39,6 +39,7 @@ const Wordmark = `
|___/\___/ \__,_| \_/ \___|_| |_|_|_|` |___/\___/ \__,_| \_/ \___|_| |_|_|_|`
const Commands = ` const Commands = `
/history
/models /models
/exit /exit
` `
-61
View File
@@ -1,61 +0,0 @@
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()
}
+73
View File
@@ -0,0 +1,73 @@
package ui
import (
"log/slog"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
)
type picker struct {
logger slog.Logger
list list.Model
title string
}
type modelsLoadedMsg struct {
models []modelItem
title string
}
type pickerChosenMsg struct{ id string }
type pickerDismissMsg struct {}
type modelItem struct {
name string
description string
}
func (i modelItem) FilterValue() string { return i.name }
func (i modelItem) Title() string { return i.name }
func (i modelItem) Description() string { return i.description }
func newPicker() picker {
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.DisableQuitKeybindings()
return picker{list: l, logger: *slog.Default().With("Component", "Picker")}
}
func (p *picker) SetSize(w, h int) {
p.list.SetSize(w, h)
}
func (p picker) Update(msg tea.Msg) (picker, tea.Cmd) {
switch msg := msg.(type) {
case modelsLoadedMsg:
p.logger.Debug("Received modelsLoadedMsg", "models", msg.models)
p.list.Title = msg.title
items := make([]list.Item, len(msg.models))
for i, m := range msg.models {
items[i] = modelItem{name: m.name, description: m.description}
}
return p, p.list.SetItems(items)
case tea.KeyPressMsg:
if msg.String() == "q" || msg.String() == "esc" {
if p.list.FilterState() != list.Filtering {
return p, func() tea.Msg { return pickerDismissMsg{} }
}
}
if msg.String() == "enter" {
p.logger.Debug("Selecting model", "model", p.list.SelectedItem())
if it, ok := p.list.SelectedItem().(modelItem); ok {
p.logger.Debug("Sending modelChosenMsg")
return p, func() tea.Msg { return pickerChosenMsg{it.name} }
}
}
}
var cmd tea.Cmd
p.list, cmd = p.list.Update(msg)
return p, cmd
}
func (p picker) View() string {
return p.list.View()
}