Add LastMessageAt field to conversation

Replaced `LatestConversationMessages` with `LoadConversationList`, which
utilizes `LastMessageAt` for much faster conversation loading in the
conversation listing TUI and `lmcli list` command.
This commit is contained in:
Matt Low 2024-10-21 15:33:20 +00:00
parent 0384c7cb66
commit 07c96082e7
5 changed files with 88 additions and 78 deletions

View File

@ -20,9 +20,9 @@ func ListCmd(ctx *lmcli.Context) *cobra.Command {
Short: "List conversations", Short: "List conversations",
Long: `List conversations in order of recent activity`, Long: `List conversations in order of recent activity`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
messages, err := ctx.Conversations.LatestConversationMessages() list, err := ctx.Conversations.LoadConversationList()
if err != nil { if err != nil {
return fmt.Errorf("Could not fetch conversations: %v", err) return fmt.Errorf("Could not load conversations: %v", err)
} }
type Category struct { type Category struct {
@ -57,12 +57,12 @@ func ListCmd(ctx *lmcli.Context) *cobra.Command {
all, _ := cmd.Flags().GetBool("all") all, _ := cmd.Flags().GetBool("all")
for _, message := range messages { for _, item := range list.Items {
messageAge := now.Sub(message.CreatedAt) age := now.Sub(item.LastMessageAt)
var category string var category string
for _, c := range categories { for _, c := range categories {
if messageAge < c.cutoff { if age < c.cutoff {
category = c.name category = c.name
break break
} }
@ -70,14 +70,14 @@ func ListCmd(ctx *lmcli.Context) *cobra.Command {
formatted := fmt.Sprintf( formatted := fmt.Sprintf(
"%s - %s - %s", "%s - %s - %s",
message.Conversation.ShortName.String, item.ShortName,
util.HumanTimeElapsedSince(messageAge), util.HumanTimeElapsedSince(age),
message.Conversation.Title, item.Title,
) )
categorized[category] = append( categorized[category] = append(
categorized[category], categorized[category],
ConversationLine{messageAge, formatted}, ConversationLine{age, formatted},
) )
} }
@ -93,7 +93,7 @@ func ListCmd(ctx *lmcli.Context) *cobra.Command {
fmt.Printf("%s:\n", category.name) fmt.Printf("%s:\n", category.name)
for _, conv := range conversationLines { for _, conv := range conversationLines {
if conversationsPrinted >= count && !all { if conversationsPrinted >= count && !all {
fmt.Printf("%d remaining conversation(s), use --all to view.\n", len(messages)-conversationsPrinted) fmt.Printf("%d remaining conversation(s), use --all to view.\n", list.Total-conversationsPrinted)
break outer break outer
} }

View File

@ -17,6 +17,7 @@ type Conversation struct {
SelectedRootID *uint SelectedRootID *uint
SelectedRoot *Message `gorm:"foreignKey:SelectedRootID"` SelectedRoot *Message `gorm:"foreignKey:SelectedRootID"`
RootMessages []Message `gorm:"-:all"` RootMessages []Message `gorm:"-:all"`
LastMessageAt time.Time
} }
type MessageMeta struct { type MessageMeta struct {

View File

@ -15,8 +15,7 @@ import (
// Repo exposes low-level message and conversation management. See // Repo exposes low-level message and conversation management. See
// Service for high-level helpers // Service for high-level helpers
type Repo interface { type Repo interface {
// LatestConversationMessages returns a slice of all conversations ordered by when they were last updated (newest to oldest) LoadConversationList() (ConversationList, error)
LatestConversationMessages() ([]Message, error)
FindConversationByShortName(shortName string) (*Conversation, error) FindConversationByShortName(shortName string) (*Conversation, error)
ConversationShortNameCompletions(search string) []string ConversationShortNameCompletions(search string) []string
@ -72,25 +71,40 @@ func NewRepo(db *gorm.DB) (Repo, error) {
return &repo{db, _sqids}, nil return &repo{db, _sqids}, nil
} }
func (s *repo) LatestConversationMessages() ([]Message, error) { type conversationListItem struct {
var latestMessages []Message ID uint
ShortName string
subQuery := s.db.Model(&Message{}). Title string
Select("MAX(created_at) as max_created_at, conversation_id"). LastMessageAt time.Time
Group("conversation_id")
err := s.db.Model(&Message{}).
Joins("JOIN (?) as sub on messages.conversation_id = sub.conversation_id AND messages.created_at = sub.max_created_at", subQuery).
Group("messages.conversation_id").
Order("created_at DESC").
Preload("Conversation.SelectedRoot").
Find(&latestMessages).Error
if err != nil {
return nil, err
} }
return latestMessages, nil type ConversationList struct {
Total int
Items []conversationListItem
}
// LoadConversationList loads existing conversations, ordered by the date
// of their latest message, from most recent to oldest.
func (s *repo) LoadConversationList() (ConversationList, error) {
list := ConversationList{}
var convos []Conversation
err := s.db.Order("last_message_at DESC").Find(&convos).Error
if err != nil {
return list, err
}
for _, c := range convos {
list.Items = append(list.Items, conversationListItem{
ID: c.ID,
ShortName: c.ShortName.String,
Title: c.Title,
LastMessageAt: c.LastMessageAt,
})
}
list.Total = len(list.Items)
return list, nil
} }
func (s *repo) FindConversationByShortName(shortName string) (*Conversation, error) { func (s *repo) FindConversationByShortName(shortName string) (*Conversation, error) {
@ -220,6 +234,9 @@ func (s *repo) Reply(to *Message, messages ...Message) ([]Message, error) {
savedMessages = append(savedMessages, message) savedMessages = append(savedMessages, message)
currentParent = &message currentParent = &message
} }
to.Conversation.LastMessageAt = savedMessages[len(savedMessages)-1].CreatedAt
s.UpdateConversation(to.Conversation)
return nil return nil
}) })
@ -427,10 +444,7 @@ func (s *repo) StartConversation(messages ...Message) (*Conversation, []Message,
// Update conversation's selected root message // Update conversation's selected root message
conversation.RootMessages = []Message{messages[0]} conversation.RootMessages = []Message{messages[0]}
conversation.SelectedRoot = &messages[0] conversation.SelectedRoot = &messages[0]
err = s.UpdateConversation(conversation) conversation.LastMessageAt = messages[0].CreatedAt
if err != nil {
return nil, nil, err
}
// Add additional replies to conversation // Add additional replies to conversation
if len(messages) > 1 { if len(messages) > 1 {
@ -439,10 +453,12 @@ func (s *repo) StartConversation(messages ...Message) (*Conversation, []Message,
return nil, nil, err return nil, nil, err
} }
messages = append([]Message{messages[0]}, newMessages...) messages = append([]Message{messages[0]}, newMessages...)
} conversation.LastMessageAt = messages[len(messages)-1].CreatedAt
return conversation, messages, nil
} }
err = s.UpdateConversation(conversation)
return conversation, messages, err
}
// CloneConversation clones the given conversation and all of its meesages // CloneConversation clones the given conversation and all of its meesages
func (s *repo) CloneConversation(toClone Conversation) (*Conversation, uint, error) { func (s *repo) CloneConversation(toClone Conversation) (*Conversation, uint, error) {

View File

@ -13,14 +13,9 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
type LoadedConversation struct {
Conv conversation.Conversation
LastReply conversation.Message
}
type AppModel struct { type AppModel struct {
Ctx *lmcli.Context Ctx *lmcli.Context
Conversations []LoadedConversation Conversations conversation.ConversationList
Conversation *conversation.Conversation Conversation *conversation.Conversation
Messages []conversation.Message Messages []conversation.Message
Model string Model string
@ -89,22 +84,6 @@ func (m *AppModel) NewConversation() {
m.ApplySystemPrompt() m.ApplySystemPrompt()
} }
func (m *AppModel) LoadConversations() (error, []LoadedConversation) {
messages, err := m.Ctx.Conversations.LatestConversationMessages()
if err != nil {
return fmt.Errorf("Could not load conversations: %v", err), nil
}
conversations := make([]LoadedConversation, len(messages))
for i, msg := range messages {
conversations[i] = LoadedConversation{
Conv: *msg.Conversation,
LastReply: msg,
}
}
return nil, conversations
}
func (a *AppModel) LoadConversationMessages() ([]conversation.Message, error) { func (a *AppModel) LoadConversationMessages() ([]conversation.Message, error) {
messages, err := a.Ctx.Conversations.PathToLeaf(a.Conversation.SelectedRoot) messages, err := a.Ctx.Conversations.PathToLeaf(a.Conversation.SelectedRoot)
if err != nil { if err != nil {

View File

@ -19,9 +19,9 @@ import (
type ( type (
// sent when conversation list is loaded // sent when conversation list is loaded
msgConversationsLoaded ([]model.LoadedConversation) msgConversationsLoaded conversation.ConversationList
// sent when a conversation is selected // sent when a single conversation is loaded
msgConversationSelected conversation.Conversation msgConversationLoaded *conversation.Conversation
// sent when a conversation is deleted // sent when a conversation is deleted
msgConversationDeleted struct{} msgConversationDeleted struct{}
) )
@ -56,19 +56,17 @@ func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
} }
} }
conversations := m.App.Conversations.Items
switch msg.String() { switch msg.String() {
case "enter": case "enter":
if len(m.App.Conversations) > 0 && m.cursor < len(m.App.Conversations) { if len(conversations) > 0 && m.cursor < len(conversations) {
m.App.ClearConversation() return m.loadConversation(conversations[m.cursor].ID)
m.App.Conversation = &m.App.Conversations[m.cursor].Conv
return func() tea.Msg {
return shared.MsgViewChange(shared.ViewChat)
}
} }
case "j", "down": case "j", "down":
if m.cursor < len(m.App.Conversations)-1 { if m.cursor < len(conversations)-1 {
m.cursor++ m.cursor++
if m.cursor == len(m.App.Conversations)-1 { if m.cursor == len(conversations)-1 {
m.content.GotoBottom() m.content.GotoBottom()
} else { } else {
// this hack positions the *next* conversatoin slightly // this hack positions the *next* conversatoin slightly
@ -78,7 +76,7 @@ func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
} }
m.content.SetContent(m.renderConversationList()) m.content.SetContent(m.renderConversationList())
} else { } else {
m.cursor = len(m.App.Conversations) - 1 m.cursor = len(conversations) - 1
m.content.GotoBottom() m.content.GotoBottom()
} }
return shared.KeyHandled(msg) return shared.KeyHandled(msg)
@ -100,14 +98,14 @@ func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
m.App.NewConversation() m.App.NewConversation()
return shared.ChangeView(shared.ViewChat) return shared.ChangeView(shared.ViewChat)
case "d": case "d":
if !m.confirmPrompt.Focused() && len(m.App.Conversations) > 0 && m.cursor < len(m.App.Conversations) { if !m.confirmPrompt.Focused() && len(conversations) > 0 && m.cursor < len(conversations) {
title := m.App.Conversations[m.cursor].Conv.Title title := conversations[m.cursor].Title
if title == "" { if title == "" {
title = "(untitled)" title = "(untitled)"
} }
m.confirmPrompt = bubbles.NewConfirmPrompt( m.confirmPrompt = bubbles.NewConfirmPrompt(
fmt.Sprintf("Delete '%s'?", title), fmt.Sprintf("Delete '%s'?", title),
m.App.Conversations[m.cursor].Conv, conversations[m.cursor],
) )
m.confirmPrompt.Style = lipgloss.NewStyle(). m.confirmPrompt.Style = lipgloss.NewStyle().
Bold(true). Bold(true).
@ -148,9 +146,15 @@ func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
m.width, m.height = msg.Width, msg.Height m.width, m.height = msg.Width, msg.Height
m.content.SetContent(m.renderConversationList()) m.content.SetContent(m.renderConversationList())
case msgConversationsLoaded: case msgConversationsLoaded:
m.App.Conversations = msg m.App.Conversations = conversation.ConversationList(msg)
m.cursor = max(0, min(len(m.App.Conversations), m.cursor)) m.cursor = max(0, min(len(m.App.Conversations.Items), m.cursor))
m.content.SetContent(m.renderConversationList()) m.content.SetContent(m.renderConversationList())
case msgConversationLoaded:
m.App.ClearConversation()
m.App.Conversation = msg
cmds = append(cmds, func() tea.Msg {
return shared.MsgViewChange(shared.ViewChat)
})
case bubbles.MsgConfirmPromptAnswered: case bubbles.MsgConfirmPromptAnswered:
m.confirmPrompt.Blur() m.confirmPrompt.Blur()
if msg.Value { if msg.Value {
@ -180,11 +184,21 @@ func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
func (m *Model) loadConversations() tea.Cmd { func (m *Model) loadConversations() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
err, conversations := m.App.LoadConversations() list, err := m.App.Ctx.Conversations.LoadConversationList()
if err != nil { if err != nil {
return shared.AsMsgError(fmt.Errorf("Could not load conversations: %v", err)) return shared.AsMsgError(fmt.Errorf("Could not load conversations: %v", err))
} }
return msgConversationsLoaded(conversations) return msgConversationsLoaded(list)
}
}
func (m *Model) loadConversation(conversationID uint) tea.Cmd {
return func() tea.Msg {
conversation, err := m.App.Ctx.Conversations.GetConversationByID(conversationID)
if err != nil {
return shared.AsMsgError(fmt.Errorf("Could not load conversation %d: %v", conversationID, err))
}
return msgConversationLoaded(conversation)
} }
} }
@ -259,12 +273,12 @@ func (m *Model) renderConversationList() string {
sb strings.Builder sb strings.Builder
) )
m.itemOffsets = make([]int, len(m.App.Conversations)) m.itemOffsets = make([]int, len(m.App.Conversations.Items))
sb.WriteRune('\n') sb.WriteRune('\n')
currentOffset += 1 currentOffset += 1
for i, c := range m.App.Conversations { for i, c := range m.App.Conversations.Items {
lastReplyAge := now.Sub(c.LastReply.CreatedAt) lastReplyAge := now.Sub(c.LastMessageAt)
var category string var category string
for _, g := range categories { for _, g := range categories {
@ -284,14 +298,14 @@ func (m *Model) renderConversationList() string {
} }
tStyle := titleStyle tStyle := titleStyle
if c.Conv.Title == "" { if c.Title == "" {
tStyle = tStyle.Inherit(untitledStyle).SetString("(untitled)") tStyle = tStyle.Inherit(untitledStyle).SetString("(untitled)")
} }
if i == m.cursor { if i == m.cursor {
tStyle = tStyle.Inherit(selectedStyle) tStyle = tStyle.Inherit(selectedStyle)
} }
title := tStyle.Width(m.width - 3).PaddingLeft(2).Render(c.Conv.Title) title := tStyle.Width(m.width - 3).PaddingLeft(2).Render(c.Title)
if i == m.cursor { if i == m.cursor {
title = ">" + title[1:] title = ">" + title[1:]
} }
@ -304,7 +318,7 @@ func (m *Model) renderConversationList() string {
)) ))
sb.WriteString(item) sb.WriteString(item)
currentOffset += tuiutil.Height(item) currentOffset += tuiutil.Height(item)
if i < len(m.App.Conversations)-1 { if i < len(m.App.Conversations.Items)-1 {
sb.WriteRune('\n') sb.WriteRune('\n')
} }
} }