2023-11-04 12:20:13 -06:00
package cli
import (
"fmt"
2023-11-12 23:35:57 -07:00
"slices"
2023-11-04 12:20:13 -06:00
"strings"
2023-11-12 00:19:45 -07:00
"time"
2023-11-04 16:56:22 -06:00
2023-11-04 12:20:13 -06:00
"github.com/spf13/cobra"
)
2023-11-18 08:07:17 -07:00
var (
2023-11-21 21:45:06 -07:00
maxTokens int
model string
systemPrompt string
systemPromptFile string
2023-11-18 08:07:17 -07:00
)
2023-11-05 11:19:30 -07:00
2023-11-13 23:00:51 -07:00
func init ( ) {
2023-11-21 23:53:22 -07:00
inputCmds := [ ] * cobra . Command { newCmd , promptCmd , replyCmd , retryCmd , continueCmd }
2023-11-18 08:07:17 -07:00
for _ , cmd := range inputCmds {
2023-11-18 18:14:00 -07:00
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" )
2023-11-21 21:45:06 -07:00
cmd . Flags ( ) . StringVar ( & systemPrompt , "system-prompt" , * config . ModelDefaults . SystemPrompt , "The system prompt to use." )
cmd . Flags ( ) . StringVar ( & systemPromptFile , "system-prompt-file" , "" , "A path to a file whose contents are used as the system prompt." )
cmd . MarkFlagsMutuallyExclusive ( "system-prompt" , "system-prompt-file" )
2023-11-18 08:07:17 -07:00
}
2023-11-13 23:00:51 -07:00
rootCmd . AddCommand (
2023-11-21 23:53:22 -07:00
continueCmd ,
2023-11-13 23:00:51 -07:00
lsCmd ,
newCmd ,
promptCmd ,
replyCmd ,
2023-11-21 23:53:22 -07:00
retryCmd ,
2023-11-13 23:00:51 -07:00
rmCmd ,
viewCmd ,
)
}
func Execute ( ) error {
return rootCmd . Execute ( )
}
2023-11-21 21:45:06 -07:00
func SystemPrompt ( ) string {
if systemPromptFile != "" {
content , err := FileContents ( systemPromptFile )
if err != nil {
Fatal ( "Could not read file contents at %s: %v" , systemPromptFile , err )
}
return content
}
return systemPrompt
}
2023-11-28 21:43:53 -07:00
// LLMRequest prompts the LLM with the given messages, writing the response
// to stdout. Returns all reply messages added by the LLM, including any
// function call messages.
func LLMRequest ( messages [ ] Message ) ( [ ] Message , error ) {
2023-11-24 08:17:24 -07:00
// receiver receives the reponse from LLM
receiver := make ( chan string )
defer close ( receiver )
// start HandleDelayedContent goroutine to print received data to stdout
go HandleDelayedContent ( receiver )
2023-11-28 21:43:53 -07:00
var replies [ ] Message
response , err := CreateChatCompletionStream ( model , messages , maxTokens , receiver , & replies )
2023-11-24 08:17:24 -07:00
if response != "" {
2023-11-24 18:26:37 -07:00
if err != nil {
Warn ( "Received partial response. Error: %v\n" , err )
err = nil
}
2023-11-24 08:17:24 -07:00
// there was some content, so break to a new line after it
fmt . Println ( )
}
2023-11-28 21:43:53 -07:00
return replies , err
2023-11-23 20:45:43 -07:00
}
2023-11-28 22:10:44 -07:00
func ( c * Conversation ) GenerateAndSaveReplies ( messages [ ] Message ) {
replies , err := LLMRequest ( messages )
if err != nil {
Fatal ( "Error fetching LLM response: %v\n" , err )
}
for _ , reply := range replies {
reply . ConversationID = c . ID
err = store . SaveMessage ( & reply )
if err != nil {
Warn ( "Could not save reply: %v\n" , err )
}
}
}
2023-11-20 09:50:56 -07:00
// InputFromArgsOrEditor returns either the provided input from the args slice
// (joined with spaces), or if len(args) is 0, opens an editor and returns
// whatever input was provided there. placeholder is a string which populates
// the editor and gets stripped from the final output.
func InputFromArgsOrEditor ( args [ ] string , placeholder string ) ( message string ) {
var err error
if len ( args ) == 0 {
message , err = InputFromEditor ( placeholder , "message.*.md" )
if err != nil {
Fatal ( "Failed to get input: %v\n" , err )
}
} else {
message = strings . Trim ( strings . Join ( args , " " ) , " \t\n" )
}
return
}
2023-11-04 12:20:13 -06:00
var rootCmd = & cobra . Command {
2023-11-04 16:37:18 -06:00
Use : "lmcli" ,
2023-11-04 12:20:13 -06:00
Short : "Interact with Large Language Models" ,
2023-11-04 16:37:18 -06:00
Long : ` lmcli is a CLI tool to interact with Large Language Models. ` ,
2023-11-04 12:20:13 -06:00
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 ) {
2023-11-12 17:20:54 -07:00
conversations , err := store . Conversations ( )
2023-11-12 00:19:45 -07:00
if err != nil {
fmt . Println ( "Could not fetch conversations." )
return
}
2023-11-04 12:20:13 -06:00
2023-11-12 00:19:45 -07:00
// Example output
// $ lmcli ls
2023-11-04 12:20:13 -06:00
// 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
2023-11-12 00:19:45 -07:00
// 8n3h - 3 days ago - Weekend plans
2023-11-04 12:20:13 -06:00
// 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
2023-11-12 00:19:45 -07:00
2023-11-12 23:35:57 -07:00
type ConversationLine struct {
timeSinceReply time . Duration
formatted string
}
2023-11-12 00:19:45 -07:00
now := time . Now ( )
categories := [ ] string {
"recent" ,
"last hour" ,
2023-11-12 23:56:14 -07:00
"last 6 hours" ,
2023-11-12 00:19:45 -07:00
"last day" ,
"last week" ,
"last month" ,
"last 6 months" ,
"older" ,
}
2023-11-12 23:35:57 -07:00
categorized := map [ string ] [ ] ConversationLine { }
2023-11-12 00:19:45 -07:00
for _ , conversation := range conversations {
2023-11-12 17:20:54 -07:00
lastMessage , err := store . LastMessage ( & conversation )
2023-11-12 00:19:45 -07:00
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"
2023-11-12 23:56:14 -07:00
case messageAge <= 6 * time . Hour :
category = "last 6 hours"
2023-11-12 00:19:45 -07:00
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 ,
)
2023-11-12 23:35:57 -07:00
categorized [ category ] = append (
categorized [ category ] ,
ConversationLine { messageAge , formatted } ,
)
2023-11-12 00:19:45 -07:00
}
for _ , category := range categories {
conversations , ok := categorized [ category ]
if ! ok {
continue
}
2023-11-12 23:35:57 -07:00
slices . SortFunc ( conversations , func ( a , b ConversationLine ) int {
return int ( a . timeSinceReply - b . timeSinceReply )
} )
2023-11-12 00:19:45 -07:00
fmt . Printf ( "%s:\n" , category )
for _ , conv := range conversations {
2023-11-12 23:35:57 -07:00
fmt . Printf ( " %s\n" , conv . formatted )
2023-11-12 00:19:45 -07:00
}
}
2023-11-04 12:20:13 -06:00
} ,
}
2023-11-12 23:56:05 -07:00
var rmCmd = & cobra . Command {
2023-11-20 09:50:56 -07:00
Use : "rm <conversation>" ,
2023-11-12 23:56:05 -07:00
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 {
2023-11-13 19:08:20 -07:00
Fatal ( "Could not search for conversation: %v\n" , err )
2023-11-12 23:56:05 -07:00
}
2023-11-13 19:08:20 -07:00
if conversation . ID == 0 {
2023-11-12 23:56:05 -07:00
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
} ,
}
2023-11-04 12:20:13 -06:00
var viewCmd = & cobra . Command {
2023-11-20 09:50:56 -07:00
Use : "view <conversation>" ,
2023-11-04 12:20:13 -06:00
Short : "View messages in a conversation" ,
2023-11-12 16:32:12 -07:00
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
} ,
2023-11-04 12:20:13 -06:00
Run : func ( cmd * cobra . Command , args [ ] string ) {
2023-11-12 16:32:12 -07:00
shortName := args [ 0 ]
conversation , err := store . ConversationByShortName ( shortName )
if conversation . ID == 0 {
Fatal ( "Conversation not found with short name: %s\n" , shortName )
}
2023-11-12 17:20:54 -07:00
messages , err := store . Messages ( conversation )
2023-11-12 16:32:12 -07:00
if err != nil {
Fatal ( "Could not retrieve messages for conversation: %s\n" , conversation . Title )
}
2023-11-18 08:57:03 -07:00
RenderConversation ( messages , false )
2023-11-12 16:32:12 -07:00
} ,
ValidArgsFunction : func ( cmd * cobra . Command , args [ ] string , toComplete string ) ( [ ] string , cobra . ShellCompDirective ) {
2023-11-12 22:26:21 -07:00
compMode := cobra . ShellCompDirectiveNoFileComp
if len ( args ) != 0 {
return nil , compMode
}
return store . ConversationShortNameCompletions ( toComplete ) , compMode
2023-11-04 12:20:13 -06:00
} ,
}
2023-11-04 16:37:18 -06:00
var replyCmd = & cobra . Command {
2023-11-20 09:50:56 -07:00
Use : "reply <conversation> [message]" ,
2023-11-04 16:37:18 -06:00
Short : "Send a reply to a conversation" ,
Long : ` Sends a reply to conversation and writes the response to stdout. ` ,
2023-11-13 19:09:09 -07:00
Args : func ( cmd * cobra . Command , args [ ] string ) error {
argCount := 1
if err := cobra . MinimumNArgs ( argCount ) ( cmd , args ) ; err != nil {
return err
}
return nil
} ,
2023-11-04 16:37:18 -06:00
Run : func ( cmd * cobra . Command , args [ ] string ) {
2023-11-13 19:09:09 -07:00
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 )
}
2023-11-20 09:50:56 -07:00
messageContents := InputFromArgsOrEditor ( args [ 1 : ] , "# How would you like to reply?\n" )
2023-11-18 08:17:18 -07:00
if messageContents == "" {
Fatal ( "No reply was provided.\n" )
}
2023-11-13 19:09:09 -07:00
userReply := Message {
2023-11-13 23:00:51 -07:00
ConversationID : conversation . ID ,
2023-11-28 22:57:38 -07:00
Role : MessageRoleUser ,
2023-11-13 19:09:09 -07:00
OriginalContent : messageContents ,
}
err = store . SaveMessage ( & userReply )
if err != nil {
Warn ( "Could not save your reply: %v\n" , err )
}
messages = append ( messages , userReply )
2023-11-18 08:57:03 -07:00
RenderConversation ( messages , true )
2023-11-28 22:10:44 -07:00
( & Message { Role : MessageRoleAssistant } ) . RenderTTY ( )
2023-11-13 19:09:09 -07:00
2023-11-28 22:10:44 -07:00
conversation . GenerateAndSaveReplies ( messages )
2023-11-13 19:09:09 -07:00
} ,
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
2023-11-04 16:37:18 -06:00
} ,
}
2023-11-04 12:20:13 -06:00
var newCmd = & cobra . Command {
2023-11-20 09:50:56 -07:00
Use : "new [message]" ,
2023-11-04 12:20:13 -06:00
Short : "Start a new conversation" ,
2023-11-04 16:56:22 -06:00
Long : ` Start a new conversation with the Large Language Model. ` ,
2023-11-04 12:20:13 -06:00
Run : func ( cmd * cobra . Command , args [ ] string ) {
2023-11-20 09:50:56 -07:00
messageContents := InputFromArgsOrEditor ( args , "# What would you like to say?\n" )
2023-11-04 12:20:13 -06:00
if messageContents == "" {
2023-11-04 13:58:48 -06:00
Fatal ( "No message was provided.\n" )
2023-11-04 12:20:13 -06:00
}
2023-11-05 01:41:43 -06:00
conversation := Conversation { }
2023-11-20 09:50:56 -07:00
err := store . SaveConversation ( & conversation )
2023-11-05 01:41:43 -06:00
if err != nil {
Fatal ( "Could not save new conversation: %v\n" , err )
}
2023-11-04 12:20:13 -06:00
2023-11-05 01:54:12 -06:00
messages := [ ] Message {
{
ConversationID : conversation . ID ,
2023-11-28 22:57:38 -07:00
Role : MessageRoleSystem ,
2023-11-21 21:45:06 -07:00
OriginalContent : SystemPrompt ( ) ,
2023-11-05 01:54:12 -06:00
} ,
{
ConversationID : conversation . ID ,
2023-11-28 22:57:38 -07:00
Role : MessageRoleUser ,
2023-11-05 01:54:12 -06:00
OriginalContent : messageContents ,
} ,
2023-11-05 01:41:43 -06:00
}
2023-11-05 01:50:07 -07:00
for _ , message := range messages {
2023-11-05 01:54:12 -06:00
err = store . SaveMessage ( & message )
if err != nil {
2023-11-05 01:50:07 -07:00
Warn ( "Could not save %s message: %v\n" , message . Role , err )
2023-11-05 01:54:12 -06:00
}
2023-11-04 12:20:13 -06:00
}
2023-11-18 08:57:03 -07:00
RenderConversation ( messages , true )
2023-11-28 22:10:44 -07:00
( & Message { Role : MessageRoleAssistant } ) . RenderTTY ( )
2023-11-05 01:41:43 -06:00
2023-11-28 22:10:44 -07:00
conversation . GenerateAndSaveReplies ( messages )
2023-11-04 12:20:13 -06:00
2023-11-12 23:39:06 -07:00
err = conversation . GenerateTitle ( )
if err != nil {
Warn ( "Could not generate title for conversation: %v\n" , err )
}
2023-11-20 09:50:56 -07:00
2023-11-12 23:39:06 -07:00
err = store . SaveConversation ( & conversation )
if err != nil {
Warn ( "Could not save conversation after generating title: %v\n" , err )
}
2023-11-04 12:20:13 -06:00
} ,
}
var promptCmd = & cobra . Command {
2023-11-20 09:50:56 -07:00
Use : "prompt [message]" ,
2023-11-04 12:20:13 -06:00
Short : "Do a one-shot prompt" ,
2023-11-04 16:56:22 -06:00
Long : ` Prompt the Large Language Model and get a response. ` ,
2023-11-04 12:20:13 -06:00
Run : func ( cmd * cobra . Command , args [ ] string ) {
2023-11-20 09:50:56 -07:00
message := InputFromArgsOrEditor ( args , "# What would you like to say?\n" )
if message == "" {
2023-11-04 16:53:09 -06:00
Fatal ( "No message was provided.\n" )
}
2023-11-04 12:20:13 -06:00
messages := [ ] Message {
2023-11-05 01:54:12 -06:00
{
2023-11-28 22:57:38 -07:00
Role : MessageRoleSystem ,
2023-11-21 21:45:06 -07:00
OriginalContent : SystemPrompt ( ) ,
2023-11-05 01:54:12 -06:00
} ,
2023-11-04 12:20:13 -06:00
{
2023-11-28 22:57:38 -07:00
Role : MessageRoleUser ,
2023-11-04 16:53:09 -06:00
OriginalContent : message ,
2023-11-04 12:20:13 -06:00
} ,
}
2023-11-24 08:17:24 -07:00
_ , err := LLMRequest ( messages )
2023-11-04 12:20:13 -06:00
if err != nil {
2023-11-24 08:17:24 -07:00
Fatal ( "Error fetching LLM response: %v\n" , err )
2023-11-04 12:20:13 -06:00
}
} ,
}
2023-11-21 23:53:22 -07:00
var retryCmd = & cobra . Command {
Use : "retry <conversation>" ,
Short : "Retries the last conversation prompt." ,
Long : ` Re-prompt the conversation up to the last user response. Can be used to regenerate the last assistant reply, or simply generate one if an error occurred. ` ,
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 )
}
var lastUserMessageIndex int
2023-11-28 21:43:53 -07:00
// walk backwards through conversations to find last user message
2023-11-23 20:45:43 -07:00
for i := len ( messages ) - 1 ; i >= 0 ; i -- {
2023-11-28 21:43:53 -07:00
if messages [ i ] . Role == MessageRoleUser {
2023-11-21 23:53:22 -07:00
lastUserMessageIndex = i
break
}
2023-11-28 21:50:45 -07:00
if lastUserMessageIndex == 0 {
// haven't found the the last user message yet, delete this one
err = store . DeleteMessage ( & messages [ i ] )
if err != nil {
Warn ( "Could not delete previous reply: %v\n" , err )
}
}
2023-11-21 23:53:22 -07:00
}
messages = messages [ : lastUserMessageIndex + 1 ]
RenderConversation ( messages , true )
2023-11-28 22:10:44 -07:00
( & Message { Role : MessageRoleAssistant } ) . RenderTTY ( )
2023-11-21 23:53:22 -07:00
2023-11-28 22:10:44 -07:00
conversation . GenerateAndSaveReplies ( messages )
2023-11-21 23:53:22 -07:00
} ,
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 continueCmd = & cobra . Command {
Use : "continue <conversation>" ,
Short : "Continues where the previous prompt left off." ,
Long : ` Re-prompt the conversation with all existing prompts. Useful if a reply was cut short. ` ,
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 , true )
2023-11-28 22:10:44 -07:00
( & Message { Role : MessageRoleAssistant } ) . RenderTTY ( )
2023-11-21 23:53:22 -07:00
2023-11-28 22:10:44 -07:00
conversation . GenerateAndSaveReplies ( messages )
2023-11-21 23:53:22 -07:00
} ,
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
} ,
}