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/url"
"os"
"strconv"
)
var ENV_PREFIX = "SOUV__"
const ENV_PREFIX = "SOUV__"
type Config struct {
Api ApiConfig
@@ -47,6 +48,7 @@ type EmbeddingConfig struct {
Key string
Model string
Dim int
Timeout int
BatchSize int
}
@@ -59,12 +61,14 @@ type HistoryConfig struct {
}
type ApiConfig struct {
Url string
Key string
Url string
Key string
Timeout int
}
type LLMConfig struct {
Model string
Model string
TitleModel string
}
type LogConfig struct {
@@ -116,7 +120,7 @@ func getLogTarget(target string) (io.Writer, error) {
return os.Stdout, nil
case "stderr":
return os.Stderr, nil
case "discrd":
case "discard":
return io.Discard, nil
default:
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 {
return &Config{
ApiConfig{
Url: "",
Key: ""},
Url: "",
Key: "",
Timeout: 300000,
},
LLMConfig{Model: ""},
[]LogConfig{{Level: slog.LevelInfo.Level(), Format: "text"}},
DbConfig{
@@ -145,6 +151,7 @@ func defaultConfig() *Config {
Key: "",
Model: "",
BatchSize: 64,
Timeout: 60000,
},
}
}
@@ -172,6 +179,22 @@ func overrideFromEnv(cfg *Config) {
*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 {
View File
+17 -9
View File
@@ -30,7 +30,7 @@ func Init(ctx context.Context, cfg config.DbConfig) (*DbClient, error) {
title TEXT,
title_source TEXT,
summary TEXT,
summary_through_seq int default 0
summary_through_seq int default 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`
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) {
cl.logger.Debug("Searching for conversation", "id", id)
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)
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) {
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 {
cl.logger.Error("Could not fetch conversations", "error", err)
return []model.Conversation{}, err
@@ -146,7 +146,7 @@ func (cl DbClient) GetConversations(ctx context.Context) ([]model.Conversation,
conversations := []model.Conversation{}
for rows.Next() {
var conv model.Conversation
rows.Scan(&conv.Id, &conv.Name, &conv.Summary)
rows.Scan(&conv.Id, &conv.Title, &conv.Summary)
conversations = append(conversations, conv)
}
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)
delta := 0
if conv.Id == "" {
if err := cl.pool.QueryRow(ctx, `INSERT INTO conversations(name, summary) VALUES($1, $2) RETURNING id`,
conv.Name,
if err := cl.pool.QueryRow(ctx, `INSERT INTO conversations(title, summary) VALUES($1, $2) RETURNING id`,
conv.Title,
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
}
}
@@ -215,7 +220,7 @@ func (cl DbClient) saveMessage(ctx context.Context, conversation_id string, mess
}
if exists {
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)
@@ -232,10 +237,13 @@ func (cl DbClient) Close() {
}
func Chunk(text string, size int, overlap int) []string {
if size <= overlap {
return []string{}
}
words := strings.Fields(text)
var chunks []string
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], " "))
if end == len(words) {
return chunks
+3 -3
View File
@@ -6,16 +6,16 @@ require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7
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 (
github.com/gopsql/db v1.2.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // 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/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/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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/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/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/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/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
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/pgvector/pgvector-go v0.4.0 h1:879hQCnuix1bkfa5TQISnnK9ik4Fo+cHj2vuZSgW5v4=
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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
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.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.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
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/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/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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())
os.Exit(1)
}
var logger = slog.Default().With("Compoent", "Main")
var logger = slog.Default().With("Component", "Main")
logger.Debug("Config initialized")
db, err := history.Init(ctx, config.Db)
+51 -4
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"net/http"
"strconv"
"time"
config "git.estatecloud.org/radumaco/souvenir/config"
"git.estatecloud.org/radumaco/souvenir/model"
@@ -19,6 +20,7 @@ const MODELS_API = "/v1/models"
type ChatClient struct {
Cfg config.Config
logger slog.Logger
client *http.Client
}
// Request Types
@@ -40,6 +42,11 @@ type ChatResponse struct {
Usage Usage `json:"usage"`
}
type RenameResponse struct {
Title string `json:"title"`
Summary string `json:"summary"`
}
type Choice struct {
Index int `json:"index"`
Message model.Message `json:"message"`
@@ -56,11 +63,21 @@ func NewLLMClient(cfg config.Config) *ChatClient {
return &ChatClient{
Cfg: cfg,
logger: *slog.Default().With("Component", "ChatClient"),
client: &http.Client{Timeout: time.Duration(cfg.Api.Timeout) * time.Millisecond},
}
}
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 {
cl.logger.Error("There was an error during the network request", "error", err.Error())
return nil, err
@@ -101,12 +118,42 @@ func (cl ChatClient) Models() ([]string, error) {
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{
Model: cl.Cfg.Llm.Model,
Messages: messages,
Tools: config.AvailableTools(),
}
return cl.Call(requestBody)
}
func (cl ChatClient) Call(requestBody ChatRequest) ([]model.Message, error) {
serialized, err := json.Marshal(requestBody)
if err != nil {
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 != "" {
req.Header.Set("Authorization", "Bearer "+cl.Cfg.Api.Key)
}
cl.logger.Debug("Executing llm call", "req", req)
resp, err := http.DefaultClient.Do(req)
cl.logger.Debug("Executing llm call")
resp, err := cl.client.Do(req)
if err != nil {
cl.logger.Error("There was an error during http call", "error", err.Error())
return []model.Message{}, err
+6 -3
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"net/http"
"strings"
"time"
config "git.estatecloud.org/radumaco/souvenir/config"
)
@@ -19,6 +20,7 @@ type Embedder struct {
Cfg config.EmbeddingConfig
logger slog.Logger
id string
client *http.Client
}
type EmbedRequest struct {
@@ -35,7 +37,7 @@ type EmbedResponse struct {
}
type Embedding struct {
Embedding []float32 `json:"embeddgin"`
Embedding []float32 `json:"embedding"`
Index int `json:"index"`
Object string `json:"object"`
}
@@ -44,6 +46,7 @@ func NewEmbedder(cfg config.EmbeddingConfig) *Embedder {
return &Embedder{
Cfg: cfg,
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 == "" {
e.id = strings.Map(func(r rune) rune {
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
default:
return '_'
@@ -89,7 +92,7 @@ func (e Embedder) EmbedBatch(text []string) ([][]float32, error) {
req.Header.Set("Authorization", "Bearer "+e.Cfg.Key)
}
e.logger.Debug("Executing embedding call", "req", req)
resp, err := http.DefaultClient.Do(req)
resp, err := e.client.Do(req)
if err != nil {
e.logger.Error("There was an error during http call", "error", err.Error())
return [][]float32{}, err
@@ -2,7 +2,7 @@ package model
type Conversation struct {
Id string
Name string
Title string
Summary string
Messages []Message
}
+164 -66
View File
@@ -26,23 +26,25 @@ const (
)
type uiModel struct {
logger slog.Logger
viewport viewport.Model
conversation model.Conversation
textarea textarea.Model
focus focusState
picker picker
senderStyle lipgloss.Style
agentStyle lipgloss.Style
errorStyle lipgloss.Style
client llm.ChatClient
errorMessage string
history history.DbClient
ctx context.Context
width int
height int
waiting bool
err error
logger slog.Logger
viewport viewport.Model
conversation model.Conversation
textarea textarea.Model
focus focusState
picker picker
senderStyle lipgloss.Style
agentStyle lipgloss.Style
errorStyle lipgloss.Style
statusStyle lipgloss.Style
client llm.ChatClient
statusMessage string
commands commandList
history history.DbClient
ctx context.Context
width int
height int
waiting bool
err error
}
type agentResponseMessage struct {
@@ -55,6 +57,12 @@ type conversationSavedMessage struct {
err error
}
type conversationRenamedMessage struct {
title string
summary string
err error
}
func InitialModel(ctx context.Context, config config.Config, client history.DbClient) uiModel {
ta := textarea.New()
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)
model := uiModel{
ui := uiModel{
textarea: ta,
conversation: model.Conversation{},
viewport: vp,
@@ -94,8 +102,10 @@ func InitialModel(ctx context.Context, config config.Config, client history.DbCl
logger: *slog.Default().With("Component", "TUI"),
err: nil,
}
model.picker = newPicker()
return model
ui.picker = newPicker()
ui.commands = newCommandList(30)
ui.commands.setAvailable(ui.buildCommands())
return ui
}
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.height = 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()
@@ -134,6 +148,10 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
switch msg.String() {
case "esc":
if m.commands.open {
m.closeCommands()
return m, nil
}
if 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":
m.textarea.InsertRune('\n')
return m, nil
case "enter":
m.clearErrorMessage()
input := m.textarea.Value()
m.textarea.SetValue("")
m.logger.Debug("Received enter. Creating new message", "message", input)
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())
case "up", "down":
if m.commands.open {
var cmd tea.Cmd
m.commands.list, cmd = m.commands.list.Update(msg)
return m, cmd
}
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:
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
v := m.textarea.Value()
if strings.HasPrefix(v, "/") {
m.openCommands()
} else {
m.closeCommands()
}
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)
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:
m.logger.Debug("Received agentResponseMessage")
m.waiting = false
@@ -259,10 +324,12 @@ func (m uiModel) View() tea.View {
}
parts := []string{m.viewport.View()}
if m.errorMessage != "" {
parts = append(parts, m.errorStyle.Render(m.errorMessage))
if m.statusMessage != "" {
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())
ui := lipgloss.JoinVertical(
lipgloss.Left,
@@ -270,10 +337,13 @@ func (m uiModel) View() tea.View {
c := m.textarea.Cursor()
if c != nil {
c.Y += lipgloss.Height(m.viewport.View()) + 1
if m.errorMessage != "" {
c.Y += lipgloss.Height(m.viewport.View())
if m.statusMessage != "" {
c.Y++
}
if m.commands.open {
c.Y += lipgloss.Height(m.commands.view())
}
gap := m.width - lipgloss.Width(ui)
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 {
m.logger.Debug("Calling agent", "query", input, "messages", messages)
return func() tea.Msg {
resp, err := m.client.Call(input, messages)
resp, err := m.client.Query(input, messages)
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 {
m.logger.Debug("Querying models")
return func() tea.Msg {
@@ -326,8 +411,8 @@ func (m uiModel) getHistory() tea.Cmd {
items := []modelItem{}
for _, conv := range resp {
name := conv.Id
if conv.Name != "" {
name = conv.Name
if conv.Title != "" {
name = conv.Title
}
items = append(items, modelItem{name: name, description: conv.Summary})
}
@@ -372,15 +457,28 @@ func (m *uiModel) renderMessages() string {
}
func (m *uiModel) setErrorMessage(message string) {
if m.errorMessage == "" {
m.viewport.SetHeight(m.viewport.Height() - 1)
}
m.errorMessage = message
m.statusMessage = m.errorStyle.Render(message)
}
func (m *uiModel) clearErrorMessage() {
if m.errorMessage != "" {
m.viewport.SetHeight(m.viewport.Height() + 1)
m.errorMessage = ""
func (m *uiModel) setStatusMessage(message string) {
if message == "" {
m.statusMessage = m.statusStyle.Render("model: " + m.client.Cfg.Llm.Model + " url: " + m.client.Cfg.Api.Url)
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 / __/ | | | | |
|___/\___/ \__,_| \_/ \___|_| |_|_|_|`
const Commands = `
/history
/models
/exit
`
func renderLanding(width int, height int) string {
return lipgloss.PlaceVertical(height, 0.7,
lipgloss.JoinVertical(lipgloss.Center,
lipgloss.PlaceHorizontal(width,
lipgloss.Center,
lipgloss.JoinHorizontal(lipgloss.Center, Logo, Wordmark)),
lipgloss.PlaceHorizontal(width,
lipgloss.Center,
Commands)))
))
}