Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 789f3b5dd3 | |||
| 8599de09de |
+26
-1
@@ -5,6 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +27,23 @@ type DbConfig struct {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package embed
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package embed
|
||||||
|
|
||||||
+179
-31
@@ -1,12 +1,16 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,36 +19,42 @@ type DbClient struct {
|
|||||||
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(),
|
||||||
|
title TEXT,
|
||||||
|
title_source TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
summary_through_seq int default 0
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
)`
|
)`
|
||||||
const createMessageTable = `
|
const createMessageTable = `
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
conversation_id UUID,
|
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
)`
|
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,7 +71,7 @@ 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 {
|
||||||
@@ -69,27 +79,165 @@ func (client DbClient) init() error {
|
|||||||
} 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type Conversation struct {
|
||||||
|
Id string
|
||||||
|
Name string
|
||||||
|
Summary string
|
||||||
|
Messages []Message
|
||||||
|
}
|
||||||
+2
-7
@@ -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"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type ToolCall struct {
|
||||||
|
Id string `json:"-"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
FunctionName string `json:"function"`
|
||||||
|
FunctionArguments string `json:"arguments"`
|
||||||
|
}
|
||||||
+144
-41
@@ -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,20 +20,23 @@ 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
|
||||||
|
errorMessage string
|
||||||
|
history history.DbClient
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
waiting bool
|
waiting bool
|
||||||
@@ -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)
|
||||||
@@ -76,17 +80,18 @@ func InitialModel(config config.Config) uiModel {
|
|||||||
|
|
||||||
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,
|
||||||
|
history: client,
|
||||||
logger: *slog.Default().With("Component", "TUI"),
|
logger: *slog.Default().With("Component", "TUI"),
|
||||||
err: nil,
|
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 {
|
||||||
|
case focusModels:
|
||||||
|
m.logger.Debug("Received pickerChosenMsg", "model", msg.id)
|
||||||
m.client.Cfg.Llm.Model = msg.id
|
m.client.Cfg.Llm.Model = msg.id
|
||||||
m.waiting = false
|
m.waiting = false
|
||||||
m.focus = focusChat
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.focus == focusOverlay {
|
case pickerDismissMsg:
|
||||||
|
m.logger.Debug("Received pickerDismissMsg")
|
||||||
|
m.waiting = false
|
||||||
|
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 != 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,32 +288,72 @@ 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(m.errorStyle.Render("Error:"))
|
|
||||||
result.WriteString(message.Content)
|
|
||||||
}
|
|
||||||
result.WriteString("\n")
|
result.WriteString("\n")
|
||||||
|
|
||||||
var sepStyle lipgloss.Style = lipgloss.NewStyle().
|
var sepStyle lipgloss.Style = lipgloss.NewStyle().
|
||||||
@@ -276,5 +364,20 @@ func (m uiModel) renderMessages() string {
|
|||||||
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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const Wordmark = `
|
|||||||
|___/\___/ \__,_| \_/ \___|_| |_|_|_|`
|
|___/\___/ \__,_| \_/ \___|_| |_|_|_|`
|
||||||
|
|
||||||
const Commands = `
|
const Commands = `
|
||||||
|
/history
|
||||||
/models
|
/models
|
||||||
/exit
|
/exit
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user