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:
parent
0384c7cb66
commit
07c96082e7
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
|
Title string
|
||||||
|
LastMessageAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
subQuery := s.db.Model(&Message{}).
|
type ConversationList struct {
|
||||||
Select("MAX(created_at) as max_created_at, conversation_id").
|
Total int
|
||||||
Group("conversation_id")
|
Items []conversationListItem
|
||||||
|
}
|
||||||
|
|
||||||
err := s.db.Model(&Message{}).
|
// LoadConversationList loads existing conversations, ordered by the date
|
||||||
Joins("JOIN (?) as sub on messages.conversation_id = sub.conversation_id AND messages.created_at = sub.max_created_at", subQuery).
|
// of their latest message, from most recent to oldest.
|
||||||
Group("messages.conversation_id").
|
func (s *repo) LoadConversationList() (ConversationList, error) {
|
||||||
Order("created_at DESC").
|
list := ConversationList{}
|
||||||
Preload("Conversation.SelectedRoot").
|
|
||||||
Find(&latestMessages).Error
|
|
||||||
|
|
||||||
|
var convos []Conversation
|
||||||
|
err := s.db.Order("last_message_at DESC").Find(&convos).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return latestMessages, nil
|
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) {
|
||||||
|
@ -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 {
|
||||||
|
@ -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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user