Compare commits

..

13 Commits

Author SHA1 Message Date
a8b7bd7662 Renamed lmcli view to lmcli show 2023-11-14 02:10:37 +00:00
90d85e676d Implement lmcli reply 2023-11-14 02:09:09 +00:00
ec013236b8 Small cleanup/fix 2023-11-14 02:08:20 +00:00
6fde3f8932 Add "last 6 hours" to lmcli ls categories 2023-11-13 06:56:14 +00:00
6af9377cf5 Implement lmcli rm 2023-11-13 06:56:05 +00:00
cf0e98f656 Generate titles for new conversations 2023-11-13 06:39:06 +00:00
e66016aedd Sort conversations properly in lmcli ls 2023-11-13 06:35:57 +00:00
9a1aae83da Update go.mod to go 1.21 2023-11-13 06:33:47 +00:00
b0e4739f4f Fixed lmcli view completions
- Don't return completions if an arg is already present
- Fixed typo in method name
2023-11-13 05:27:21 +00:00
4e3976fc73 Remove Get prefix from Store methods
It feels better this way (and to the rest of Go, apparently)
2023-11-13 00:20:54 +00:00
b87c3ffc53 Implement lmcli view [conversation] with completions
Separate out logic to retrieve a message's "friendly" role (System,
User, Assistant)
2023-11-12 23:33:16 +00:00
b0a1299e0b Implement lmcli ls 2023-11-12 14:30:42 -07:00
ae424530f9 Parameterize the openai model used
Add `openai.defaultConfig` to set the default, will allow overriding
with CLI flag
2023-11-09 06:07:52 +00:00
8 changed files with 373 additions and 31 deletions

2
go.mod
View File

@ -1,6 +1,6 @@
module git.mlow.ca/mlow/lmcli module git.mlow.ca/mlow/lmcli
go 1.19 go 1.21
require ( require (
github.com/go-yaml/yaml v2.1.0+incompatible github.com/go-yaml/yaml v2.1.0+incompatible

View File

@ -2,7 +2,9 @@ package cli
import ( import (
"fmt" "fmt"
"slices"
"strings" "strings"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -24,17 +26,21 @@ var lsCmd = &cobra.Command{
Short: "List existing conversations", Short: "List existing conversations",
Long: `List all existing conversations in descending order of recent activity.`, Long: `List all existing conversations in descending order of recent activity.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Listing conversations...") conversations, err := store.Conversations()
// Example output, asterisk to indicate current converation if err != nil {
fmt.Println("Could not fetch conversations.")
return
}
// $ lm ls // Example output
// $ lmcli ls
// last hour: // last hour:
// 98sg - 12 minutes ago - Project discussion // 98sg - 12 minutes ago - Project discussion
// last day: // last day:
// tj3l - 10 hours ago - Deep learning concepts // tj3l - 10 hours ago - Deep learning concepts
// last week: // last week:
// bwfm - 2 days ago - Machine learning study // bwfm - 2 days ago - Machine learning study
// * 8n3h - 3 days ago - Weekend plans // 8n3h - 3 days ago - Weekend plans
// f3n7 - 6 days ago - CLI development // f3n7 - 6 days ago - CLI development
// last month: // last month:
// 5hn2 - 8 days ago - Book club discussion // 5hn2 - 8 days ago - Book club discussion
@ -45,15 +51,149 @@ var lsCmd = &cobra.Command{
// g8d9 - 3 months ago - History book club // g8d9 - 3 months ago - History book club
// 4lk3 - 4 months ago - Local events and meetups // 4lk3 - 4 months ago - Local events and meetups
// 43jn - 6 months ago - Mobile photography techniques // 43jn - 6 months ago - Mobile photography techniques
type ConversationLine struct {
timeSinceReply time.Duration
formatted string
}
now := time.Now()
categories := []string{
"recent",
"last hour",
"last 6 hours",
"last day",
"last week",
"last month",
"last 6 months",
"older",
}
categorized := map[string][]ConversationLine{}
for _, conversation := range conversations {
lastMessage, err := store.LastMessage(&conversation)
if lastMessage == nil || err != nil {
continue
}
messageAge := now.Sub(lastMessage.CreatedAt)
var category string
switch {
case messageAge <= 10*time.Minute:
category = "recent"
case messageAge <= time.Hour:
category = "last hour"
case messageAge <= 6*time.Hour:
category = "last 6 hours"
case messageAge <= 24*time.Hour:
category = "last day"
case messageAge <= 7*24*time.Hour:
category = "last week"
case messageAge <= 30*24*time.Hour:
category = "last month"
case messageAge <= 6*30*24*time.Hour: // Approximate as 6 months
category = "last 6 months"
default:
category = "older"
}
formatted := fmt.Sprintf(
"%s - %s - %s",
conversation.ShortName.String,
humanTimeElapsedSince(messageAge),
conversation.Title,
)
categorized[category] = append(
categorized[category],
ConversationLine{messageAge, formatted},
)
}
for _, category := range categories {
conversations, ok := categorized[category]
if !ok {
continue
}
slices.SortFunc(conversations, func(a, b ConversationLine) int {
return int(a.timeSinceReply - b.timeSinceReply)
})
fmt.Printf("%s:\n", category)
for _, conv := range conversations {
fmt.Printf(" %s\n", conv.formatted)
}
}
}, },
} }
var viewCmd = &cobra.Command{ var rmCmd = &cobra.Command{
Use: "view", Use: "rm [conversation]",
Short: "View messages in a conversation", Short: "Remove a conversation",
Long: `Displays all the messages in a coversation.`, Long: `Removes a conversation by its short name.`,
Args: func(cmd *cobra.Command, args []string) error {
argCount := 1
if err := cobra.MinimumNArgs(argCount)(cmd, args); err != nil {
return err
}
return nil
},
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Displaying conversation messages...") shortName := args[0]
conversation, err := store.ConversationByShortName(shortName)
if err != nil {
Fatal("Could not search for conversation: %v\n", err)
}
if conversation.ID == 0 {
Fatal("Conversation not found with short name: %s\n", shortName)
}
err = store.DeleteConversation(conversation)
if err != nil {
Fatal("Could not delete conversation: %v\n", err)
}
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
compMode := cobra.ShellCompDirectiveNoFileComp
if len(args) != 0 {
return nil, compMode
}
return store.ConversationShortNameCompletions(toComplete), compMode
},
}
var showCmd = &cobra.Command{
Use: "show [conversation]",
Short: "Show messages in a conversation",
Long: `Finds a conversation by its short name and displays its contents.`,
Args: func(cmd *cobra.Command, args []string) error {
argCount := 1
if err := cobra.MinimumNArgs(argCount)(cmd, args); err != nil {
return err
}
return nil
},
Run: func(cmd *cobra.Command, args []string) {
shortName := args[0]
conversation, err := store.ConversationByShortName(shortName)
if conversation.ID == 0 {
Fatal("Conversation not found with short name: %s\n", shortName)
}
messages, err := store.Messages(conversation)
if err != nil {
Fatal("Could not retrieve messages for conversation: %s\n", conversation.Title)
}
l := len(messages)
for i, message := range messages {
message.RenderTTY(i < l-1)
}
fmt.Println()
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
compMode := cobra.ShellCompDirectiveNoFileComp
if len(args) != 0 {
return nil, compMode
}
return store.ConversationShortNameCompletions(toComplete), compMode
}, },
} }
@ -61,8 +201,75 @@ var replyCmd = &cobra.Command{
Use: "reply", Use: "reply",
Short: "Send a reply to a conversation", Short: "Send a reply to a conversation",
Long: `Sends a reply to conversation and writes the response to stdout.`, Long: `Sends a reply to conversation and writes the response to stdout.`,
Args: func(cmd *cobra.Command, args []string) error {
argCount := 1
if err := cobra.MinimumNArgs(argCount)(cmd, args); err != nil {
return err
}
return nil
},
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Replying to a conversation...") shortName := args[0]
conversation, err := store.ConversationByShortName(shortName)
if conversation.ID == 0 {
Fatal("Conversation not found with short name: %s\n", shortName)
}
messages, err := store.Messages(conversation)
if err != nil {
Fatal("Could not retrieve messages for conversation: %s\n", conversation.Title)
}
messageContents, err := InputFromEditor("# How would you like to reply?\n", "reply.*.md")
userReply := Message{
ConversationID: conversation.ID,
Role: "user",
OriginalContent: messageContents,
}
err = store.SaveMessage(&userReply)
if err != nil {
Warn("Could not save your reply: %v\n", err)
}
messages = append(messages, userReply)
for _, message := range messages {
message.RenderTTY(true)
}
assistantReply := Message{
ConversationID: conversation.ID,
Role: "assistant",
}
assistantReply.RenderTTY(false)
receiver := make(chan string)
response := make(chan string)
go func() {
response <- HandleDelayedResponse(receiver)
}()
err = CreateChatCompletionStream(config.OpenAI.DefaultModel, messages, MAX_TOKENS, receiver)
if err != nil {
Fatal("%v\n", err)
}
assistantReply.OriginalContent = <-response
err = store.SaveMessage(&assistantReply)
if err != nil {
Fatal("Could not save assistant reply: %v\n", err)
}
fmt.Println()
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
compMode := cobra.ShellCompDirectiveNoFileComp
if len(args) != 0 {
return nil, compMode
}
return store.ConversationShortNameCompletions(toComplete), compMode
}, },
} }
@ -123,7 +330,7 @@ var newCmd = &cobra.Command{
response <- HandleDelayedResponse(receiver) response <- HandleDelayedResponse(receiver)
}() }()
err = CreateChatCompletionStream(messages, MAX_TOKENS, receiver) err = CreateChatCompletionStream(config.OpenAI.DefaultModel, messages, MAX_TOKENS, receiver)
if err != nil { if err != nil {
Fatal("%v\n", err) Fatal("%v\n", err)
} }
@ -136,6 +343,15 @@ var newCmd = &cobra.Command{
} }
fmt.Println() fmt.Println()
err = conversation.GenerateTitle()
if err != nil {
Warn("Could not generate title for conversation: %v\n", err)
}
err = store.SaveConversation(&conversation)
if err != nil {
Warn("Could not save conversation after generating title: %v\n", err)
}
}, },
} }
@ -163,7 +379,7 @@ var promptCmd = &cobra.Command{
receiver := make(chan string) receiver := make(chan string)
go HandleDelayedResponse(receiver) go HandleDelayedResponse(receiver)
err := CreateChatCompletionStream(messages, MAX_TOKENS, receiver) err := CreateChatCompletionStream(config.OpenAI.DefaultModel, messages, MAX_TOKENS, receiver)
if err != nil { if err != nil {
Fatal("%v\n", err) Fatal("%v\n", err)
} }
@ -174,8 +390,12 @@ var promptCmd = &cobra.Command{
func NewRootCmd() *cobra.Command { func NewRootCmd() *cobra.Command {
rootCmd.AddCommand( rootCmd.AddCommand(
lsCmd,
newCmd, newCmd,
promptCmd, promptCmd,
replyCmd,
rmCmd,
showCmd,
) )
return rootCmd return rootCmd
} }

View File

@ -11,6 +11,7 @@ import (
type Config struct { type Config struct {
OpenAI struct { OpenAI struct {
APIKey string `yaml:"apiKey"` APIKey string `yaml:"apiKey"`
DefaultModel string `yaml:"defaultModel"`
} `yaml:"openai"` } `yaml:"openai"`
} }

56
pkg/cli/conversation.go Normal file
View File

@ -0,0 +1,56 @@
package cli
import (
"fmt"
"strings"
)
// FriendlyRole returns a human friendly signifier for the message's role.
func (m *Message) FriendlyRole() string {
var friendlyRole string
switch m.Role {
case "user":
friendlyRole = "You"
case "system":
friendlyRole = "System"
case "assistant":
friendlyRole = "Assistant"
default:
friendlyRole = m.Role
}
return friendlyRole
}
func (c *Conversation) GenerateTitle() error {
const header = "Generate a short title for the conversation below."
prompt := fmt.Sprintf("%s\n\n---\n\n%s", header, c.FormatForExternalPrompting())
messages := []Message{
{
Role: "user",
OriginalContent: prompt,
},
}
model := "gpt-3.5-turbo" // use cheap model to generate title
response, err := CreateChatCompletion(model, messages, 10)
if err != nil {
return err
}
c.Title = response
return nil
}
func (c *Conversation) FormatForExternalPrompting() string {
sb := strings.Builder{}
messages, err := store.Messages(c)
if err != nil {
Fatal("Could not retrieve messages for conversation %v", c)
}
for _, message := range messages {
sb.WriteString(fmt.Sprintf("<%s>\n", message.FriendlyRole()))
sb.WriteString(fmt.Sprintf("\"\"\"\n%s\n\"\"\"\n\n", message.OriginalContent))
}
return sb.String()
}

View File

@ -8,7 +8,7 @@ import (
openai "github.com/sashabaranov/go-openai" openai "github.com/sashabaranov/go-openai"
) )
func CreateChatCompletionRequest(messages []Message, maxTokens int) openai.ChatCompletionRequest { func CreateChatCompletionRequest(model string, messages []Message, maxTokens int) openai.ChatCompletionRequest {
chatCompletionMessages := []openai.ChatCompletionMessage{} chatCompletionMessages := []openai.ChatCompletionMessage{}
for _, m := range messages { for _, m := range messages {
chatCompletionMessages = append(chatCompletionMessages, openai.ChatCompletionMessage{ chatCompletionMessages = append(chatCompletionMessages, openai.ChatCompletionMessage{
@ -18,7 +18,7 @@ func CreateChatCompletionRequest(messages []Message, maxTokens int) openai.ChatC
} }
return openai.ChatCompletionRequest{ return openai.ChatCompletionRequest{
Model: openai.GPT3Dot5Turbo, Model: model,
Messages: chatCompletionMessages, Messages: chatCompletionMessages,
MaxTokens: maxTokens, MaxTokens: maxTokens,
} }
@ -26,9 +26,9 @@ func CreateChatCompletionRequest(messages []Message, maxTokens int) openai.ChatC
// CreateChatCompletion submits a Chat Completion API request and returns the // CreateChatCompletion submits a Chat Completion API request and returns the
// response. // response.
func CreateChatCompletion(messages []Message, maxTokens int) (string, error) { func CreateChatCompletion(model string, messages []Message, maxTokens int) (string, error) {
client := openai.NewClient(config.OpenAI.APIKey) client := openai.NewClient(config.OpenAI.APIKey)
req := CreateChatCompletionRequest(messages, maxTokens) req := CreateChatCompletionRequest(model, messages, maxTokens)
resp, err := client.CreateChatCompletion(context.Background(), req) resp, err := client.CreateChatCompletion(context.Background(), req)
if err != nil { if err != nil {
return "", err return "", err
@ -39,9 +39,9 @@ func CreateChatCompletion(messages []Message, maxTokens int) (string, error) {
// CreateChatCompletionStream submits a streaming Chat Completion API request // CreateChatCompletionStream submits a streaming Chat Completion API request
// and streams the response to the provided output channel. // and streams the response to the provided output channel.
func CreateChatCompletionStream(messages []Message, maxTokens int, output chan string) error { func CreateChatCompletionStream(model string, messages []Message, maxTokens int, output chan string) error {
client := openai.NewClient(config.OpenAI.APIKey) client := openai.NewClient(config.OpenAI.APIKey)
req := CreateChatCompletionRequest(messages, maxTokens) req := CreateChatCompletionRequest(model, messages, maxTokens)
defer close(output) defer close(output)

View File

@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time"
sqids "github.com/sqids/sqids-go" sqids "github.com/sqids/sqids-go"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
@ -22,6 +24,7 @@ type Message struct {
Conversation Conversation Conversation Conversation
OriginalContent string OriginalContent string
Role string // 'user' or 'assistant' Role string // 'user' or 'assistant'
CreatedAt time.Time
} }
type Conversation struct { type Conversation struct {
@ -83,18 +86,46 @@ func (s *Store) SaveConversation(conversation *Conversation) error {
return err return err
} }
func (s *Store) DeleteConversation(conversation *Conversation) error {
s.db.Where("conversation_id = ?", conversation.ID).Delete(&Message{})
return s.db.Delete(&conversation).Error
}
func (s *Store) SaveMessage(message *Message) error { func (s *Store) SaveMessage(message *Message) error {
return s.db.Create(message).Error return s.db.Create(message).Error
} }
func (s *Store) GetConversations() ([]Conversation, error) { func (s *Store) Conversations() ([]Conversation, error) {
var conversations []Conversation var conversations []Conversation
err := s.db.Find(&conversations).Error err := s.db.Find(&conversations).Error
return conversations, err return conversations, err
} }
func (s *Store) GetMessages(conversation *Conversation) ([]Message, error) { func (s *Store) ConversationShortNameCompletions(shortName string) []string {
var completions []string
conversations, _ := s.Conversations() // ignore error for completions
for _, conversation := range conversations {
if shortName == "" || strings.HasPrefix(conversation.ShortName.String, shortName) {
completions = append(completions, fmt.Sprintf("%s\t%s", conversation.ShortName.String, conversation.Title))
}
}
return completions
}
func (s *Store) ConversationByShortName(shortName string) (*Conversation, error) {
var conversation Conversation
err := s.db.Where("short_name = ?", shortName).Find(&conversation).Error
return &conversation, err
}
func (s *Store) Messages(conversation *Conversation) ([]Message, error) {
var messages []Message var messages []Message
err := s.db.Where("conversation_id = ?", conversation.ID).Find(&messages).Error err := s.db.Where("conversation_id = ?", conversation.ID).Find(&messages).Error
return messages, err return messages, err
} }
func (s *Store) LastMessage(conversation *Conversation) (*Message, error) {
var message Message
err := s.db.Where("conversation_id = ?", conversation.ID).Last(&message).Error
return &message, err
}

View File

@ -59,16 +59,7 @@ func HandleDelayedResponse(response chan string) string {
} }
func (m *Message) RenderTTY(paddingDown bool) { func (m *Message) RenderTTY(paddingDown bool) {
var friendlyRole string fmt.Printf("<%s>\n\n", m.FriendlyRole())
switch m.Role {
case "user":
friendlyRole = "You"
case "system":
friendlyRole = "System"
case "assistant":
friendlyRole = "Assistant"
}
fmt.Printf("<%s>\n\n", friendlyRole)
if m.OriginalContent != "" { if m.OriginalContent != "" {
fmt.Print(m.OriginalContent) fmt.Print(m.OriginalContent)
} }

View File

@ -1,9 +1,11 @@
package cli package cli
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"time"
) )
// InputFromEditor retrieves user input by opening an editor (one specified by // InputFromEditor retrieves user input by opening an editor (one specified by
@ -48,3 +50,44 @@ func InputFromEditor(placeholder string, pattern string) (string, error) {
return strings.Trim(content, "\n \t"), nil return strings.Trim(content, "\n \t"), nil
} }
// humanTimeElapsedSince returns a human-friendly representation of the given time
// duration.
func humanTimeElapsedSince(d time.Duration) string {
seconds := d.Seconds()
minutes := seconds / 60
hours := minutes / 60
days := hours / 24
weeks := days / 7
months := days / 30
years := days / 365
switch {
case seconds < 60:
return "seconds ago"
case minutes < 2:
return "1 minute ago"
case minutes < 60:
return fmt.Sprintf("%d minutes ago", int64(minutes))
case hours < 2:
return "1 hour ago"
case hours < 24:
return fmt.Sprintf("%d hours ago", int64(hours))
case days < 2:
return "1 day ago"
case days < 7:
return fmt.Sprintf("%d days ago", int64(days))
case weeks < 2:
return "1 week ago"
case weeks <= 4:
return fmt.Sprintf("%d weeks ago", int64(weeks))
case months < 2:
return "1 month ago"
case months < 12:
return fmt.Sprintf("%d months ago", int64(months))
case years < 2:
return "1 year ago"
default:
return fmt.Sprintf("%d years ago", int64(years))
}
}