Compare commits

..

2 Commits

Author SHA1 Message Date
Radu Macocian (admac) 789f3b5dd3 embed work 2026-06-23 14:36:34 +02:00
Radu Macocian (admac) 8599de09de Added message history saving 2026-06-18 11:42:29 +02:00
14 changed files with 550 additions and 186 deletions
+33 -8
View File
@@ -5,6 +5,8 @@ import (
"errors" "errors"
"io" "io"
"log/slog" "log/slog"
"net"
"net/url"
"os" "os"
) )
@@ -18,13 +20,30 @@ type Config struct {
} }
type DbConfig struct { type DbConfig struct {
Url string Url string
Port string Port string
DbName string DbName string
User string User string
Password string Password string
History HistoryConfig History HistoryConfig
Memory MemoryConfig Memory MemoryConfig
Embedding EmbeddingConfig
}
func (config DbConfig) ConnectionString() string {
u := &url.URL{
Scheme: "postgres",
User: url.UserPassword(config.User, config.Password),
Host: net.JoinHostPort(config.Url, config.Port),
Path: config.DbName,
}
return u.String()
}
type EmbeddingConfig struct {
Url string
key string
Model string
} }
type MemoryConfig struct { type MemoryConfig struct {
@@ -126,13 +145,16 @@ func overrideFromEnv(cfg *Config) {
{conf: &cfg.Api.Url, key: ENV_PREFIX + "api__url"}, {conf: &cfg.Api.Url, key: ENV_PREFIX + "api__url"},
{conf: &cfg.Api.Key, key: ENV_PREFIX + "api__key"}, {conf: &cfg.Api.Key, key: ENV_PREFIX + "api__key"},
{conf: &cfg.Llm.Model, key: ENV_PREFIX + "llm__model"}, {conf: &cfg.Llm.Model, key: ENV_PREFIX + "llm__model"},
{conf: &cfg.Db.Embedding.Url, key: ENV_PREFIX + "db__embedding__url"},
{conf: &cfg.Db.Embedding.key, key: ENV_PREFIX + "db__embedding__key"},
{conf: &cfg.Db.Embedding.Model, key: ENV_PREFIX + "db__embedding__model"},
{conf: &cfg.Db.DbName, key: ENV_PREFIX + "db__dbName"}, {conf: &cfg.Db.DbName, key: ENV_PREFIX + "db__dbName"},
{conf: &cfg.Db.Url, key: ENV_PREFIX + "db__url"}, {conf: &cfg.Db.Url, key: ENV_PREFIX + "db__url"},
{conf: &cfg.Db.User, key: ENV_PREFIX + "db__user"}, {conf: &cfg.Db.User, key: ENV_PREFIX + "db__user"},
{conf: &cfg.Db.Password, key: ENV_PREFIX + "db__password"}, {conf: &cfg.Db.Password, key: ENV_PREFIX + "db__password"},
{conf: &cfg.Db.Port, key: ENV_PREFIX + "db__port"}, {conf: &cfg.Db.Port, key: ENV_PREFIX + "db__port"},
} }
for _, override := range(overrides) { for _, override := range overrides {
val := os.Getenv(override.key) val := os.Getenv(override.key)
if val != "" { if val != "" {
*override.conf = val *override.conf = val
@@ -144,5 +166,8 @@ func (cfg Config) validate() error {
if cfg.Api.Url == "" { if cfg.Api.Url == "" {
return errors.New("No API url configured") return errors.New("No API url configured")
} }
if cfg.Db.Embedding.Model == "" {
return errors.New("No embedding model configured")
}
return nil return nil
} }
+51
View File
@@ -0,0 +1,51 @@
package embed
import (
"fmt"
"log/slog"
"git.estatecloud.org/radumaco/souvenir/config"
"github.com/gopsql/pgx"
)
type Embed struct {
Cfg config.DbConfig
Logger slog.Logger
}
func (e Embed) tableName() string {
return fmt.Sprintf(`message_chunks_vec_%s`, e.Cfg.Embedding.Model)
}
func (e Embed) Init() error {
const createVectorTableTemplate = `
CREATE TABLE IF NOT EXISTS %s (
chunk_id UUID PRIMARY KEY REFERENCES message_chunks(id) ON DELETE CASCADE,
embedding vector(384),
content_hash text not null
)`
conn := pgx.MustOpen(e.Cfg.ConnectionString())
var exists bool
if err := conn.QueryRow(`SELECT EXISTS
(SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name =$1)`, e.tableName()).Scan(&exists); err != nil {
e.Logger.Error("Error while checking table", "table", e.tableName(), "error", err)
return err
}
if exists {
e.Logger.Debug("Table found", "table", e.tableName())
} else {
e.Logger.Debug("Table not found. Creating", "table", e.tableName())
script := fmt.Sprintf(createVectorTableTemplate, e.tableName())
if _, err := conn.Exec(script); err != nil {
e.Logger.Error("Could not create table", "table", e.tableName(), "error", err)
return err
}
}
return nil
}
func (e Embed) EmbedConversation(id string) error {
return nil
}
+2
View File
@@ -0,0 +1,2 @@
package embed
+2
View File
@@ -0,0 +1,2 @@
package embed
+184 -36
View File
@@ -1,50 +1,60 @@
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(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now() title TEXT,
title_source TEXT,
summary TEXT,
summary_through_seq int default 0
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(),
)` tsv tsvector generated always as (to_tsvector('english', content)) stored,
const createToolCallTable = ` UNIQUE (conversation_id, seq)
CREATE TABLE IF NOT EXISTS tool_calls ( );
id UUID PRIMARY KEY, CREATE INDEX on messages using gin (tsv);
type TEXT NOT NULL, CREATE INDEX on messages (conversation_id, seq);`
functionName TEXT NOT NULL, const createMessageChunkTable = `
functionArgs TEXT NOT NULL, CREATE TABLE IF NOT EXISTS message_chunks (
message_id UUID, id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now() conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
content TEXT NOT NULL,
content_hash TEXT NOT NULL
)` )`
conn := pgx.MustOpen(client.connectionString()) if err := client.ensureDbExists(); err != nil {
return err
}
conn := pgx.MustOpen(client.Cfg.ConnectionString())
defer conn.Close() defer conn.Close()
tables := []struct { tables := []struct {
tableName string tableName string
@@ -52,7 +62,7 @@ func (client DbClient) init() error {
}{ }{
{"conversations", createConversationTable}, {"conversations", createConversationTable},
{"messages", createMessageTable}, {"messages", createMessageTable},
{"tool_calls", createToolCallTable}, {"message_chunks", createMessageChunkTable},
} }
for _, table := range tables { for _, table := range tables {
@@ -61,35 +71,173 @@ 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) connectionString() string { func (client DbClient) GetConversation(id string) (model.Conversation, error) {
u := &url.URL{ conn := pgx.MustOpen(client.Cfg.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 id, role, content, seq
FROM messages
WHERE conversation_id = $1 ORDER BY 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 (
msgId, role, content string
seq int
)
err = rows.Scan(&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,
})
}
}
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.Cfg.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.Cfg.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("Insertting message ", "seq", message.Seq)
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")
}
}
if err := conn.QueryRow(`SELECT EXISTS (SELECT 1 FROM message_chunks WHERE id = $1)`,
message.Id).Scan(&exists); err != nil {
client.Logger.Warn("Error while scanning for message_chunk", "message id", message.Id, "error", err)
return message.Id, errors.New("Could not save message chunk")
}
if exists {
client.Logger.Debug("Message chunk already exists. Skipping")
return message.Id, nil
}
if _, err := conn.Exec(`INSERT INTO message_chunks (conversation_id, message_id, content, content_hash)
VALUES ($1, $2, $3, $4)`); err != nil {
client.Logger.Warn("Error while creating message chunk", "message id", message.Id, "error", err)
}
return message.Id, nil
}
func (client DbClient) ensureDbExists() error {
maintenanceUrl := &url.URL{
Scheme: "postgres", Scheme: "postgres",
User: url.UserPassword(client.Cfg.User, client.Cfg.Password), User: url.UserPassword(client.Cfg.User, client.Cfg.Password),
Host: net.JoinHostPort(client.Cfg.Url, client.Cfg.Port), Host: net.JoinHostPort(client.Cfg.Url, client.Cfg.Port),
Path: client.Cfg.DbName, Path: "postgres",
} }
return u.String() 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
} }
+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()
}