psgpt.ps1

# Initialize the conversation file path as a file path in the current directory named conversation-{iso8601 current date}.json
$global:conversationFilePath = "conversation-" + (Get-Date -Format "yyyy-MM-ddTHH-mm-ss") + ".json"

function Invoke-ChatGptAPI {
    param (
        [string] $model,
        [PSCustomObject[]] $messages,
        [string] $apiKey
    )
  
    if (!$apiKey) {
        Write-Output "Error: OpenAI API Key not found in environment variables. Set the CHATGPT_API_KEY environment variable with your API key."
        return
    }
  
    $header = @{
        "Content-Type"  = "application/json"
        "Authorization" = "Bearer $apiKey"
    }
  
    $body = @{
        "model"    = $model
        # convert messages into PSCustomObject array if there are any
        # otherwise, set it to an empty array
        "messages" = if ($messages) { @($messages) } else { @() }
    }
  
    # convert messages into array
    $body.messages = @($body.messages)
  
    $json = $body | ConvertTo-Json
  
    $response = Invoke-RestMethod -Uri "https://api.openai.com/v1/chat/completions" -Method POST -Headers $header -Body $json
  
    return $response
}

function DeserializeObject {
    param (
        [string] $content
    )
    #Convert JSON file to an object
    $object = $content | ConvertFrom-Json
    if (!$object) {
        $object = [PSCustomObject]@{}
    }
    return $object
}

function Get-Conversation {
    param (
        [string] $filePath
    )

    if (-not (Test-Path $filePath)) {
        New-Item -Path $filePath -ItemType File -Force
        "{}" | Set-Content $filePath
    }
    $content = Get-Content $filePath
    $conversation = DeserializeObject $content
    
    # initialize $conversation and $conversation.messages if it is null
    if (!$conversation -or !$conversation.messages) {
        $conversation = [PSCustomObject]@{
            messages = @()
        }
    }
    return $conversation
}

function Save-Conversation {
    param (
        [string] $filePath,
        [object] $conversation
    )
  
    $conversation | ConvertTo-Json | Set-Content $filePath
    Write-Output "Conversation saved to $filePath."
}

function Get-UserInput {
    return Read-Host "user"
}

function Write-Message {
    param (
        [string] $role,
        [string] $content
    )
    #if content is null, set it to empty string
    if (!$content) {
        $content = ""
    }
    
    $host.UI.RawUI.FlushInputBuffer() # flush any pending input first
    [System.Console]::ResetColor() # reset colors
    $content.Split("`n") | ForEach-Object {
        # convert markdown to VT100 encoded string
        $line = ($_ | ConvertFrom-Markdown -AsVT100EncodedString).VT100EncodedString.Trim()
        [System.Console]::WriteLine($line) 
    }
}

function Invoke-PsGpt-Command {
    param (
        [string] $command
    )

    # Get the command name
    $commandName = $command.Split(" ")[0]

    # Get the arguments
    $arguments = $command.Split(" ")[1..$command.Length]

    # Invoke the command
    if ($commandName -eq "/help" -or $commandName -eq "/h") {
        Write-Output "Commands:"
        Write-Output "/clear, /cls, /c: Clear the console."
        Write-Output "/delete, /del, /d: Delete the conversation file."
        Write-Output "/edit: Edit the conversation file."
        Write-Output "/exit, /quit, /q: Exit the script."
        Write-Output "/help, /h: Show this help message."
        Write-Output "/save, /s: Save the conversation to the file."
        Write-Output "/saveas, /sa: Save the conversation to a new file."
        Write-Output "/wq, /wq!, /x: Save the conversation to the file and exit the script."
    }
    elseif ($commandName -eq "/save" -or $commandName -eq "/s") {
        # Save the conversation to the file
        Save-Conversation -filePath $arguments[0] -conversation $conversation
    }
    elseif ($commandName -eq "/saveas" -or $commandName -eq "/sa") {
        $newFilePath = $arguments[1]
        # Prompt if arguments are not provided
        if (!$newFilePath) {
            $newFilePath = Read-Host "File path"
        }
        # Save the conversation to a new file
        Save-Conversation -filePath $newFilePath -conversation $conversation
        # Update the conversation file path
        $global:conversationFilePath = $newFilePath
    }
    elseif ($commandName -eq "/wq" -or $commandName -eq "/wq!" -or $commandName -eq "/x") {
        # Save the conversation and exit the script
        Save-Conversation -filePath $global:conversationFilePath -conversation $conversation
        break
    }
    elseif ($commandName -eq "/exit" -or $commandName -eq "/quit" -or $commandName -eq "/q") {
        # Exit the script
        break
    }
    elseif ($commandName -eq "/clear" -or $commandName -eq "/cls" -or $commandName -eq "/c") {
        # Clear the console
        Clear-Host
    }
    elseif ($commandName -eq "/edit") {
        # Edit the conversation file
        Invoke-Expression ("notepad.exe " + $arguments[0])
    }
    elseif ($commandName -eq "/delete" -or $commandName -eq "/del" -or $commandName -eq "/d") {
        # Delete the conversation file
        Remove-Item $arguments[0]
    }
    else {
        Write-Output "Error: Command not found."
    }
}

function Invoke-PsGPT {
    # print version
    Write-Output "PsGPT v0.0.9"
    # (Get-Content -Path "psgpt.psd1" -Raw) -match "ModuleVersion = '(.*)'" | Out-Null; $Matches[1]

    # Get the file path for the conversation file
    $filePath = $args[0]

    # Use named arguments
    if ($args[0] -eq "-filePath") {
        $filePath = $args[1]
    }

    # filePath is not a mandatory field, if it is empty the $conversationFilePath global variable will be set instead
    if (!$filePath) {
        $filePath = $global:conversationFilePath
    }
    else {
        $global:conversationFilePath = $filePath
    }

    # Load the conversation from the file
    $conversation = Get-Conversation -filePath $global:conversationFilePath

    # if the conversation is an array of two elements
    # the first element is the conversation file path
    # the second element is the conversation object
    if ($conversation.Length -eq 2) {
        $global:conversationFilePath = $conversation[0]
        $conversation = $conversation[1]
    }
    # Print the conversation if it is not empty
    if ($conversation.messages) {
        $conversation.messages | ForEach-Object {
            # Invoke Write-Message function
            Write-Message $_.role $_.content
        }
    }

    # Load the ChatGPT API key from an environment variable
    $apiKey = [System.Environment]::GetEnvironmentVariable("CHATGPT_API_KEY", "User")

    # Check if the API key is set
    if (!$apiKey) {
        Write-Output "Error: OpenAI API Key not found in environment variables. Set the CHATGPT_API_KEY environment variable with your API key."
        return
    }

    # Main loop to wait for user input
    while ($true) {
        # Get user input from the console, allow multiline input so if user presses enter
        # It will not submit the message, unless the user submits an empty message as the last message
        $userInput = ""
        while ($true) {
            # Read the user input
            $tmpInput = Read-Host "user"
            if ($tmpInput) {
                $userInput += $tmpInput + "`n"
            }
            else {
                break
            }
        }
        # If the user input is empty, skip the rest of the loop
        if (!$userInput) {
            continue
        }
        $userInput = $userInput.Trim()

        # If the user input starts with a slash, it is a command
        if ($userInput.StartsWith("/")) {
            # Invoke the command
            Invoke-PsGpt-Command $userInput
        }
        else {
            # Add the user input to the conversation
            # initialize the conversation if it is empty
            if (!$conversation.messages) {
                $conversation.messages = @()
            }
            $conversation.messages += [PSCustomObject]@{
                role    = "user"
                content = $userInput
            }

            # Call the ChatGPT API
            $assistantResponse = Invoke-ChatGptAPI -model "gpt-3.5-turbo" -messages $conversation.messages -apiKey $apiKey

            # Parse each assistant response and trim the message, then print it
            $assistantResponse.choices | ForEach-Object {
                $_.message.content = $_.message.content.Trim()
                # Add the assistant response to the conversation
                $conversation.messages += $_.message

                Write-Message $_.message.role $_.message.content
            }
        }
    }
}

Invoke-PsGPT @args