Matt Low
dce62e7748
Add new SetStructDefaults function to handle the "defaults" struct tag. Only works on struct fields which are pointers (in order to be able to distinguish between not set (nil) and zero values). So, the Config struct has been updated to use pointer fields and we now need to dereference those pointers to use them.
407 lines
10 KiB
Go
407 lines
10 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
maxTokens int
|
|
model string
|
|
)
|
|
|
|
func init() {
|
|
inputCmds := []*cobra.Command{newCmd, promptCmd, replyCmd}
|
|
for _, cmd := range inputCmds {
|
|
cmd.Flags().IntVar(&maxTokens, "length", *config.OpenAI.DefaultMaxLength, "Max response length in tokens")
|
|
cmd.Flags().StringVar(&model, "model", *config.OpenAI.DefaultModel, "The language model to use")
|
|
}
|
|
|
|
rootCmd.AddCommand(
|
|
lsCmd,
|
|
newCmd,
|
|
promptCmd,
|
|
replyCmd,
|
|
rmCmd,
|
|
viewCmd,
|
|
)
|
|
}
|
|
|
|
func Execute() error {
|
|
return rootCmd.Execute()
|
|
}
|
|
|
|
var rootCmd = &cobra.Command{
|
|
Use: "lmcli",
|
|
Short: "Interact with Large Language Models",
|
|
Long: `lmcli is a CLI tool to interact with Large Language Models.`,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
// execute `lm ls` by default
|
|
},
|
|
}
|
|
|
|
var lsCmd = &cobra.Command{
|
|
Use: "ls",
|
|
Short: "List existing conversations",
|
|
Long: `List all existing conversations in descending order of recent activity.`,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
conversations, err := store.Conversations()
|
|
if err != nil {
|
|
fmt.Println("Could not fetch conversations.")
|
|
return
|
|
}
|
|
|
|
// Example output
|
|
// $ lmcli ls
|
|
// last hour:
|
|
// 98sg - 12 minutes ago - Project discussion
|
|
// last day:
|
|
// tj3l - 10 hours ago - Deep learning concepts
|
|
// last week:
|
|
// bwfm - 2 days ago - Machine learning study
|
|
// 8n3h - 3 days ago - Weekend plans
|
|
// f3n7 - 6 days ago - CLI development
|
|
// last month:
|
|
// 5hn2 - 8 days ago - Book club discussion
|
|
// b7ze - 20 days ago - Gardening tips and tricks
|
|
// last 6 months:
|
|
// 3jn2 - 30 days ago - Web development best practices
|
|
// 43jk - 2 months ago - Longboard maintenance
|
|
// g8d9 - 3 months ago - History book club
|
|
// 4lk3 - 4 months ago - Local events and meetups
|
|
// 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 rmCmd = &cobra.Command{
|
|
Use: "rm [conversation]",
|
|
Short: "Remove a conversation",
|
|
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) {
|
|
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 viewCmd = &cobra.Command{
|
|
Use: "view [conversation]",
|
|
Short: "View 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)
|
|
}
|
|
|
|
RenderConversation(messages, false)
|
|
},
|
|
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 replyCmd = &cobra.Command{
|
|
Use: "reply",
|
|
Short: "Send a reply to a conversation",
|
|
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) {
|
|
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")
|
|
if messageContents == "" {
|
|
Fatal("No reply was provided.\n")
|
|
}
|
|
|
|
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)
|
|
|
|
RenderConversation(messages, true)
|
|
assistantReply := Message{
|
|
ConversationID: conversation.ID,
|
|
Role: "assistant",
|
|
}
|
|
assistantReply.RenderTTY()
|
|
|
|
receiver := make(chan string)
|
|
response := make(chan string)
|
|
go func() {
|
|
response <- HandleDelayedResponse(receiver)
|
|
}()
|
|
|
|
err = CreateChatCompletionStream(model, messages, maxTokens, 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
|
|
},
|
|
}
|
|
|
|
var newCmd = &cobra.Command{
|
|
Use: "new",
|
|
Short: "Start a new conversation",
|
|
Long: `Start a new conversation with the Large Language Model.`,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
messageContents, err := InputFromEditor("# What would you like to say?\n", "message.*.md")
|
|
if err != nil {
|
|
Fatal("Failed to get input: %v\n", err)
|
|
}
|
|
|
|
if messageContents == "" {
|
|
Fatal("No message was provided.\n")
|
|
}
|
|
|
|
// TODO: set title if --title provided, otherwise defer for later(?)
|
|
conversation := Conversation{}
|
|
err = store.SaveConversation(&conversation)
|
|
if err != nil {
|
|
Fatal("Could not save new conversation: %v\n", err)
|
|
}
|
|
|
|
const system = "You are a helpful assistant."
|
|
messages := []Message{
|
|
{
|
|
ConversationID: conversation.ID,
|
|
Role: "system",
|
|
OriginalContent: system,
|
|
},
|
|
{
|
|
ConversationID: conversation.ID,
|
|
Role: "user",
|
|
OriginalContent: messageContents,
|
|
},
|
|
}
|
|
for _, message := range messages {
|
|
err = store.SaveMessage(&message)
|
|
if err != nil {
|
|
Warn("Could not save %s message: %v\n", message.Role, err)
|
|
}
|
|
}
|
|
|
|
RenderConversation(messages, true)
|
|
reply := Message{
|
|
ConversationID: conversation.ID,
|
|
Role: "assistant",
|
|
}
|
|
reply.RenderTTY()
|
|
|
|
receiver := make(chan string)
|
|
response := make(chan string)
|
|
go func() {
|
|
response <- HandleDelayedResponse(receiver)
|
|
}()
|
|
|
|
err = CreateChatCompletionStream(model, messages, maxTokens, receiver)
|
|
if err != nil {
|
|
Fatal("%v\n", err)
|
|
}
|
|
|
|
reply.OriginalContent = <-response
|
|
|
|
err = store.SaveMessage(&reply)
|
|
if err != nil {
|
|
Fatal("Could not save reply: %v\n", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
},
|
|
}
|
|
|
|
var promptCmd = &cobra.Command{
|
|
Use: "prompt",
|
|
Short: "Do a one-shot prompt",
|
|
Long: `Prompt the Large Language Model and get a response.`,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
message := strings.Join(args, " ")
|
|
if len(strings.Trim(message, " \t\n")) == 0 {
|
|
Fatal("No message was provided.\n")
|
|
}
|
|
|
|
const system = "You are a helpful assistant."
|
|
messages := []Message{
|
|
{
|
|
Role: "system",
|
|
OriginalContent: system,
|
|
},
|
|
{
|
|
Role: "user",
|
|
OriginalContent: message,
|
|
},
|
|
}
|
|
|
|
receiver := make(chan string)
|
|
go HandleDelayedResponse(receiver)
|
|
err := CreateChatCompletionStream(model, messages, maxTokens, receiver)
|
|
if err != nil {
|
|
Fatal("%v\n", err)
|
|
}
|
|
|
|
fmt.Println()
|
|
},
|
|
}
|