Added command list

This commit is contained in:
Radu Macocian (admac)
2026-06-25 14:36:49 +02:00
parent 491510ca78
commit 9537237a85
14 changed files with 447 additions and 103 deletions
+30 -7
View File
@@ -8,9 +8,10 @@ import (
"net" "net"
"net/url" "net/url"
"os" "os"
"strconv"
) )
var ENV_PREFIX = "SOUV__" const ENV_PREFIX = "SOUV__"
type Config struct { type Config struct {
Api ApiConfig Api ApiConfig
@@ -47,6 +48,7 @@ type EmbeddingConfig struct {
Key string Key string
Model string Model string
Dim int Dim int
Timeout int
BatchSize int BatchSize int
} }
@@ -59,12 +61,14 @@ type HistoryConfig struct {
} }
type ApiConfig struct { type ApiConfig struct {
Url string Url string
Key string Key string
Timeout int
} }
type LLMConfig struct { type LLMConfig struct {
Model string Model string
TitleModel string
} }
type LogConfig struct { type LogConfig struct {
@@ -116,7 +120,7 @@ func getLogTarget(target string) (io.Writer, error) {
return os.Stdout, nil return os.Stdout, nil
case "stderr": case "stderr":
return os.Stderr, nil return os.Stderr, nil
case "discrd": case "discard":
return io.Discard, nil return io.Discard, nil
default: default:
return os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) return os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
@@ -126,8 +130,10 @@ func getLogTarget(target string) (io.Writer, error) {
func defaultConfig() *Config { func defaultConfig() *Config {
return &Config{ return &Config{
ApiConfig{ ApiConfig{
Url: "", Url: "",
Key: ""}, Key: "",
Timeout: 300000,
},
LLMConfig{Model: ""}, LLMConfig{Model: ""},
[]LogConfig{{Level: slog.LevelInfo.Level(), Format: "text"}}, []LogConfig{{Level: slog.LevelInfo.Level(), Format: "text"}},
DbConfig{ DbConfig{
@@ -145,6 +151,7 @@ func defaultConfig() *Config {
Key: "", Key: "",
Model: "", Model: "",
BatchSize: 64, BatchSize: 64,
Timeout: 60000,
}, },
} }
} }
@@ -172,6 +179,22 @@ func overrideFromEnv(cfg *Config) {
*override.conf = val *override.conf = val
} }
} }
durationOverrides := []struct {
conf *int
key string
}{
{conf: &cfg.Api.Timeout, key: ENV_PREFIX + "api__timeout"},
{conf: &cfg.Embedding.Timeout, key: ENV_PREFIX + "embedding__timeout"},
}
for _, override := range durationOverrides {
val := os.Getenv(override.key)
if val != "" {
parsed, err := strconv.Atoi(val)
if err == nil {
*override.conf = parsed
}
}
}
} }
func (cfg Config) validate() error { func (cfg Config) validate() error {
View File
+17 -9
View File
@@ -30,7 +30,7 @@ func Init(ctx context.Context, cfg config.DbConfig) (*DbClient, error) {
title TEXT, title TEXT,
title_source TEXT, title_source TEXT,
summary TEXT, summary TEXT,
summary_through_seq int default 0 summary_through_seq int default 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)` )`
const createMessageTable = ` const createMessageTable = `
@@ -102,7 +102,7 @@ func Init(ctx context.Context, cfg config.DbConfig) (*DbClient, error) {
func (cl DbClient) GetConversation(ctx context.Context, id string) (model.Conversation, error) { func (cl DbClient) GetConversation(ctx context.Context, id string) (model.Conversation, error) {
cl.logger.Debug("Searching for conversation", "id", id) cl.logger.Debug("Searching for conversation", "id", id)
var conv model.Conversation var conv model.Conversation
if err := cl.pool.QueryRow(ctx, "SELECT id, name, summary FROM conversations WHERE id = $1", id).Scan(&conv.Id, &conv.Name, &conv.Summary); err != nil { if err := cl.pool.QueryRow(ctx, "SELECT id, title, summary FROM conversations WHERE id = $1", id).Scan(&conv.Id, &conv.Title, &conv.Summary); err != nil {
cl.logger.Error("Could not fetch conversation", "id", id, "error", err) cl.logger.Error("Could not fetch conversation", "id", id, "error", err)
return conv, err return conv, err
} }
@@ -137,7 +137,7 @@ func (cl DbClient) GetConversation(ctx context.Context, id string) (model.Conver
func (cl DbClient) GetConversations(ctx context.Context) ([]model.Conversation, error) { func (cl DbClient) GetConversations(ctx context.Context) ([]model.Conversation, error) {
cl.logger.Debug("Fetching conversations") cl.logger.Debug("Fetching conversations")
rows, err := cl.pool.Query(ctx, "SELECT id, name, summary FROM conversations") rows, err := cl.pool.Query(ctx, "SELECT id, title, summary FROM conversations")
if err != nil { if err != nil {
cl.logger.Error("Could not fetch conversations", "error", err) cl.logger.Error("Could not fetch conversations", "error", err)
return []model.Conversation{}, err return []model.Conversation{}, err
@@ -146,7 +146,7 @@ func (cl DbClient) GetConversations(ctx context.Context) ([]model.Conversation,
conversations := []model.Conversation{} conversations := []model.Conversation{}
for rows.Next() { for rows.Next() {
var conv model.Conversation var conv model.Conversation
rows.Scan(&conv.Id, &conv.Name, &conv.Summary) rows.Scan(&conv.Id, &conv.Title, &conv.Summary)
conversations = append(conversations, conv) conversations = append(conversations, conv)
} }
cl.logger.Debug("Found conversations", "count", len(conversations)) cl.logger.Debug("Found conversations", "count", len(conversations))
@@ -157,10 +157,15 @@ func (cl DbClient) SaveConversation(ctx context.Context, conv model.Conversation
cl.logger.Debug("Saving conversation", "conversation", conv) cl.logger.Debug("Saving conversation", "conversation", conv)
delta := 0 delta := 0
if conv.Id == "" { if conv.Id == "" {
if err := cl.pool.QueryRow(ctx, `INSERT INTO conversations(name, summary) VALUES($1, $2) RETURNING id`, if err := cl.pool.QueryRow(ctx, `INSERT INTO conversations(title, summary) VALUES($1, $2) RETURNING id`,
conv.Name, conv.Title,
conv.Summary).Scan(&conv.Id); err != nil { conv.Summary).Scan(&conv.Id); err != nil {
cl.logger.Error("Could not create conversation", "name", conv.Name, "error", err) cl.logger.Error("Could not create conversation", "title", conv.Title, "error", err)
return conv, err
}
} else {
if _, err := cl.pool.Exec(ctx, `UPDATE conversations SET title = $1, summary = $2 WHERE id = $3`, conv.Title, conv.Summary, conv.Id); err != nil {
cl.logger.Error("Could not update conversation", "id", conv.Id, "error", err)
return conv, err return conv, err
} }
} }
@@ -215,7 +220,7 @@ func (cl DbClient) saveMessage(ctx context.Context, conversation_id string, mess
} }
if exists { if exists {
cl.logger.Debug("Message chunk already exists. Skipping") cl.logger.Debug("Message chunk already exists. Skipping")
return message.Id, nil continue
} }
if _, err := cl.pool.Exec(ctx, `INSERT INTO message_chunks (conversation_id, message_id, content, content_hash) if _, err := cl.pool.Exec(ctx, `INSERT INTO message_chunks (conversation_id, message_id, content, content_hash)
@@ -232,10 +237,13 @@ func (cl DbClient) Close() {
} }
func Chunk(text string, size int, overlap int) []string { func Chunk(text string, size int, overlap int) []string {
if size <= overlap {
return []string{}
}
words := strings.Fields(text) words := strings.Fields(text)
var chunks []string var chunks []string
for start := 0; start < len(words); start += size - overlap { for start := 0; start < len(words); start += size - overlap {
end := min(start + size, len(words)) end := min(start+size, len(words))
chunks = append(chunks, strings.Join(words[start:end], " ")) chunks = append(chunks, strings.Join(words[start:end], " "))
if end == len(words) { if end == len(words) {
return chunks return chunks
+3 -3
View File
@@ -6,16 +6,16 @@ require (
charm.land/bubbles/v2 v2.1.0 charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7 charm.land/bubbletea/v2 v2.0.7
charm.land/lipgloss/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.3
github.com/jackc/pgx/v5 v5.4.3
github.com/pgvector/pgvector-go v0.4.0
github.com/sahilm/fuzzy v0.1.1
) )
require ( require (
github.com/gopsql/db v1.2.1 // indirect github.com/gopsql/db v1.2.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
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/puddle/v2 v2.2.1 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/pgvector/pgvector-go v0.4.0 // 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
) )
+6
View File
@@ -30,6 +30,7 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gopsql/db v1.2.1 h1:dzi13FK9OCeV2pARpmn4kSp351Uzg/Le2mEKo43H+EQ= github.com/gopsql/db v1.2.1 h1:dzi13FK9OCeV2pARpmn4kSp351Uzg/Le2mEKo43H+EQ=
github.com/gopsql/db v1.2.1/go.mod h1:plazrQDxoOfbv749q6AqZyR0wtPak19s4uw8J8pnMYA= github.com/gopsql/db v1.2.1/go.mod h1:plazrQDxoOfbv749q6AqZyR0wtPak19s4uw8J8pnMYA=
@@ -49,6 +50,8 @@ github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NB
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
@@ -57,6 +60,7 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/pgvector/pgvector-go v0.4.0 h1:879hQCnuix1bkfa5TQISnnK9ik4Fo+cHj2vuZSgW5v4= github.com/pgvector/pgvector-go v0.4.0 h1:879hQCnuix1bkfa5TQISnnK9ik4Fo+cHj2vuZSgW5v4=
github.com/pgvector/pgvector-go v0.4.0/go.mod h1:4fSXyjl1TYAIdByAql6JazKWRr2s7J0g4hcRY5cBFCk= github.com/pgvector/pgvector-go v0.4.0/go.mod h1:4fSXyjl1TYAIdByAql6JazKWRr2s7J0g4hcRY5cBFCk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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=
@@ -70,6 +74,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
@@ -121,4 +126,5 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+1 -1
View File
@@ -19,7 +19,7 @@ func main() {
slog.Error("Config error:", "message", err.Error()) slog.Error("Config error:", "message", err.Error())
os.Exit(1) os.Exit(1)
} }
var logger = slog.Default().With("Compoent", "Main") var logger = slog.Default().With("Component", "Main")
logger.Debug("Config initialized") logger.Debug("Config initialized")
db, err := history.Init(ctx, config.Db) db, err := history.Init(ctx, config.Db)
+51 -4
View File
@@ -8,6 +8,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"time"
config "git.estatecloud.org/radumaco/souvenir/config" config "git.estatecloud.org/radumaco/souvenir/config"
"git.estatecloud.org/radumaco/souvenir/model" "git.estatecloud.org/radumaco/souvenir/model"
@@ -19,6 +20,7 @@ const MODELS_API = "/v1/models"
type ChatClient struct { type ChatClient struct {
Cfg config.Config Cfg config.Config
logger slog.Logger logger slog.Logger
client *http.Client
} }
// Request Types // Request Types
@@ -40,6 +42,11 @@ type ChatResponse struct {
Usage Usage `json:"usage"` Usage Usage `json:"usage"`
} }
type RenameResponse struct {
Title string `json:"title"`
Summary string `json:"summary"`
}
type Choice struct { type Choice struct {
Index int `json:"index"` Index int `json:"index"`
Message model.Message `json:"message"` Message model.Message `json:"message"`
@@ -56,11 +63,21 @@ func NewLLMClient(cfg config.Config) *ChatClient {
return &ChatClient{ return &ChatClient{
Cfg: cfg, Cfg: cfg,
logger: *slog.Default().With("Component", "ChatClient"), logger: *slog.Default().With("Component", "ChatClient"),
client: &http.Client{Timeout: time.Duration(cfg.Api.Timeout) * time.Millisecond},
} }
} }
func (cl ChatClient) Models() ([]string, error) { func (cl ChatClient) Models() ([]string, error) {
resp, err := http.Get(cl.Cfg.Api.Url + MODELS_API) req, err := http.NewRequest("GET", cl.Cfg.Api.Url+MODELS_API, nil)
if err != nil {
cl.logger.Error("There was an error creating the request", "error", err.Error())
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if cl.Cfg.Api.Key != "" {
req.Header.Set("Authorization", "Bearer "+cl.Cfg.Api.Key)
}
resp, err := cl.client.Do(req)
if err != nil { if err != nil {
cl.logger.Error("There was an error during the network request", "error", err.Error()) cl.logger.Error("There was an error during the network request", "error", err.Error())
return nil, err return nil, err
@@ -101,12 +118,42 @@ func (cl ChatClient) Models() ([]string, error) {
return ret, nil return ret, nil
} }
func (cl ChatClient) Call(query string, messages []model.Message) ([]model.Message, error) { func (cl ChatClient) Rename(messages []model.Message) (RenameResponse, error) {
llm := cl.Cfg.Llm.TitleModel
if llm == "" {
llm = cl.Cfg.Llm.Model
}
request := ChatRequest{
Model: llm,
Messages: append([]model.Message{{
Role: "system",
Content: "Reply with ONLY JSON with the form: {\"title\":<title>, \"summary\":<summary>}. Title <= 10 words. Summary 2-3 sentances.",
}}, messages...),
}
cl.logger.Debug("Calling rename", "request", request)
out, err := cl.Call(request)
if err != nil {
cl.logger.Error("Error while asking for rename", "error", err)
return RenameResponse{}, err
}
var meta RenameResponse
if err := json.Unmarshal([]byte(out[0].Content), &meta); err != nil {
cl.logger.Error("Could not parse the rename response", "content", out[0].Content)
return meta, err
}
return meta, nil
}
func (cl ChatClient) Query(query string, messages []model.Message) ([]model.Message, error) {
requestBody := ChatRequest{ requestBody := ChatRequest{
Model: cl.Cfg.Llm.Model, Model: cl.Cfg.Llm.Model,
Messages: messages, Messages: messages,
Tools: config.AvailableTools(), Tools: config.AvailableTools(),
} }
return cl.Call(requestBody)
}
func (cl ChatClient) Call(requestBody ChatRequest) ([]model.Message, error) {
serialized, err := json.Marshal(requestBody) serialized, err := json.Marshal(requestBody)
if err != nil { if err != nil {
cl.logger.Error("There was an error marshelling the request body", cl.logger.Error("There was an error marshelling the request body",
@@ -125,8 +172,8 @@ func (cl ChatClient) Call(query string, messages []model.Message) ([]model.Messa
if cl.Cfg.Api.Key != "" { if cl.Cfg.Api.Key != "" {
req.Header.Set("Authorization", "Bearer "+cl.Cfg.Api.Key) req.Header.Set("Authorization", "Bearer "+cl.Cfg.Api.Key)
} }
cl.logger.Debug("Executing llm call", "req", req) cl.logger.Debug("Executing llm call")
resp, err := http.DefaultClient.Do(req) resp, err := cl.client.Do(req)
if err != nil { if err != nil {
cl.logger.Error("There was an error during http call", "error", err.Error()) cl.logger.Error("There was an error during http call", "error", err.Error())
return []model.Message{}, err return []model.Message{}, err
+6 -3
View File
@@ -9,6 +9,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
"time"
config "git.estatecloud.org/radumaco/souvenir/config" config "git.estatecloud.org/radumaco/souvenir/config"
) )
@@ -19,6 +20,7 @@ type Embedder struct {
Cfg config.EmbeddingConfig Cfg config.EmbeddingConfig
logger slog.Logger logger slog.Logger
id string id string
client *http.Client
} }
type EmbedRequest struct { type EmbedRequest struct {
@@ -35,7 +37,7 @@ type EmbedResponse struct {
} }
type Embedding struct { type Embedding struct {
Embedding []float32 `json:"embeddgin"` Embedding []float32 `json:"embedding"`
Index int `json:"index"` Index int `json:"index"`
Object string `json:"object"` Object string `json:"object"`
} }
@@ -44,6 +46,7 @@ func NewEmbedder(cfg config.EmbeddingConfig) *Embedder {
return &Embedder{ return &Embedder{
Cfg: cfg, Cfg: cfg,
logger: *slog.Default().With("Component", "Embedder"), logger: *slog.Default().With("Component", "Embedder"),
client: &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Millisecond},
} }
} }
@@ -55,7 +58,7 @@ func (e Embedder) ID() string {
if e.id == "" { if e.id == "" {
e.id = strings.Map(func(r rune) rune { e.id = strings.Map(func(r rune) rune {
switch { switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r <= '0' && r <= '9': case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return r return r
default: default:
return '_' return '_'
@@ -89,7 +92,7 @@ func (e Embedder) EmbedBatch(text []string) ([][]float32, error) {
req.Header.Set("Authorization", "Bearer "+e.Cfg.Key) req.Header.Set("Authorization", "Bearer "+e.Cfg.Key)
} }
e.logger.Debug("Executing embedding call", "req", req) e.logger.Debug("Executing embedding call", "req", req)
resp, err := http.DefaultClient.Do(req) resp, err := e.client.Do(req)
if err != nil { if err != nil {
e.logger.Error("There was an error during http call", "error", err.Error()) e.logger.Error("There was an error during http call", "error", err.Error())
return [][]float32{}, err return [][]float32{}, err
@@ -2,7 +2,7 @@ package model
type Conversation struct { type Conversation struct {
Id string Id string
Name string Title string
Summary string Summary string
Messages []Message Messages []Message
} }
+164 -66
View File
@@ -26,23 +26,25 @@ const (
) )
type uiModel struct { type uiModel struct {
logger slog.Logger logger slog.Logger
viewport viewport.Model viewport viewport.Model
conversation model.Conversation conversation model.Conversation
textarea textarea.Model textarea textarea.Model
focus focusState focus focusState
picker picker picker picker
senderStyle lipgloss.Style senderStyle lipgloss.Style
agentStyle lipgloss.Style agentStyle lipgloss.Style
errorStyle lipgloss.Style errorStyle lipgloss.Style
client llm.ChatClient statusStyle lipgloss.Style
errorMessage string client llm.ChatClient
history history.DbClient statusMessage string
ctx context.Context commands commandList
width int history history.DbClient
height int ctx context.Context
waiting bool width int
err error height int
waiting bool
err error
} }
type agentResponseMessage struct { type agentResponseMessage struct {
@@ -55,6 +57,12 @@ type conversationSavedMessage struct {
err error err error
} }
type conversationRenamedMessage struct {
title string
summary string
err error
}
func InitialModel(ctx context.Context, config config.Config, client history.DbClient) uiModel { func InitialModel(ctx context.Context, config config.Config, client history.DbClient) uiModel {
ta := textarea.New() ta := textarea.New()
ta.Placeholder = "Send a message..." ta.Placeholder = "Send a message..."
@@ -80,7 +88,7 @@ func InitialModel(ctx context.Context, config config.Config, client history.DbCl
ta.KeyMap.InsertNewline.SetEnabled(false) ta.KeyMap.InsertNewline.SetEnabled(false)
model := uiModel{ ui := uiModel{
textarea: ta, textarea: ta,
conversation: model.Conversation{}, conversation: model.Conversation{},
viewport: vp, viewport: vp,
@@ -94,8 +102,10 @@ func InitialModel(ctx context.Context, config config.Config, client history.DbCl
logger: *slog.Default().With("Component", "TUI"), logger: *slog.Default().With("Component", "TUI"),
err: nil, err: nil,
} }
model.picker = newPicker() ui.picker = newPicker()
return model ui.commands = newCommandList(30)
ui.commands.setAvailable(ui.buildCommands())
return ui
} }
func (m uiModel) Init() tea.Cmd { func (m uiModel) Init() tea.Cmd {
@@ -113,6 +123,10 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.picker.SetSize(msg.Width, msg.Height) m.picker.SetSize(msg.Width, msg.Height)
m.commands.list.SetSize(msg.Width*80/100, commandDropdownHeight)
if m.commands.open {
m.viewport.SetHeight(m.viewport.Height() - commandDropdownHeight)
}
var messages = m.renderMessages() var messages = m.renderMessages()
@@ -134,6 +148,10 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
switch msg.String() { switch msg.String() {
case "esc": case "esc":
if m.commands.open {
m.closeCommands()
return m, nil
}
if m.focus != focusChat { if m.focus != focusChat {
m.focus = focusChat m.focus = focusChat
} }
@@ -144,40 +162,75 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "shift+enter", "ctrl+j", "alt+enter": case "shift+enter", "ctrl+j", "alt+enter":
m.textarea.InsertRune('\n') m.textarea.InsertRune('\n')
return m, nil return m, nil
case "enter": case "up", "down":
m.clearErrorMessage() if m.commands.open {
input := m.textarea.Value() var cmd tea.Cmd
m.textarea.SetValue("") m.commands.list, cmd = m.commands.list.Update(msg)
m.logger.Debug("Received enter. Creating new message", "message", input) return m, cmd
switch input {
case "/history":
m.logger.Debug("Received history call")
m.focus = focusHistory
return m, m.getHistory()
case "/models":
m.logger.Debug("Received models sequence")
m.focus = focusModels
return m, m.getModels()
case "/exit":
m.logger.Debug("Received exit sequence. Quiting")
fmt.Println(m.textarea.Value())
return m, tea.Quit
default:
m.conversation.Messages = append(m.conversation.Messages, model.Message{
Content: input,
Role: "user",
Seq: len(m.conversation.Messages) + 1,
})
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, tea.Batch(m.callAgent(input, m.conversation.Messages), m.saveConversation())
} }
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
case "tab":
if m.commands.open {
if sel, ok := m.commands.selectedItem(); ok {
m.textarea.SetValue("/" + sel.name + " ")
}
m.closeCommands()
return m, nil
}
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
case "enter":
m.setStatusMessage("")
input := m.textarea.Value()
if m.commands.open {
if sel, ok := m.commands.selectedItem(); ok {
m.textarea.SetValue("")
m.closeCommands()
mm, cmd := sel.handler(m)
return mm, cmd
}
}
if after, ok :=strings.CutPrefix(input, "/"); ok {
name := after
for _, c := range m.commands.available {
if c.name == name {
m.textarea.SetValue("")
m.closeCommands()
mm, cmd := c.handler(m)
return mm, cmd
}
}
}
m.textarea.SetValue("")
m.closeCommands()
m.logger.Debug("Received enter. Creating new message", "message", input)
m.conversation.Messages = append(m.conversation.Messages, model.Message{
Content: input,
Role: "user",
Seq: len(m.conversation.Messages) + 1,
})
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, tea.Batch(m.callAgent(input, m.conversation.Messages), m.saveConversation())
default: default:
var cmd tea.Cmd var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg) m.textarea, cmd = m.textarea.Update(msg)
v := m.textarea.Value()
if strings.HasPrefix(v, "/") {
m.openCommands()
} else {
m.closeCommands()
}
return m, cmd return m, cmd
} }
@@ -187,6 +240,18 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea, cmd = m.textarea.Update(msg) m.textarea, cmd = m.textarea.Update(msg)
return m, cmd return m, cmd
case conversationRenamedMessage:
m.logger.Debug("Received messageRenamedMessage")
m.waiting = false
if msg.err != nil {
m.logger.Error("There was an error", "error", msg.err)
m.setErrorMessage(msg.err.Error())
}
m.setStatusMessage("Conversation renamed to: " + msg.title)
m.conversation.Title = msg.title
m.conversation.Summary = msg.summary
return m, nil
case agentResponseMessage: case agentResponseMessage:
m.logger.Debug("Received agentResponseMessage") m.logger.Debug("Received agentResponseMessage")
m.waiting = false m.waiting = false
@@ -259,10 +324,12 @@ func (m uiModel) View() tea.View {
} }
parts := []string{m.viewport.View()} parts := []string{m.viewport.View()}
if m.errorMessage != "" { if m.statusMessage != "" {
parts = append(parts, m.errorStyle.Render(m.errorMessage)) parts = append(parts, m.statusMessage)
}
if m.commands.open {
parts = append(parts, m.commands.view())
} }
parts = append(parts, "model: "+m.client.Cfg.Llm.Model+"; url: "+m.client.Cfg.Api.Url)
parts = append(parts, m.textarea.View()) parts = append(parts, m.textarea.View())
ui := lipgloss.JoinVertical( ui := lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
@@ -270,10 +337,13 @@ func (m uiModel) View() tea.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())
if m.errorMessage != "" { if m.statusMessage != "" {
c.Y++ c.Y++
} }
if m.commands.open {
c.Y += lipgloss.Height(m.commands.view())
}
gap := m.width - lipgloss.Width(ui) gap := m.width - lipgloss.Width(ui)
c.X += max(gap/2, 0) c.X += max(gap/2, 0)
} }
@@ -286,7 +356,7 @@ 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 {
m.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.Query(input, messages)
return agentResponseMessage{response: resp, err: err} return agentResponseMessage{response: resp, err: err}
} }
} }
@@ -299,6 +369,21 @@ func (m uiModel) saveConversation() tea.Cmd {
} }
} }
func (m uiModel) renameConversation() tea.Cmd {
m.logger.Debug("Renaming conversation", "conversation", m.conversation)
return func() tea.Msg {
meta, err := m.client.Rename(m.conversation.Messages)
if err != nil {
return conversationRenamedMessage{title: meta.Title, summary: meta.Summary, err: err}
}
m.conversation.Title = meta.Title
m.conversation.Summary = meta.Summary
m.conversation, err = m.history.SaveConversation(m.ctx, m.conversation)
return conversationRenamedMessage{title: m.conversation.Title, summary: m.conversation.Summary, err: err}
}
}
func (m uiModel) getModels() tea.Cmd { func (m uiModel) getModels() tea.Cmd {
m.logger.Debug("Querying models") m.logger.Debug("Querying models")
return func() tea.Msg { return func() tea.Msg {
@@ -326,8 +411,8 @@ func (m uiModel) getHistory() tea.Cmd {
items := []modelItem{} items := []modelItem{}
for _, conv := range resp { for _, conv := range resp {
name := conv.Id name := conv.Id
if conv.Name != "" { if conv.Title != "" {
name = conv.Name name = conv.Title
} }
items = append(items, modelItem{name: name, description: conv.Summary}) items = append(items, modelItem{name: name, description: conv.Summary})
} }
@@ -372,15 +457,28 @@ func (m *uiModel) renderMessages() string {
} }
func (m *uiModel) setErrorMessage(message string) { func (m *uiModel) setErrorMessage(message string) {
if m.errorMessage == "" { m.statusMessage = m.errorStyle.Render(message)
m.viewport.SetHeight(m.viewport.Height() - 1)
}
m.errorMessage = message
} }
func (m *uiModel) clearErrorMessage() { func (m *uiModel) setStatusMessage(message string) {
if m.errorMessage != "" { if message == "" {
m.viewport.SetHeight(m.viewport.Height() + 1) m.statusMessage = m.statusStyle.Render("model: " + m.client.Cfg.Llm.Model + " url: " + m.client.Cfg.Api.Url)
m.errorMessage = "" return
}
m.statusMessage = m.statusStyle.Render(message)
}
func (m *uiModel) openCommands() {
if !m.commands.open {
m.commands.open = true
m.viewport.SetHeight(m.viewport.Height() - commandDropdownHeight)
}
m.commands.filter(strings.TrimPrefix(m.textarea.Value(), "/"))
}
func (m *uiModel) closeCommands() {
if m.commands.open {
m.commands.open = false
m.viewport.SetHeight(m.viewport.Height() + commandDropdownHeight)
} }
} }
+167
View File
@@ -0,0 +1,167 @@
package ui
import (
"fmt"
"io"
"strings"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/sahilm/fuzzy"
)
const commandDropdownHeight = 8
var (
dropdownStyle = lipgloss.NewStyle().
Background(lipgloss.Color("236")).
Padding(0, 1)
inlineTitleStyle = lipgloss.NewStyle().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("86")).Bold(true)
inlineTitleSelStyle = lipgloss.NewStyle().Background(lipgloss.Color("60")).Foreground(lipgloss.Color("86")).Bold(true)
inlineDescStyle = lipgloss.NewStyle().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("245"))
inlineDescSelStyle = lipgloss.NewStyle().Background(lipgloss.Color("60")).Foreground(lipgloss.Color("255"))
inlinePadStyle = lipgloss.NewStyle().Background(lipgloss.Color("236"))
inlinePadSelStyle = lipgloss.NewStyle().Background(lipgloss.Color("60"))
)
type inlineDelegate struct{}
func (inlineDelegate) Height() int { return 1 }
func (inlineDelegate) Spacing() int { return 0 }
func (inlineDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (inlineDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
c, ok := item.(command)
if !ok {
return
}
if m.Width() <= 0 {
return
}
title := "/" + c.name
desc := c.description
titleStyle := inlineTitleStyle
descStyle := inlineDescStyle
padStyle := inlinePadStyle
if index == m.Index() {
titleStyle = inlineTitleSelStyle
descStyle = inlineDescSelStyle
padStyle = inlinePadSelStyle
}
renderedTitle := titleStyle.Render(title)
renderedDesc := descStyle.Render(desc)
pad := max(m.Width()-1-lipgloss.Width(renderedTitle)-lipgloss.Width(renderedDesc), 1)
fmt.Fprintf(w, "%s%s%s%s", renderedTitle, padStyle.Render(" "), renderedDesc, padStyle.Render(strings.Repeat(" ", pad)))
}
type command struct {
name string
description string
handler func(m uiModel) (uiModel, tea.Cmd)
}
func (c command) FilterValue() string { return c.name }
func (c command) Title() string { return "/" + c.name }
func (c command) Description() string { return c.description }
type commandList struct {
list list.Model
open bool
available []command
}
func newCommandList(width int) commandList {
l := list.New(nil, inlineDelegate{}, width, 1)
l.Title = "Commands"
l.SetShowFilter(false)
l.SetFilteringEnabled(false)
l.SetShowStatusBar(false)
l.SetShowPagination(false)
l.SetShowHelp(false)
l.DisableQuitKeybindings()
return commandList{list: l}
}
func (m uiModel) buildCommands() []command {
return []command{
{
name: "exit",
description: "Quit the application",
handler: func(m uiModel) (uiModel, tea.Cmd) {
m.logger.Debug("Received exit sequence. Quiting")
return m, tea.Quit
},
},
{
name: "history",
description: "Open conversation history",
handler: func(m uiModel) (uiModel, tea.Cmd) {
m.logger.Debug("Received history call")
m.focus = focusHistory
return m, m.getHistory()
},
},
{
name: "models",
description: "Choose an LLM model",
handler: func(m uiModel) (uiModel, tea.Cmd) {
m.logger.Debug("Received models sequence")
m.focus = focusModels
return m, m.getModels()
},
},
{
name: "rename",
description: "Rename the current conversation",
handler: func(m uiModel) (uiModel, tea.Cmd) {
m.logger.Debug("Received rename request")
m.waiting = true
return m, m.renameConversation()
},
},
}
}
func (cl *commandList) setAvailable(cmds []command) {
cl.available = cmds
items := make([]list.Item, len(cmds))
for i, c := range cmds {
items[i] = c
}
cl.list.SetItems(items)
cl.list.SetHeight(len(items) + 1)
}
func (cl *commandList) filter(query string) {
var matches []list.Item
if query == "" {
matches = make([]list.Item, len(cl.available))
for i, c := range cl.available {
matches[i] = c
}
} else {
names := make([]string, len(cl.available))
for i, c := range cl.available {
names[i] = c.name
}
result := fuzzy.Find(query, names)
matches = make([]list.Item, len(result))
for i, r := range result {
matches[i] = cl.available[r.Index]
}
}
cl.list.SetItems(matches)
cl.list.ResetSelected()
}
func (cl commandList) view() string {
return dropdownStyle.Render(cl.list.View())
}
func (cl commandList) selectedItem() (command, bool) {
c, ok := cl.list.SelectedItem().(command)
return c, ok
}
+1 -9
View File
@@ -38,19 +38,11 @@ const Wordmark = `
\__ \ (_) | |_| |\ V / __/ | | | | | \__ \ (_) | |_| |\ V / __/ | | | | |
|___/\___/ \__,_| \_/ \___|_| |_|_|_|` |___/\___/ \__,_| \_/ \___|_| |_|_|_|`
const Commands = `
/history
/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.JoinVertical(lipgloss.Center, lipgloss.JoinVertical(lipgloss.Center,
lipgloss.PlaceHorizontal(width, lipgloss.PlaceHorizontal(width,
lipgloss.Center, lipgloss.Center,
lipgloss.JoinHorizontal(lipgloss.Center, Logo, Wordmark)), lipgloss.JoinHorizontal(lipgloss.Center, Logo, Wordmark)),
lipgloss.PlaceHorizontal(width, ))
lipgloss.Center,
Commands)))
} }