diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d2809e..1a95d5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,10 @@ ### Added +* Add interactive mode for text command * Add parameter `history`, `model`, `temp`, `system` for text command * Add history with name for text command +* Add sdk claude for text command * Add sdk openai for text command * Add localized for `en` and `fr` * Read config.ini file diff --git a/commands/text.go b/commands/text.go index 22aebd7..7b410c4 100644 --- a/commands/text.go +++ b/commands/text.go @@ -1,7 +1,7 @@ package commands import ( - "errors" + "fmt" "io" "os" "strconv" @@ -60,27 +60,45 @@ func TextFlags() []cli.Flag { return nil }, }, - &cli.StringFlag{ + &cli.StringSliceFlag{ Name: "system", Aliases: []string{"s"}, Usage: l.Get("text-system-usage"), - Action: func(c *cli.Context, value string) error { - if value == "-" { - stdin, err := io.ReadAll(os.Stdin) + Action: func(c *cli.Context, values []string) error { + for _, value := range values { + if value == "-" { + stdin, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + value = string(stdin) + } + + textSdk.AppendHistory("system", value) + } + + return nil + }, + }, + &cli.StringSliceFlag{ + Name: "file", + Aliases: []string{"f"}, + Usage: l.Get("text-file-usage"), + Action: func(c *cli.Context, files []string) error { + for _, file := range files { + f, err := os.ReadFile(file) if err != nil { return err } - value = string(stdin) - } + if len(f) == 0 { + return fmt.Errorf(l.Get("empty-file"), file) + } - message := sdk.Message{ - Role: "system", - Content: value, + textSdk.AppendHistory("system", string(f)) } - textSdk.AppendHistory(message) - return nil }, }, @@ -97,19 +115,30 @@ func TextFlags() []cli.Flag { return nil }, }, + &cli.BoolFlag{ + Name: "list-history", + Aliases: []string{"l"}, + Usage: l.Get("text-list-history-usage"), + Action: func(c *cli.Context, value bool) error { + if err := service.ListHistory(true, true); err != nil { + return err + } + os.Exit(0) + return nil + }, + }, } } func textAction(c *cli.Context) error { - l := lang.GetLocalize() - if c.NArg() == 0 { - return errors.New(l.Get("no-args")) + if err := service.InteractiveMode(); err != nil { + return err + } + return nil } - prompt := c.Args().First() - - if err := service.SendTextRequest(prompt); err != nil { + if err := service.SendTextRequest(c.Args().First()); err != nil { return err } diff --git a/config/config.go b/config/config.go index 8d32a37..edb4b54 100644 --- a/config/config.go +++ b/config/config.go @@ -13,14 +13,15 @@ import ( var home, _ = os.UserHomeDir() var ( - NAME = "aicli" - VERSION = "1.0.0" - CONFIG_DIR = path.Join(home, ".config", "aicli") - CONFIG_FILE = path.Join(CONFIG_DIR, "config.ini") - LOG_FILE = path.Join(CONFIG_DIR, "log") - HISTORY_FILE = path.Join(CONFIG_DIR, "history.json") - CONFIG_INI *ini.File - CONFIG_EXEMPLE = ` + NAME = "aicli" + VERSION = "1.0.0" + CONFIG_DIR = path.Join(home, ".config", "aicli") + CONFIG_FILE = path.Join(CONFIG_DIR, "config.ini") + LOG_FILE = path.Join(CONFIG_DIR, "log") + HISTORY_FILE = path.Join(CONFIG_DIR, "history.json") + HISTORY_CONTENT = "{ \"default\": [] }" + CONFIG_INI *ini.File + CONFIG_EXEMPLE = ` [text] type=openai model=gpt-4 @@ -58,7 +59,7 @@ func InitConfig() error { } if !utils.FileExist(HISTORY_FILE) { - if _, err := os.Create(HISTORY_FILE); err != nil { + if err := os.WriteFile(HISTORY_FILE, []byte(HISTORY_CONTENT), 0644); err != nil { return err } fmt.Printf("History file created at %s\n", HISTORY_FILE) diff --git a/lang/en.go b/lang/en.go index 247d763..08b49ea 100644 --- a/lang/en.go +++ b/lang/en.go @@ -1,19 +1,26 @@ package lang +import "github.com/LordPax/aicli/utils" + var EN_STRINGS = LangString{ - "usage": "CLI toot to use ai model", - "output-desc": "Output directory", - "output-dir-empty": "Output directory is empty", - "silent": "Disable printing log to stdout", - "no-args": "No arguments provided", - "no-command": "No command provided", - "unknown-sdk": "Unknown sdk \"%s\"", - "sdk-model-usage": "Select a model", - "text-usage": "Generate text from a prompt", - "text-temp-usage": "Set temperature", - "text-system-usage": "Instruction with role system (use \"-\" for stdin)", - "text-history-usage": "Select a history", - "text-clear-usage": "Clear history", - "type-required": "Type is required", - "apiKey-required": "API key is required", + "usage": "CLI toot to use ai model", + "output-desc": "Output directory", + "output-dir-empty": "Output directory is empty", + "silent": "Disable printing log to stdout", + "no-args": "No arguments provided", + "no-command": "No command provided", + "unknown-sdk": "Unknown sdk \"%s\"", + "sdk-model-usage": "Select a model", + "text-usage": "Generate text from a prompt", + "text-temp-usage": "Set temperature", + "text-system-usage": "Instruction with role system (use \"-\" for stdin)", + "text-history-usage": "Select a history", + "text-clear-usage": "Clear history", + "text-file-usage": "Text file to use", + "text-input": "(\"exit\" to quit) " + utils.Blue + "user> " + utils.Reset, + "text-list-history-usage": "List history", + "type-required": "Type is required", + "apiKey-required": "API key is required", + "empty-file": "File \"%s\" is empty", + "empty-history": "History \"%s\" is empty\n", } diff --git a/lang/fr.go b/lang/fr.go index feabe43..d2a84a3 100644 --- a/lang/fr.go +++ b/lang/fr.go @@ -1,19 +1,26 @@ package lang +import "github.com/LordPax/aicli/utils" + var FR_STRINGS = LangString{ - "usage": "CLI pour utiliser des modèles d'IA", - "output-desc": "Répertoire de sortie", - "output-dir-empty": "Le répertoire de sortie est vide", - "silent": "Désactiver l'impression du journal sur stdout", - "no-args": "Aucun argument fourni", - "no-command": "Aucune commande fournie", - "unknown-sdk": "Sdk inconnu \"%s\"", - "sdk-model-usage": "Sélectionner un modèle", - "text-usage": "Générer du texte à partir d'un prompt", - "text-temp-usage": "Définir la température", - "text-system-usage": "Instruction avec rôle système (utilisez \"-\" pour stdin)", - "text-history-usage": "Sélectionner un historique", - "text-clear-usage": "Effacer l'historique", - "type-required": "Le type est requis", - "apiKey-required": "La clé API est requise", + "usage": "CLI pour utiliser des modèles d'IA", + "output-desc": "Répertoire de sortie", + "output-dir-empty": "Le répertoire de sortie est vide", + "silent": "Désactiver l'impression du journal sur stdout", + "no-args": "Aucun argument fourni", + "no-command": "Aucune commande fournie", + "unknown-sdk": "Sdk inconnu \"%s\"", + "sdk-model-usage": "Sélectionner un modèle", + "text-usage": "Générer du texte à partir d'un prompt", + "text-temp-usage": "Définir la température", + "text-system-usage": "Instruction avec rôle système (utilisez \"-\" pour stdin)", + "text-history-usage": "Sélectionner un historique", + "text-clear-usage": "Effacer l'historique", + "text-file-usage": "Fichier texte à utiliser", + "text-input": "(\"exit\" pour quitter) " + utils.Blue + "user> " + utils.Reset, + "text-list-history-usage": "Lister l'historique", + "type-required": "Le type est requis", + "apiKey-required": "La clé API est requise", + "empty-file": "Le fichier est vide", + "empty-history": "L'historique \"%s\" est vide\n", } diff --git a/sdk/claude.go b/sdk/claude.go new file mode 100644 index 0000000..6a944da --- /dev/null +++ b/sdk/claude.go @@ -0,0 +1,100 @@ +package sdk + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/LordPax/aicli/utils" +) + +type ClaudeResponse struct { + Role string `json:"role"` + Content []Content `json:"content"` +} + +type ClaudeText struct { + Sdk + SdkText +} + +// Initialize ClaudeText struct, inheriting from Sdk and SdkText +func NewClaudeText(apiKey, model string, temp float64) (*ClaudeText, error) { + sdkService := &ClaudeText{ + Sdk: Sdk{ + ApiUrl: "https://api.anthropic.com/v1/messages", + ApiKey: apiKey, + Model: "claude-3-5-sonnet-20240620", + }, + SdkText: SdkText{ + History: make(map[string][]Message), + SelectedHistory: "default", + Temp: 0.7, + }, + } + + if model != "" { + sdkService.Model = model + } + + if temp != 0 { + sdkService.Temp = temp + } + + if err := sdkService.LoadHistory(); err != nil { + return nil, err + } + + return sdkService, nil +} + +func (c *ClaudeText) SendRequest(text string) (Message, error) { + var textResponse ClaudeResponse + + c.AppendHistory("user", text) + + jsonBody, err := json.Marshal(TextBody{ + Model: c.Model, + // MaxTokens: 1024, + Messages: c.GetHistory(), + }) + if err != nil { + return Message{}, err + } + + resp, err := utils.PostRequest(c.ApiUrl, jsonBody, map[string]string{ + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "x-api-key": c.ApiKey, + }) + if err != nil { + return Message{}, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return Message{}, err + } + + if resp.StatusCode != http.StatusOK { + var errorMsg ErrorMsg + if err := json.Unmarshal(respBody, &errorMsg); err != nil { + return Message{}, err + } + return Message{}, errors.New(errorMsg.Error.Message) + } + + if err := json.Unmarshal(respBody, &textResponse); err != nil { + return Message{}, err + } + + respMessage := c.AppendHistory(textResponse.Role, textResponse.Content[0].Text) + + if err := c.SaveHistory(); err != nil { + return Message{}, err + } + + return respMessage, nil +} diff --git a/sdk/openai-text.go b/sdk/openai.go similarity index 79% rename from sdk/openai-text.go rename to sdk/openai.go index b9fdfdc..dd7dbf3 100644 --- a/sdk/openai-text.go +++ b/sdk/openai.go @@ -3,13 +3,21 @@ package sdk import ( "encoding/json" "errors" - "fmt" "io" "net/http" "github.com/LordPax/aicli/utils" ) +type OpenaiResponse struct { + Choices Choices `json:"choices"` +} + +type Choices []struct { + Index int64 `json:"index"` + Message Message `json:"message"` +} + type OpenaiText struct { Sdk SdkText @@ -46,28 +54,21 @@ func NewOpenaiText(apiKey, model string, temp float64) (*OpenaiText, error) { } func (o *OpenaiText) SendRequest(text string) (Message, error) { - var textResponse TextResponse + var textResponse OpenaiResponse - message := Message{ - Role: "user", - Content: text, - } + o.AppendHistory("user", text) - o.AppendHistory(message) - - body := TextBody{ + jsonBody, err := json.Marshal(TextBody{ Model: o.Model, Messages: o.GetHistory(), - } - - jsonBody, err := json.Marshal(body) + }) if err != nil { return Message{}, err } resp, err := utils.PostRequest(o.ApiUrl, jsonBody, map[string]string{ "Content-Type": "application/json", - "Authorization": fmt.Sprintf("Bearer %s", o.ApiKey), + "Authorization": "Bearer " + o.ApiKey, }) if err != nil { return Message{}, err @@ -91,8 +92,8 @@ func (o *OpenaiText) SendRequest(text string) (Message, error) { return Message{}, err } - respMessage := textResponse.Choices[0].Message - o.AppendHistory(respMessage) + msg := textResponse.Choices[0].Message + respMessage := o.AppendHistory(msg.Role, msg.Content) if err := o.SaveHistory(); err != nil { return Message{}, err diff --git a/sdk/sdk.go b/sdk/sdk.go index 8bdd529..b7cc138 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -8,7 +8,7 @@ import ( "github.com/LordPax/aicli/lang" ) -var sdkTextInstance ISdkText +var sdkTextInstance ITextService type ISdk interface { SendRequest(text string) (Message, error) @@ -30,10 +30,6 @@ func (s *Sdk) GetModel() string { return s.Model } -// type SdkImage struct { -// Dimenssions string -// } - func InitSdkText() error { var err error @@ -46,6 +42,8 @@ func InitSdkText() error { switch sdkType { case "openai": sdkTextInstance, err = NewOpenaiText(apiKey, model, temp) + case "claude": + sdkTextInstance, err = NewClaudeText(apiKey, model, temp) default: return fmt.Errorf(l.Get("unknown-sdk"), sdkType) } @@ -97,6 +95,6 @@ func InitSdk() error { return nil } -func GetSdkText() ISdkText { +func GetSdkText() ITextService { return sdkTextInstance } diff --git a/sdk/text.go b/sdk/text.go index c5330f9..f9f0e95 100644 --- a/sdk/text.go +++ b/sdk/text.go @@ -7,37 +7,43 @@ import ( "github.com/LordPax/aicli/config" ) +type Content struct { + Type string `json:"type"` + Text string `json:"text"` + Source struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` + } `json:"source"` +} + type Message struct { Role string `json:"role"` Content string `json:"content"` + // Content []Content `json:"content"` } type TextBody struct { - Model string `json:"model"` + Model string `json:"model"` + // MaxTokens int64 `json:"max_tokens"` Messages []Message `json:"messages"` } -type TextResponse struct { - Choices Choices `json:"choices"` -} - -type Choices []struct { - Index int64 `json:"index"` - Message Message `json:"message"` -} - type ErrorMsg struct { Error struct { Message string `json:"message"` } `json:"error"` } -type ISdkText interface { +type ITextService interface { ISdk + ISdkText +} +type ISdkText interface { SetTemp(temp float64) GetTemp() float64 - AppendHistory(text Message) + AppendHistory(role, text string) Message SaveHistory() error LoadHistory() error GetHistory() []Message @@ -68,9 +74,21 @@ func (s *SdkText) GetTemp() float64 { return s.Temp } -func (s *SdkText) AppendHistory(text Message) { +func (s *SdkText) AppendHistory(role, text string) Message { name := s.SelectedHistory - s.History[name] = append(s.History[name], text) + message := Message{ + Role: role, + Content: text, + // Content: []Content{ + // { + // Type: "text", + // Text: text, + // }, + // }, + } + s.History[name] = append(s.History[name], message) + + return message } func (s *SdkText) SaveHistory() error { diff --git a/service/text.go b/service/text.go index fc2a143..7b3c309 100644 --- a/service/text.go +++ b/service/text.go @@ -2,19 +2,101 @@ package service import ( "fmt" + "io" + "os" + "github.com/LordPax/aicli/lang" "github.com/LordPax/aicli/sdk" + "github.com/LordPax/aicli/utils" ) func SendTextRequest(prompt string) error { textSdk := sdk.GetSdkText() + if prompt == "-" { + stdin, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + prompt = string(stdin) + } + resp, err := textSdk.SendRequest(prompt) if err != nil { return err } + // fmt.Println(resp.Content[0].Text) fmt.Println(resp.Content) return nil } + +func InteractiveMode() error { + textSdk := sdk.GetSdkText() + l := lang.GetLocalize() + + if err := ListHistory(false, false); err != nil { + return err + } + + for { + input := utils.Input(l.Get("text-input"), "", false) + if input == "exit" { + break + } + + resp, err := textSdk.SendRequest(input) + if err != nil { + return err + } + + fmt.Print("\n") + fmt.Println(utils.Red + resp.Role + ">" + utils.Reset) + // fmt.Println(resp.Content[0].Text) + fmt.Println(resp.Content) + fmt.Print("\n") + } + + return nil +} + +func ListHistory(showSystem, showMsg bool) error { + textSdk := sdk.GetSdkText() + l := lang.GetLocalize() + log, err := utils.GetLog() + if err != nil { + return err + } + + history := textSdk.GetHistory() + + if len(history) == 0 && showMsg { + log.Printf(l.Get("empty-history"), textSdk.GetSelectedHistory()) + return nil + } + + for _, message := range history { + role := message.Role + + if role == "system" && !showSystem { + continue + } + + switch role { + case "user": + fmt.Print(utils.Blue + "user> " + utils.Reset) + case "system": + fmt.Println(utils.Green + "system> " + utils.Reset) + case "assistant": + fmt.Println(utils.Red + "assistant> " + utils.Reset) + } + + // fmt.Println(message.Content[0].Text) + fmt.Println(message.Content) + fmt.Print("\n") + } + + return nil +} diff --git a/utils/utils.go b/utils/utils.go index 05945be..40fcbf7 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1,11 +1,21 @@ package utils import ( + "bufio" "bytes" + "fmt" "net/http" "os" ) +const ( + Escape = "\x1b" + Reset = Escape + "[0m" + Red = Escape + "[31m" + Green = Escape + "[32m" + Blue = Escape + "[34m" +) + func FileExist(file string) bool { _, err := os.Stat(file) return !os.IsNotExist(err) @@ -32,3 +42,25 @@ func PostRequest(url string, data []byte, option map[string]string) (*http.Respo } return resp, nil } + +func Input(prompt string, defaultVal string, nullable bool) string { + if defaultVal != "" { + prompt = fmt.Sprintf("[%s] %s", defaultVal, prompt) + } + + fmt.Print(prompt) + + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + text := scanner.Text() + + if text == "" && defaultVal != "" { + return defaultVal + } + + if text == "" && !nullable { + return Input(prompt, defaultVal, nullable) + } + + return text +}