diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..96950a1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,39 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/powershell +{ + "name": "PowerShell", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/powershell:lts-debian-11", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "true", + "username": "vscode", + "upgradePackages": "false", + "nonFreePackages": "true" + } + }, + + "postCreateCommand": "sudo chsh vscode -s \"$(which pwsh)\"", + + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "terminal.integrated.defaultProfile.linux": "pwsh" + }, + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-vscode.powershell" + ] + } + } + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a5e50f..0e968dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,9 @@ on: push: paths-ignore: - - 'README.md' - - 'changelog.md' - - 'CommunityContributions/**' + - "README.md" + - "changelog.md" + - "CommunityContributions/**" branches: - master @@ -20,5 +20,7 @@ jobs: - name: Run Continuous Integration shell: pwsh run: | + Install-Module -Name PowerShellAI.Functions -Force + cd ./__tests__ Invoke-Pester -Output Detailed diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a023e8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.vscode/ +.vs/ diff --git a/CommunityContributions/03-AIPrompts-UsingTheModule/AI-Commands.ps1 b/CommunityContributions/03-AIPrompts-UsingTheModule/AI-Demo.psm1 similarity index 84% rename from CommunityContributions/03-AIPrompts-UsingTheModule/AI-Commands.ps1 rename to CommunityContributions/03-AIPrompts-UsingTheModule/AI-Demo.psm1 index 6f9bd59..b180fe5 100644 --- a/CommunityContributions/03-AIPrompts-UsingTheModule/AI-Commands.ps1 +++ b/CommunityContributions/03-AIPrompts-UsingTheModule/AI-Demo.psm1 @@ -1,6 +1,7 @@ #Requires -Modules PowerShellAI -#Marv is a quirky chatbot, you can get all types of weird and wonderful answer from Marv, just ask away using the Question parameter +#Marv is a quirky chatbot, you can get all types of weird and wonderful answer from Marv, just ask away using the Question parameter +#Example Get-AIResponse -Question "What does HTTP stand for" Function Get-AIResponse { param( [Parameter(Mandatory)] @@ -11,6 +12,7 @@ return $result } #Quickly get some brainstorming ideas for anything, just supply what you want to brainstorm and you will get back the ideas +#Example New-AIBrainStorm -BrainStorm "Fun things to do with the family" Function New-AIBrainStorm { param( [Parameter(Mandatory)] @@ -21,6 +23,7 @@ return $result } #Got no food in the kitchen bar a few items? Then maybe you could create somethin amazing from this with an AI recipe +#Example New-AIReceipe -Ingredients "apple sugar bannana milk" Function New-AIRecipe { param( [Parameter(Mandatory)] @@ -31,6 +34,7 @@ return $result } #Need to get the kids to bed but do not have a bed-time story to hand? Well just generate a story of any genre and topic using this function +#Example New-AIStory -Topic "Chicken Pig" -sentences 20 -Genre "comedy" Function New-AIStory { param( [Parameter(Mandatory)] @@ -52,6 +56,7 @@ return $result } #Feeling like you need to chat to someone but do not have anyone? Well now you do, have real-life-like conversations with AI +#Example New-AIChat "Where should I take my dog a walk today?" Function New-AIChat { param( [Parameter(Mandatory)] @@ -62,6 +67,7 @@ return $result } #Got an exam on something and you need to prep some study notes? Do not worry just use this module +#Example New-AIStudyNotes -NumberOfPoints 7 -Studying "Ancient Rome" Function New-AIStudyNotes { param( [Parameter(Mandatory)] @@ -74,6 +80,7 @@ return $result } #Maybe you want to cheat at your local pub quiz. Get the truthful answer to any truthful event. This will need to be prior to 2021 as the Queen of England is still alive apparently. +#Example Get-AIanswer -Question "What is longer a imperial mile or a metri kilometer" Function Get-AIanswer { param( [Parameter(Mandatory)] @@ -85,6 +92,7 @@ return $result } #Maybe you are not down with the kids these days and do not understand all the grammatical errors they write. Now you can decode badly typed English into proper English +#Example Get-AIGrammarCheck -Text "Wassup mush wanna grab some bevies later?" Function Get-AIGrammarCheck { param( [Parameter(Mandatory)] @@ -95,6 +103,7 @@ return $result } #You got an idea for a product, but do not know how to sell the idea? Well just use this hand-dandy function +#Example Get-AIProductAdvert -ProductDescription "Alien Grow" -Tags "replace, hair, manly, speedy, growth, super" Function Get-AIProductAdvert { param( [Parameter(Mandatory)] @@ -107,6 +116,7 @@ return $result } #Need a web color code in a hurry, you know what the color you want is but just do not know the annoying code. Boom I got your back with this function +#Example Get-AIColorCode -DescribeColor "the same color as rust on metal" Function Get-AIColorCode { param( [Parameter(Mandatory)] @@ -116,6 +126,7 @@ $result = ai "The CSS code for a color like $($DescribeColor):" return $result } +#Your new Powershell buddy Function Get-AIPowershell { param( [Parameter(Mandatory)] diff --git a/CommunityContributions/03-AIPrompts-UsingTheModule/README.md b/CommunityContributions/03-AIPrompts-UsingTheModule/README.md index f64e66e..11ab087 100644 --- a/CommunityContributions/03-AIPrompts-UsingTheModule/README.md +++ b/CommunityContributions/03-AIPrompts-UsingTheModule/README.md @@ -1,3 +1,8 @@ +# What Can I Do With This Module? + Yes I asked myself that same question. For me this was like having the internet for the very first time. You could ask it something and you get back an answer. Okay, I hear you asking so what makes OpenAI so great then, if it is like a google but one answer instead of a billion. Well my Powershell friend the answer is simple, you need to read the examples here:- https://platform.openai.com/examples + So after reading these examples and realising the power comes from the prompt you give it, I thought why not make this super-easy and build these as mini-commands you can use + + |Function Name|Parameters|Prompt| |:---|:---|:---| @@ -11,4 +16,4 @@ |Get-AIProductAdvert|ProductDescription,Tags|`Product description:$($ProductDescription) Seed Words:$($Tags)` |Get-AIColorCode|DescribeColor|`The CSS code for a color like $($DescribeColor):` |Get-AIPowershell|Question|`This is a message-style chatbot that can answer questions about using Powershell. It uses a few examples to get the conversation started. $($Question):` -|Get-AIinterviewQuestions|NumberOfQuesitons,Position|`Create a list of $($NumberOfQuesitons) questions for my interview with a $($Position):` \ No newline at end of file +|Get-AIinterviewQuestions|NumberOfQuesitons,Position|`Create a list of $($NumberOfQuesitons) questions for my interview with a $($Position):` diff --git a/CommunityContributions/04-ErrorInsights/ErrorInsightsExamples.ipynb b/CommunityContributions/04-ErrorInsights/ErrorInsightsExamples.ipynb new file mode 100644 index 0000000..64064e9 --- /dev/null +++ b/CommunityContributions/04-ErrorInsights/ErrorInsightsExamples.ipynb @@ -0,0 +1,181 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "dotnet_interactive": { + "language": "csharp" + }, + "polyglot_notebook": { + "kernelName": "csharp" + } + }, + "source": [ + "# Error Insights\n", + "\n", + "Some PowerShell errors are pretty vague so this function can take the details from the last error record and send them to ChatGPT to make sense of them. \n", + "\n", + "Make sure you have your `$env:OpenAIKey` set and have the PowerShellAI module installed and imported into your current PowerShell session." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "dotnet_interactive": { + "language": "pwsh" + }, + "polyglot_notebook": { + "kernelName": "pwsh" + } + }, + "source": [ + "### Example\n", + "\n", + "Web errors are often buried in an exception object. Rather than digging around just send it into the AI with `Invoke-AIErrorHelper`!" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "dotnet_interactive": { + "language": "pwsh" + }, + "polyglot_notebook": { + "kernelName": "pwsh" + }, + "vscode": { + "languageId": "polyglot-notebook" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\u001b[31;1mInvoke-RestMethod: \u001b[0m\n", + "\u001b[31;1m\u001b[36;1mLine |\u001b[0m\n", + "\u001b[31;1m\u001b[36;1m\u001b[36;1m 4 | \u001b[0m \u001b[36;1mInvoke-RestMethod \"https://postman-echo.com/status/429\"\u001b[0m\n", + "\u001b[31;1m\u001b[36;1m\u001b[36;1m\u001b[0m\u001b[36;1m\u001b[0m\u001b[36;1m | \u001b[31;1m ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\u001b[0m\n", + "\u001b[31;1m\u001b[36;1m\u001b[36;1m\u001b[0m\u001b[36;1m\u001b[0m\u001b[36;1m\u001b[31;1m\u001b[31;1m\u001b[36;1m | \u001b[31;1m{\u001b[0m\n", + "\u001b[31;1m\u001b[36;1m\u001b[36;1m\u001b[0m\u001b[36;1m\u001b[0m\u001b[36;1m\u001b[31;1m\u001b[31;1m\u001b[36;1m\u001b[31;1m \"status\": 429\u001b[0m\n", + "\u001b[31;1m\u001b[36;1m\u001b[36;1m\u001b[0m\u001b[36;1m\u001b[0m\u001b[36;1m\u001b[31;1m\u001b[31;1m\u001b[36;1m\u001b[31;1m}\u001b[0m\n" + ] + } + ], + "source": [ + "# Throw an error\n", + "Invoke-RestMethod \"https://postman-echo.com/status/429\"" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": { + "dotnet_interactive": { + "language": "pwsh" + }, + "polyglot_notebook": { + "kernelName": "pwsh" + }, + "vscode": { + "languageId": "polyglot-notebook" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\u001b[96mWebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand\n", + "Response status code does not indicate success: 429 (Too Many Requests).\n", + "\u001b[0m\n", + "\u001b[90mThis PowerShell error occurs when a user has exceeded the rate limit of requests to a web service. This is usually due to making too many requests in a short period of time. A potential solution is to use the Start-Sleep cmdlet to pause the script for a few seconds between requests. This will allow the web service to process the requests without exceeding the rate limit. For example:\n", + "\n", + "Invoke-RestMethod -Uri $url\n", + "Start-Sleep -Seconds 5\n", + "Invoke-RestMethod -Uri $url\u001b[0m\n" + ] + } + ], + "source": [ + "Invoke-AIErrorHelper" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".NET (C#)", + "language": "C#", + "name": ".net-csharp" + }, + "polyglot_notebook": { + "kernelInfo": { + "defaultKernelName": "csharp", + "items": [ + { + "aliases": [ + "c#", + "C#" + ], + "languageName": "C#", + "name": "csharp" + }, + { + "aliases": [ + "frontend" + ], + "name": "vscode" + }, + { + "aliases": [], + "name": "value" + }, + { + "aliases": [], + "name": ".NET" + }, + { + "aliases": [ + "f#", + "F#" + ], + "languageName": "F#", + "name": "fsharp" + }, + { + "aliases": [], + "languageName": "HTML", + "name": "html" + }, + { + "aliases": [], + "languageName": "KQL", + "name": "kql" + }, + { + "aliases": [], + "languageName": "Mermaid", + "name": "mermaid" + }, + { + "aliases": [ + "powershell" + ], + "languageName": "PowerShell", + "name": "pwsh" + }, + { + "aliases": [], + "languageName": "SQL", + "name": "sql" + } + ] + } + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/CommunityContributions/05-Settings/Settings.ipynb b/CommunityContributions/05-Settings/Settings.ipynb new file mode 100644 index 0000000..7cd458c --- /dev/null +++ b/CommunityContributions/05-Settings/Settings.ipynb @@ -0,0 +1,214 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "dotnet_interactive": { + "language": "csharp" + }, + "polyglot_notebook": { + "kernelName": "csharp" + } + }, + "source": [ + "### Set the $env:OpenAIKey = 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "dotnet_interactive": { + "language": "csharp" + }, + "polyglot_notebook": { + "kernelName": "csharp" + } + }, + "source": [ + "### Get OpenAI User Information" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "pwsh" + }, + "polyglot_notebook": { + "kernelName": "pwsh" + }, + "vscode": { + "languageId": "polyglot-notebook" + } + }, + "outputs": [], + "source": [ + "$organizationId = \"\"\n", + "Get-OpenAIUser $organizationId" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "dotnet_interactive": { + "language": "csharp" + }, + "polyglot_notebook": { + "kernelName": "csharp" + } + }, + "source": [ + "### Get Usage Information" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "pwsh" + }, + "polyglot_notebook": { + "kernelName": "pwsh" + }, + "vscode": { + "languageId": "polyglot-notebook" + } + }, + "outputs": [], + "source": [ + "$startDate = '2023-03-01'\n", + "$endDate = '2023-04-01'\n", + "\n", + "$usage = Get-OpenAIUsage -StartDate $startDate -EndDate $endDate\n", + "\n", + "$usage.daily_costs" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "dotnet_interactive": { + "language": "csharp" + }, + "polyglot_notebook": { + "kernelName": "csharp" + } + }, + "source": [ + "### Get API keys" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "pwsh" + }, + "polyglot_notebook": { + "kernelName": "pwsh" + }, + "vscode": { + "languageId": "polyglot-notebook" + } + }, + "outputs": [], + "source": [ + "Get-OpenAIKey" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".NET (C#)", + "language": "C#", + "name": ".net-csharp" + }, + "polyglot_notebook": { + "kernelInfo": { + "defaultKernelName": "csharp", + "items": [ + { + "aliases": [ + "c#", + "C#" + ], + "languageName": "C#", + "name": "csharp" + }, + { + "aliases": [ + "frontend" + ], + "languageName": null, + "name": "vscode" + }, + { + "aliases": [], + "languageName": null, + "name": ".NET" + }, + { + "aliases": [ + "f#", + "F#" + ], + "languageName": "F#", + "name": "fsharp" + }, + { + "aliases": [], + "languageName": "HTML", + "name": "html" + }, + { + "aliases": [], + "languageName": "KQL", + "name": "kql" + }, + { + "aliases": [], + "languageName": "Mermaid", + "name": "mermaid" + }, + { + "aliases": [ + "powershell" + ], + "languageName": "PowerShell", + "name": "pwsh" + }, + { + "aliases": [], + "languageName": "SQL", + "name": "sql" + }, + { + "aliases": [], + "languageName": null, + "name": "value" + }, + { + "aliases": [ + "js" + ], + "languageName": "JavaScript", + "name": "javascript" + }, + { + "aliases": [], + "name": "webview" + } + ] + } + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/CommunityContributions/06-AIFunctionBuilder/README.md b/CommunityContributions/06-AIFunctionBuilder/README.md new file mode 100644 index 0000000..f299f84 --- /dev/null +++ b/CommunityContributions/06-AIFunctionBuilder/README.md @@ -0,0 +1,94 @@ +# AIFunctionBuilder + +AIFunctionBuilder takes a prompt and generates a PowerShell function which is validated for syntax and logical issues so you don't have to do the boring work. + +To launch the function builder just enter the command `Invoke-AIFunctionBuilder` with no parameters, the video below demonstrates most of the current features. + +https://user-images.githubusercontent.com/13159458/230610955-c37ad3e8-f12c-4802-83d5-20ed550f4a2c.mp4 + +## Usage + +To launch the function builder interactively: +```pwsh +Invoke-AIFunctionBuilder +``` +To edit an existing function provide the text and a prompt that would be used to create it, the builder will correct any issues and validate it meets the prompt requirements: +```pwsh +Invoke-AIFunctionBuilder -InitialFunction "function Say-Hello { Write-Host 'hello' }" -Prompt "Write a powershell function that will say hello" +``` + +The function builder is designed to run interactively so you can see the work the LLM is doing but if you want to you can run the function builder non-interactively with the `-NonInteractive` switch: +```pwsh +"Write a powershell function that will list all available wifi networks" | Invoke-AIFunctionBuilder -NonInteractive +``` + +## Background + +Hi I'm [@shaun_lawrie](https://twitter.com/shaun_lawrie) and I've spent a bit of time spent going back-and-forth between ChatGPT and other LLM tooling to validate any output that's more complicated than a one-liner. + +The approach I've built here is an experiment that uses an automated feedback loop to iterate on the initial script the LLM produces so I don't have to muck around fixing the obvious issues. By utilizing [PSScriptAnalyzer](https://github.com/PowerShell/PSScriptAnalyzer) and the PowerShell Abstract Syntax Tree (AST) to parse the script and check for the common signs of hallucination the script can be validated to a degree that should provide much better reliability on the first execution. + +The script is not executed until the user reviews it because blindly executing code can cause some damage (think: write me a script to clean up space on my harddrive). + +I chose to focus this solution on "functions" instead of PowerShell code in a general sense because they're relatively easy to reason about, they generally take parameters and return values. Realistically this is producing a single-function module but as seen in the video above it can also produce multi-function modules occasionally. + +## Solution + +The function builder starts with `Invoke-AIFunctionBuilder` which generates an initial version of the function and then optimizes it and asks the user what to do with the output. The optimize logic is detailed below because it's fairly hefty. + +```mermaid +flowchart + A[Invoke-AIFunctionBuilder] -->|Ask LLM to Write a PowerShell Function| B([Seed Function]) + B --> |Optimize the Function| C([Optimized Function]) + C --> D{What do you want to do?} + D --> E[Run] + D --> F[Save] + D --> G[Edit] + G --> B + E --> H{Error Occurred?} + H --> |Yes| B +``` + +## Optimize the Function +This is the heaviest bit of complexity which is where the seed function is validated in a loop with both the syntax and semantics being checked until they're both correct. Originally the code corrections were being performed by [Codex](https://openai.com/blog/openai-codex) but OpenAI discontinued the model, I still stand by this model performing much better for targeted code editing but I had to update it to use the chat endpoints. + +The syntax checks have been put together using the AST inside this module but these would probably be a good candidate for [custom PSScriptAnalyzer rules](https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/create-custom-rule?view=ps-modules). + +`Optimize-AifbFunction` in [FunctionBuilderCore](/Private/FunctionBuilderCore.ps1) +```mermaid +flowchart + A[Initial Function Version] -.-> B[fa:fa-magnifying-glass Potential Function] + B --> C{Is Syntax Correct?} + C --> |No| D([Ask LLM to fix syntax errors]) + D --> B + C --> |Yes| E{Is Logic Correct?} + E --> |No| H([Ask LLM to fix semantic errors]) + H --> B + E ---> |Yes| G[fa:fa-flag-checkered Final Function] + style A opacity:0 + style C stroke:#ff6723, fill: #65290d + style E stroke:#ff6723, fill: #65290d + style G stroke:#0b0 +``` + +### 🔶 Is Syntax Correct? +The initial version of the function builder checks the following during the syntax checking stage: + - Basic script parsing validates the syntax for things like missing parentheses, brackets, unclosed strings etc. + - Checks commands are available. + - If not it checks on PSGallery if any modules have it and asks if you want to install a module or try another command. + - Checks for parameters used in the script that the commandlet doesn't accept. + - Checks for any unnamed parameters and asks the LLM to always used named params because they're less ambiguous and easier to validate. + - Checks for named parameters being used multiple times. + - Checks that at least one parameter set is satisfied by the parameters provided to the commandlet by the LLM. + - Checks if types used by New-Object/Add-Type are real and resolvable. + - Checks that any .NET static methods used exist and have the correct number of parameters passed to them. + - Checks that any .NET constructors have the correct number of parameters passed to them. + - Checks that any .NET static properties used exist. + +### 🔶 Is Logic Correct? +The semantic check simply asks the LLM "given the prompt {A} does the function {B} meet all requirements?" and if not it is asked to rewrite it to fix any issues. + +## Notes +> **Warning** +> - Sometimes the builder can get stuck in a loop because it tries to fix something then breaks it over and over, try to be clearer in the prompt, discourage the feature that's being painful or raise an issue. +> - Sometimes PSScriptAnalyzer rules can be too restricting, you can suggest exclusions to add in [`/Private/FunctionBuilderParser.ps1`](/Private/FunctionBuilderParser.ps1) diff --git a/CommunityContributions/07-NotebookCopilot/NotebookCopilot.ipynb b/CommunityContributions/07-NotebookCopilot/NotebookCopilot.ipynb new file mode 100644 index 0000000..76b027f --- /dev/null +++ b/CommunityContributions/07-NotebookCopilot/NotebookCopilot.ipynb @@ -0,0 +1,164 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## NBCopilot\n", + "\n", + "### Pre-requisites\n", + "- Install-Module PowerShellAI\n", + "- Your OpenAI API key from https://platform.openai.com/account/api-keys and set it $env:OpenAIKey\n", + "\n", + "### More details and videos\n", + "- https://github.com/dfinke/PowerShellAI\n", + "\n", + "\n", + "Using NBCopilot enables you to have a conversation with GPT. Meanging the below interaction makes conext and memory of the conversation possible. \n", + "\n", + "> **Note:** Stop-Chat is used at the end to stop the conversation." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "dotnet_interactive": { + "language": "pwsh" + }, + "polyglot_notebook": { + "kernelName": "pwsh" + } + }, + "outputs": [], + "source": [ + "NBCopilot 'Write a PowerShell core function, just the function, no explanation, do not show how to use it, that will: show a date and time in timestamp form'" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "dotnet_interactive": { + "language": "pwsh" + }, + "polyglot_notebook": { + "kernelName": "pwsh" + } + }, + "outputs": [], + "source": [ + "NBCopilot 'add parameter to function to specify a date '" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "dotnet_interactive": { + "language": "pwsh" + }, + "polyglot_notebook": { + "kernelName": "pwsh" + } + }, + "outputs": [], + "source": [ + "NBCopilot 'insert comment based help into function'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "pwsh" + }, + "polyglot_notebook": { + "kernelName": "pwsh" + } + }, + "outputs": [], + "source": [ + "Stop-Chat" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "dotnet_interactive": { + "language": "pwsh" + }, + "polyglot_notebook": { + "kernelName": "pwsh" + } + }, + "outputs": [], + "source": [ + "NBCopilot 'sample kql query' -cellType kql" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "dotnet_interactive": { + "language": "pwsh" + }, + "polyglot_notebook": { + "kernelName": "pwsh" + } + }, + "outputs": [], + "source": [ + "NBCopilot 'hello world in c#, just code no explanation' -cellType csharp" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "pwsh" + }, + "polyglot_notebook": { + "kernelName": "pwsh" + } + }, + "outputs": [], + "source": [ + "Stop-Chat" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".NET (C#)", + "language": "C#", + "name": ".net-csharp" + }, + "language_info": { + "name": "polyglot-notebook" + }, + "polyglot_notebook": { + "kernelInfo": { + "defaultKernelName": "csharp", + "items": [ + { + "aliases": [], + "name": "csharp" + }, + { + "aliases": [], + "languageName": "pwsh", + "name": "pwsh" + } + ] + } + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/CommunityContributions/07-NotebookCopilot/README.md b/CommunityContributions/07-NotebookCopilot/README.md new file mode 100644 index 0000000..164ac3d --- /dev/null +++ b/CommunityContributions/07-NotebookCopilot/README.md @@ -0,0 +1,16 @@ +# NBCopilot - ChatGPT + Polyglot Interactive Notebook + +This PowerShell function allows you to use ChatGPT directly from your Interactive Notebook. + +# Demo + +Check it out. If you like it, please give it a thumbs up, subscribe and hit the notification for future videos. + +
+ +# The Notebook + +The first cell interacts with the ChatGPT API with that prompt and inserts GPT's response at the end, as you can see, the last cell. + + +![Alt text](../../media/NBCopilot.png) \ No newline at end of file diff --git a/Examples/Azure OpenAI/ConnectToAzureInstance-1.ps1 b/Examples/Azure OpenAI/ConnectToAzureInstance-1.ps1 new file mode 100644 index 0000000..3d3b544 --- /dev/null +++ b/Examples/Azure OpenAI/ConnectToAzureInstance-1.ps1 @@ -0,0 +1,8 @@ +Set-AzureOpenAI ` + -Endpoint https://anEndpoint.openai.azure.com/ ` + -DeploymentName aName ` + -ApiVersion 2023-03-15-preview ` + -ApiKey aKey + +chat 'what is 5+8?' +Stop-Chat \ No newline at end of file diff --git a/Examples/Azure OpenAI/ConnectToAzureInstance-2.ps1 b/Examples/Azure OpenAI/ConnectToAzureInstance-2.ps1 new file mode 100644 index 0000000..833b1d1 --- /dev/null +++ b/Examples/Azure OpenAI/ConnectToAzureInstance-2.ps1 @@ -0,0 +1,8 @@ +Set-AzureOpenAI ` + -Endpoint https://anEndpoint.openai.azure.com/ ` + -DeploymentName aName ` + -ApiVersion 2023-03-15-preview ` + -ApiKey aKey + +chat 'I have 3 apples, gave one to the teacher, and gave one to my friend. How many apples do I have left?' +Stop-Chat \ No newline at end of file diff --git a/Examples/Invoke-ExcelTemplate/01-Top-Ten-Cats.ps1 b/Examples/Invoke-ExcelTemplate/01-Top-Ten-Cats.ps1 new file mode 100644 index 0000000..b316c8a --- /dev/null +++ b/Examples/Invoke-ExcelTemplate/01-Top-Ten-Cats.ps1 @@ -0,0 +1,3 @@ +. ./Invoke-ExcelTemplate.ps1 + +Invoke-ExcelTemplate 'top 10 cats, include 5 details about it' \ No newline at end of file diff --git a/Examples/Invoke-ExcelTemplate/02-Top-Ten-Common-Names.ps1 b/Examples/Invoke-ExcelTemplate/02-Top-Ten-Common-Names.ps1 new file mode 100644 index 0000000..a176826 --- /dev/null +++ b/Examples/Invoke-ExcelTemplate/02-Top-Ten-Common-Names.ps1 @@ -0,0 +1,3 @@ +. ./Invoke-ExcelTemplate.ps1 + +Invoke-ExcelTemplate 'top 10 common names, include 5 details about it' \ No newline at end of file diff --git a/Examples/Invoke-ExcelTemplate/03-Agenda-Sales-Kick-Off.ps1 b/Examples/Invoke-ExcelTemplate/03-Agenda-Sales-Kick-Off.ps1 new file mode 100644 index 0000000..51abab3 --- /dev/null +++ b/Examples/Invoke-ExcelTemplate/03-Agenda-Sales-Kick-Off.ps1 @@ -0,0 +1,3 @@ +. ./Invoke-ExcelTemplate.ps1 + +Invoke-ExcelTemplate 'Agenda for a one day sales kick off event with session descriptions and status.' \ No newline at end of file diff --git a/Examples/Invoke-ExcelTemplate/Invoke-ExcelTemplate.ps1 b/Examples/Invoke-ExcelTemplate/Invoke-ExcelTemplate.ps1 new file mode 100644 index 0000000..8e45cb8 --- /dev/null +++ b/Examples/Invoke-ExcelTemplate/Invoke-ExcelTemplate.ps1 @@ -0,0 +1,43 @@ +#Requires -Modules @{ ModuleName="PowerShellAI"; ModuleVersion="0.7.9" }, ImportExcel + +function Invoke-ExcelTemplate { + + <# + .SYNOPSIS + Generates an Excel spreadsheet based on a given query. + + .DESCRIPTION + The Invoke-ExcelTemplate function generates an Excel spreadsheet based on a given query. The function takes a single parameter, $q, which represents the query to be used for generating the spreadsheet. + + .PARAMETER q + The query to be used for generating the spreadsheet. + + .EXAMPLE + Invoke-ExcelTemplate -q 'Agenda for a one day sales kick off event with time, session descriptions, presenter, location, status.' + + This example generates an Excel spreadsheet with an agenda for a one day sales kick off event, including time, session descriptions, presenter, location, and status. + + .EXAMPLE + Invoke-ExcelTemplate -q 'Agenda for a one day sales kick off event with session descriptions and status.' + + This example generates an Excel spreadsheet with an agenda for a one day sales kick off event, including session descriptions and status. + #> + + param( + [Parameter(Mandatory)] + $q + ) + + $messages = @( + New-ChatMessageTemplate system ' + You are an expert at creating Excel spreadsheets for everything. + Output only in markdown tableformat. + ' + ) + + Set-ChatSessionOption -max_tokens 1024 + + $messages += New-ChatMessageTemplate user $q + + (Get-CompletionFromMessages $messages).content | ConvertFrom-GPTMarkdownTable | Export-Excel +} \ No newline at end of file diff --git a/Examples/Read-Excel-Schema-GPT/Read-ExcelAndMultiplyUnits.ps1 b/Examples/Read-Excel-Schema-GPT/Read-ExcelAndMultiplyUnits.ps1 new file mode 100644 index 0000000..900b3dc --- /dev/null +++ b/Examples/Read-Excel-Schema-GPT/Read-ExcelAndMultiplyUnits.ps1 @@ -0,0 +1,49 @@ +#Requires -Modules @{ ModuleName="ImportExcel"; ModuleVersion="7.8.5" }, PowerShellAI + +<# +This PowerShell script uses the ImportExcel module to read an Excel file and multiply the units by 20%. It first gets the schema of the Excel file using the Get-ExcelFileSchema function, then creates chat messages for the user and system. The user is prompted to "read the excel file" and "multiply the units by 20%". The script then uses the Get-CompletionFromMessages function to get completion from the chat messages and outputs the second to second-to-last lines of the content. Finally, it outputs the PowerShell code that reads the Excel file, multiplies the units by 20%, and formats the output as a table. +#> + +# Set the path to the Excel file +$path = "$PSScriptRoot\salesData.xlsx" + +# Get the schema of the Excel file using the ImportExcel module +$schema = Get-ExcelFileSchema $path + +# Create chat messages for the user and system +$messages = @() +$messages += New-ChatMessageTemplate -Role system -Content (@' +You are a great PowerShell assistant. Based on the attached +excel schema, sheet names and property names, please create PowerShell code +based on follow up questions, no explanation needed. + +- Use the PowerShell ImportExcel module +- Use the this excel file {0}. +- Output results to the console +- Schema: +{1} +'@ -f $path, $schema) + +$messages += New-ChatMessageTemplate -Role user -Content " +read the excel file +multiply the units by 20% +" + +# Set the max tokens for the chat session +Set-ChatSessionOption -max_tokens 1024 + +# Get completion from the chat messages +$r = Get-CompletionFromMessages $messages + +# Split the content by new lines and output the second to second-to-last lines +$content = $r.content -split '\r?\n' +$content[1..($content.Count - 2)] + +# Output: Your mileage may vary +<# + +$excelFile = "D:\mygit\PowerShellAI\Examples\yyz\salesData.xlsx" +$data = Import-Excel -Path $excelFile -WorksheetName "Sheet1" +$data | ForEach-Object { $_.Units = $_.Units * 1.2; $_ } | Format-Table + +#> \ No newline at end of file diff --git a/Examples/Read-Excel-Schema-GPT/salesData.xlsx b/Examples/Read-Excel-Schema-GPT/salesData.xlsx new file mode 100644 index 0000000..a7037dd Binary files /dev/null and b/Examples/Read-Excel-Schema-GPT/salesData.xlsx differ diff --git a/InstallModule.ps1 b/InstallModule.ps1 index aa19331..02abef5 100644 --- a/InstallModule.ps1 +++ b/InstallModule.ps1 @@ -6,5 +6,5 @@ Select-Object -First 1 $fullPath = Join-Path $fullPath -ChildPath "PowerShellAI" Push-location $PSScriptRoot -Robocopy . $fullPath /mir /XD .vscode images .git .github /XF README.md .gitattributes .gitignore install.ps1 InstallModule.ps1 PublishToGallery.ps1 +Robocopy . $fullPath /mir /XD .vscode images .git .github .devcontainer /XF README.md .gitattributes .gitignore install.ps1 InstallModule.ps1 PublishToGallery.ps1 Pop-Location \ No newline at end of file diff --git a/PSAI.png b/PSAI.png new file mode 100644 index 0000000..c59af3b Binary files /dev/null and b/PSAI.png differ diff --git a/PowerShellAI.psd1 b/PowerShellAI.psd1 index 8fae344..61709fa 100644 --- a/PowerShellAI.psd1 +++ b/PowerShellAI.psd1 @@ -1,40 +1,119 @@ @{ RootModule = 'PowerShellAI.psm1' - ModuleVersion = '0.4.7' + ModuleVersion = '0.9.7' GUID = '081ce7b4-6e63-41ca-92a7-2bf72dbad018' Author = 'Douglas Finke' CompanyName = 'Doug Finke' - Copyright = 'c 2023 All rights reserved.' + Copyright = 'c 2024 All rights reserved.' Description = @' The PowerShell AI module integrates with the OpenAI API and let's you easily access the GPT models for text completion, image generation and more. '@ + # Modules that must be imported into the global environment prior to importing this module + RequiredModules = @( + @{ModuleName = 'PowerShellAI.Functions'; ModuleVersion = "0.1.0" ; GUID = "bd4306a8-d043-430b-b02c-813ab8330924" } + ) + FunctionsToExport = @( + 'Get-OpenAIEdit' + 'Get-OpenAIEditsUri' + 'Get-OpenAIEmbeddings' + 'Get-OpenAIEmbeddingsUri' 'ai' + 'ConvertTo-JsonL' 'ConvertFrom-GPTMarkdownTable' 'copilot' 'Disable-AIShortCutKey' + 'Disable-ChatPersistence' 'Enable-AIShortCutKey' + 'Enable-ChatPersistence' + 'Get-ChatPersistence' + 'Get-CompletionFromMessages' 'Get-DalleImage' 'Get-GPT3Completion' - 'Get-OpenAIModel' - 'Get-OpenAIModeration' - 'Invoke-OpenAIAPI' - 'Set-DalleImageAsWallpaper' - 'Set-OpenAIKey' + 'Get-GPT4Completion' + 'Get-GPT4Response' 'Get-OpenAIBaseRestUri' + 'Get-OpenAIChatCompletionUri' 'Get-OpenAICompletionsUri' 'Get-OpenAIImagesGenerationsUri' + 'Get-OpenAIKey' + 'Get-OpenAIModel' 'Get-OpenAIModelsUri' + 'Get-OpenAIModeration' 'Get-OpenAIModerationsUri' - 'Get-OpenAIEditsUri' - 'Get-OpenAIEdit' + + 'Get-OpenAIEditsUri' + 'Get-OpenAIEdit' 'New-SpreadSheet' + + # new chat functions + 'Add-ChatMessage' + 'Clear-ChatMessages' + 'Export-ChatSession' + 'Get-ChatMessages' + 'Get-ChatPayload' + 'Get-ChatSession' + 'Get-ChatSessionContent' + 'Get-ChatSessionFile' + 'Get-ChatSessionOptions' + 'Get-ChatSessionPath' + 'Get-ChatSessionTimeStamp' + 'New-Chat' + 'New-ChatAssistantMessage' + 'New-ChatMessage' + 'New-ChatMessageTemplate' + 'New-ChatSystemMessage' + 'New-ChatUserMessage' + 'Reset-ChatSessionOptions' + 'Reset-ChatSessionPath' + 'Reset-ChatSessionTimeStamp' + 'Set-ChatSessionOption' + 'Set-ChatSessionPath' + 'Stop-Chat' + 'Test-ChatInProgress' + # + + # Azure OpenAI + 'Get-ChatAPIProvider' + 'Get-ChatAzureOpenAIURI' + 'Get-AOAIDalleImage' + 'Get-AzureOpenAIOptions' + 'Reset-AzureOpenAIOptions' + 'Set-AzureOpenAI' + 'Set-ChatAPIProvider' + 'Set-AzureOpenAIOptions' + # + + 'Get-OpenAIUsage' + 'Get-OpenAIUser' + 'Invoke-AIErrorHelper' + 'Invoke-AIExplain' + 'Invoke-AIFunctionBuilder' + 'Invoke-OpenAIAPI' + 'New-SpreadSheet' + 'Set-DalleImageAsWallpaper' + 'Set-OpenAIKey' + 'Test-AzureOpenAIKey' + + # Notebook Copilot + 'NBCopilot' + 'New-NBCell' + + # Copilot wrappers + 'git?' # Translate natural language to Git commands + 'gh?' # Translate natural language to to GitHub CLI commands ) AliasesToExport = @( 'gpt' + 'gpt4' + 'chat' + 'ieh' + 'explain' + 'ifb' + '??' ) PrivateData = @{ diff --git a/PowerShellAI.psm1 b/PowerShellAI.psm1 index 2de2f36..8b39db1 100644 --- a/PowerShellAI.psm1 +++ b/PowerShellAI.psm1 @@ -1,5 +1,37 @@ -$Script:OpenAIKey = $null +# Set the OpenAI key to null +$Script:OpenAIKey = $null +# Set the chat API provider to OpenAI +$Script:ChatAPIProvider = 'OpenAI' + +# Set the chat in progress flag to false +$Script:ChatInProgress = $false + +# Create an array list to store chat messages +[System.Collections.ArrayList]$Script:ChatMessages = @() + +# Enable chat persistence +$Script:ChatPersistence = $true + +# Set the options for the chat session +$Script:ChatSessionOptions = @{ + 'model' = 'gpt-4' + 'temperature' = 0.0 + 'max_tokens' = 256 + 'top_p' = 1.0 + 'frequency_penalty' = 0 + 'presence_penalty' = 0 + 'stop' = $null +} + +# Set the options for the Azure OpenAI API +$Script:AzureOpenAIOptions = @{ + Endpoint = 'not set' + DeploymentName = 'not set' + ApiVersion = 'not set' +} + +# Load all PowerShell scripts in the Public and Private directories foreach ($directory in @('Public', 'Private')) { Get-ChildItem -Path "$PSScriptRoot\$directory\*.ps1" | ForEach-Object { . $_.FullName } } diff --git a/Private/CustomReadHost.ps1 b/Private/CustomReadHost.ps1 index 97ec552..4f28e84 100644 --- a/Private/CustomReadHost.ps1 +++ b/Private/CustomReadHost.ps1 @@ -1,3 +1,15 @@ +function Test-VSCodeInstalled { + <# + .SYNOPSIS + Test if VSCode is installed. + + .EXAMPLE + Test-VSCodeInstalled + #> + + $null -ne (Get-Command code -ErrorAction SilentlyContinue) +} + function CustomReadHost { <# .SYNOPSIS @@ -7,11 +19,23 @@ function CustomReadHost { CustomReadHost #> - $Yes = New-Object System.Management.Automation.Host.ChoiceDescription '&Yes', 'Yes, run the code' - $no = New-Object System.Management.Automation.Host.ChoiceDescription '&No', 'No, do not run the code' + $Run = New-Object System.Management.Automation.Host.ChoiceDescription '&Yes', 'Yes, run the code' + $Explain = New-Object System.Management.Automation.Host.ChoiceDescription '&Explain', 'Explain the code' + $Copy = New-Object System.Management.Automation.Host.ChoiceDescription '&Copy', 'Copy to clipboard' + $VSCode = New-Object System.Management.Automation.Host.ChoiceDescription '&VSCode', 'Open in VSCode' + $Quit = New-Object System.Management.Automation.Host.ChoiceDescription '&Quit', 'Do not run the code' - $options = [System.Management.Automation.Host.ChoiceDescription[]]($Yes, $no) + $options = [System.Management.Automation.Host.ChoiceDescription[]]($Run, $Explain, $Copy, $VSCode, $Quit) + + if (Test-VSCodeInstalled) { + $defaultChoice = 4 + $options = [System.Management.Automation.Host.ChoiceDescription[]]($Run, $Explain, $Copy, $VSCode, $Quit) + } + else { + $defaultChoice = 3 + $options = [System.Management.Automation.Host.ChoiceDescription[]]($Run, $Explain, $Copy, $Quit) + } - $message = 'Run the code?' - $host.ui.PromptForChoice($null, $message, $options, 1) + $message = 'Run the code? You can also choose additional actions' + $host.ui.PromptForChoice($null, $message, $options, $defaultChoice) } \ No newline at end of file diff --git a/Private/FunctionBuilderCore.ps1 b/Private/FunctionBuilderCore.ps1 new file mode 100644 index 0000000..bac9a5e --- /dev/null +++ b/Private/FunctionBuilderCore.ps1 @@ -0,0 +1,500 @@ +# Settings that may need tweaking for new models +$script:OpenAISettings = @{ + MaxTokens = 2048 + # The model is setup in Initialize-AifbFunction + Model = $null + # The codewriter is the system used to generate the first instance of the function + CodeWriter = @{ + SystemPrompt = "You are a bot who is an expert in PowerShell and respond to all questions with PowerShell code contained in a ``````powershell code fence. You know that valid PowerShell functions always start with a verb prefix like Add, Clear, Close, Copy, Enter, Exit, Find, Format, Get, Hide, Join, Lock, Move, New, Open, Optimize, Push, Pop, Redo, Remove, Rename, Reset, Resize, Search, Select, Set, Show, Skip, Split, + Step, Switch, Undo, Unlock or Watch. You do not use splatting for commandlet parameters." + Temperature = 0.7 + } + # The codeeditor alters the first instance of the code to meet new requirements + CodeEditor = @{ + SystemPrompt = "You are a bot who is an expert in PowerShell and respond to all questions with the code fixed based on the requests made in the chat. You respond with the code in a ``````powershell code fence and if the code has no issues you return the original code. You do not use splatting for commandlet parameters." + Temperature = 0.3 + Prompts = @{ + SyntaxCorrection = @' +Fix all of these PowerShell issues in the code below: +{0} + +```powershell +{1} +``` +'@ + } + } + # The semantic reinforcement system is used to check the code meets the requirements of the original prompt + SemanticReinforcement = @{ + SystemPrompt = "You are a bot who is an expert in PowerShell and you respond to all questions with only the word YES if the PowerShell functions provided meet the requirements specified or you reply with a corrected version of the PowerShell functions rewritten in their entirety inside a ``````powershell code fence." + Temperature = 0.0 + Prompts = @{ + Reinforcement = @' +Respond with YES if the PowerShell functions below meet the requirement: {0}. +If they don't meet ALL requirements then rewrite the function so that it does and explain what was missing. + +```powershell +{1} +``` +'@ + FollowUp = "What would the functions look like if they were fixed?" + } + } +} + +# Not sure if there will be multiple ways functions will be rendered in responses from the LLM +$script:FunctionExtractionPatterns = @( + @{ + Regex = '(?s)(function\s+([a-z0-9\-]+)\s*\{.+})' + FunctionNameGroup = 2 + FunctionBodyGroup = 1 + } +) + +function Get-AifbUserAction { + <# + .SYNOPSIS + A prompt for AIFunctionBuilder to allow the user to choose what to do with the final function output + #> + + $actions = @( + New-Object System.Management.Automation.Host.ChoiceDescription '&Save', 'Save this function to your local filesystem' + New-Object System.Management.Automation.Host.ChoiceDescription '&Run', 'Save this function to a temporary location on your local filesystem and load it into this PowerShell session to be run' + New-Object System.Management.Automation.Host.ChoiceDescription '&Copy', 'Copy the function to your clipboard' + New-Object System.Management.Automation.Host.ChoiceDescription '&Edit', 'Request changes to this function' + New-Object System.Management.Automation.Host.ChoiceDescription 'E&xplain', 'Explain why this function works' + New-Object System.Management.Automation.Host.ChoiceDescription '&Quit', 'Exit AIFunctionBuilder' + ) + + $response = $Host.UI.PromptForChoice($null, "What do you want to do?", $actions, 5) + + return $actions[$response].Label -replace '&', '' +} + +function Save-AifbFunctionOutput { + <# + .SYNOPSIS + Prompt the user for a destination to save their script output and save it to disk, this uses psm1 files because they're easier to load into the function builder. + #> + param ( + # The name of the function to be tested + [string] $FunctionName, + # A function in a text format to be formatted + [string] $FunctionText, + # The prompt used to create the function + [string] $Prompt + ) + + $suggestedFilename = "$FunctionName.psm1" + + $powershellAiDirectory = Join-Path ([Environment]::GetFolderPath("MyDocuments")) "PowerShellAI" + $defaultFile = Join-Path $powershellAiDirectory $SuggestedFilename + $suffix = 1 + while((Test-Path -Path $defaultFile) -and $suffix -le 10) { + $defaultFile = $defaultFile -replace '[0-9]+\.ps1$', "$suffix.ps1" + $suffix++ + } + + while($true) { + $finalDestination = Read-Host -Prompt "Enter a location to save or press enter for the default ($defaultFile)" + if([string]::IsNullOrEmpty($finalDestination)) { + $finalDestination = $defaultFile + if(!(Test-Path $powershellAiDirectory)) { + New-Item -Path $powershellAiDirectory -ItemType Directory -Force | Out-Null + } + } + + if(Test-Path $finalDestination) { + Write-Error "There is already a file at '$finalDestination'" + } else { + Set-Content -Path $finalDestination -Value "<#`n$Prompt`n#>`n`n$FunctionText" + Write-Output $finalDestination + break + } + } +} + +function Remove-AifbComments { + <# + .SYNOPSIS + Removes comments from a string of PowerShell code. + + .EXAMPLE + PS C:\> Remove-AifbComments "function foo { # comment 1 `n # comment 2 `n return 'bar' }" + function foo { `n `n return 'bar' } + #> + param ( + # A function in a text format to have comments stripped + [Parameter(ValueFromPipeline = $true)] + [string] $FunctionText + ) + + process { + $tokens = @() + + [System.Management.Automation.Language.Parser]::ParseInput($FunctionText, [ref]$tokens, [ref]$null) | Out-Null + + $comments = $tokens | Where-Object { $_.Kind -eq "Comment" } + + # Strip comments from bottom to top to preserve extent offsets + $comments | Sort-Object { $_.Extent.StartOffset } -Descending | ForEach-Object { + $preComment = $FunctionText.Substring(0, $_.Extent.StartOffset) + $postComment = $FunctionText.Substring($_.Extent.EndOffset, $FunctionText.Length - $_.Extent.EndOffset) + $FunctionText = $preComment + $postComment + } + + return $FunctionText + } +} + +function ConvertTo-AifbFunction { + <# + .SYNOPSIS + Converts a string containing a function into a hashtable with the function name and body + .EXAMPLE + ConvertTo-AifbFunction "This funtion writes 'bar' to the terminal function Get-Foo { Write-Host 'bar' }" + Would return: + @{ + Name = "Get-Foo" + Body = "function Get-Foo { Write-Host 'bar' }" + } + #> + param ( + # Some text that contains a function name and body to extract + [Parameter(ValueFromPipeline = $true)] + [string] $Text, + [string] $FallbackText + ) + process { + foreach($pattern in $script:FunctionExtractionPatterns) { + if($Text -match $pattern.Regex) { + return @{ + Name = $Matches[$pattern.FunctionNameGroup] + Body = ($Matches[$pattern.FunctionBodyGroup] -replace '(?s)```.+', '' | Format-AifbFunction) + } + } + } + + if($FallbackText) { + Add-AifbLogMessage -Level "WRN" -Message "There is no function in this PowerShell code block: $Text" + foreach($pattern in $script:FunctionExtractionPatterns) { + if($FallbackText -match $pattern.Regex) { + return @{ + Name = $Matches[$pattern.FunctionNameGroup] + Body = ($Matches[$pattern.FunctionBodyGroup] -replace '(?s)```.+', '' | Format-AifbFunction) + } + } + } + } + + Write-Error "There is no function in the PowerShell code block returned by the LLM or in the fallback text provided as a backup. LLM: [$Text]`nFallback: [$FallbackText]" -ErrorAction "Stop" + } +} + +function Format-AifbFunction { + <# + .SYNOPSIS + Strip all comments from a PowerShell code block and use PSScriptAnalyzer to format the script if it's available + #> + param ( + # A function in a text format to be formatted + [Parameter(ValueFromPipeline = $true)] + [string] $FunctionText + ) + + process { + Write-Verbose "Input function input:`n$FunctionText" + + # Remove all comments because the comments can skew the LLMs interpretation of the code + $FunctionText = $FunctionText | Remove-AifbComments + + # Remove empty lines to save space in the rendering window + $FunctionText = ($FunctionText.Split("`n") | Where-Object { ![string]::IsNullOrWhiteSpace($_) }) -join "`n" + + if(Test-AifbScriptAnalyzerAvailable) { + $FunctionText = Invoke-Formatter -ScriptDefinition $FunctionText -Verbose:$false + } + + Write-Verbose "Output function:`n$FunctionText" + + return $FunctionText + } +} + +function Test-AifbFunctionSyntax { + <# + .SYNOPSIS + This function tests a PowerShell script for quality and commandlet usage issues. + + .DESCRIPTION + The Test-AifbFunctionSyntax function checks a PowerShell script for quality and commandlet usage issues by + checking that the script: + - Uses valid syntax + - All commandlets are used and the correct parameters are used. + For the first line with issues, the function returns a ChatGPT prompt that requests the LLM to perform corrections for the issues. + Only the first line is returned because asking ChatGPT or other LLM models to do multiple things at once tends to result in pretty mangled code. + + .EXAMPLE + $FunctionText = @" + function Get-RunningServices { Get-Service | Where-Object {$_.Status -eq "Running"} | Sort-Object -Property Name } + "@ + $originalPrompt = "Some Prompt" + Test-AifbFunctionSyntax -FunctionText $FunctionText + + This example tests the specified PowerShell script for quality and commandlet usage issues. If any issues are found, the function returns a prompt for corrections. + #> + param ( + # A function in a text format to be formatted + [string] $FunctionText + ) + + $issuesToCorrect = @() + + # Check syntax errors + $issuesToCorrect += Test-AifbFunctionParsing -FunctionText $FunctionText + + # Only check commandlet usage if there are no syntax errors + if($issuesToCorrect.Count -eq 0) { + $issuesToCorrect += Test-AifbFunctionCommandletUsage -FunctionText $FunctionText + } + + # Only check static function usage if there are no syntax errors + if($issuesToCorrect.Count -eq 0) { + $issuesToCorrect += Test-AifbFunctionStaticMethodUsage -FunctionText $FunctionText + } + + # Extract extents to highlight + $extents = $issuesToCorrect.Extent + + # Extract lines to highlight + $lines = $issuesToCorrect.Line | Group-Object | Select-Object -ExpandProperty Name + + # Deduplicate issue messages + $issuesToCorrect = $issuesToCorrect.Message | Group-Object | Select-Object -ExpandProperty Name + + if($issuesToCorrect.Count -gt 0) { + return @{ + Lines = $lines + Extents = $extents + IssuesToCorrect = ($issuesToCorrect -join "`n") + } + } else { + Write-Verbose "The script has no issues to correct" + return @{ + Lines = @() + Extents = @() + IssuesToCorrect = $null + } + } +} + +function Get-AifbSemanticFailureReason { + <# + .SYNOPSIS + This function takes a chat GPT response that contains code and a reason for failing function semantic validation and returns just the reason. + #> + param ( + # The text response from ChatGPT format. + [Parameter(ValueFromPipeline = $true)] + [string] $Text + ) + + $result = $Text.Trim() -replace '(?i)NO(\.|,)?\s+', '' + $result = $result -replace '(?s)\s+(Here is |Here''s |The function should be rewritten|The corrected).+', '' + $result = $result -replace '(?s)```.+', '' + + if([string]::IsNullOrWhiteSpace($result)) { + Write-Error "A reason for failure is required" + } + + return $result +} + +function Write-AifbChat { + <# + .SYNOPSIS + Write the latest chat log for debugging + #> + param () + Get-ChatMessages | ForEach-Object { + Write-Host -NoNewline "$($_.role): " + Write-Host -ForegroundColor DarkGray $_.content + } +} + +function Get-GPT4CompletionWithRetries { + <# + .SYNOPSIS + TODO This is a workaround for rate limiting until https://github.com/dfinke/PowerShellAI/issues/107 is fixed. + #> + param ( + [string] $Content + ) + + $attempts = 0 + $maxAttempts = 5 + + while($attempts -lt $maxAttempts) { + $attempts++ + Write-Verbose "Trying to get AI completion attempt number $attempts" + $response = Get-GPT4Completion -Content $Content -ErrorAction "SilentlyContinue" + if([string]::IsNullOrWhiteSpace($response)) { + $delayInSeconds = 10 * [math]::Pow(2, $attempts) + Add-AifbLogMessage -Level "WRN" -Message "Rate limited by the AI API, trying again in $delayInSeconds seconds." + Start-Sleep -Seconds $delayInSeconds + continue + } else { + return $response + } + } + + Write-Error "Ran out of retries after $maxRetries attempts trying to talk to the AI API." +} + +function Test-AifbFunctionSemantics { + <# + .SYNOPSIS + This function takes a the text of a function and the original prompt used to generate it and checks that the code will achieve the goals of the original prompt. + #> + param ( + # The original prompt used to generate the code provided as FunctionText + [string] $Prompt, + # The function as text generated by the prompt + [string] $FunctionText + ) + + Set-ChatSessionOption ` + -model $script:OpenAISettings.Model ` + -max_tokens $script:OpenAISettings.MaxTokens ` + -temperature $script:OpenAISettings.SemanticReinforcement.Temperature | Out-Null + New-Chat -Content $script:OpenAISettings.SemanticReinforcement.SystemPrompt | Out-Null + + $attempts = 0 + $maxAttempts = 4 + + while($attempts -lt $maxAttempts) { + $attempts++ + + Add-AifbLogMessage "Waiting for AI to validate semantics for prompt '$Prompt'." + $response = Get-GPT4CompletionWithRetries -Content ($script:OpenAISettings.SemanticReinforcement.Prompts.Reinforcement -f $Prompt, $FunctionText) + $response = $response.Trim() + + if($response -match "(?i)\bYES\b") { + Add-AifbLogMessage "The function meets the original intent of the prompt." + return $FunctionText | ConvertTo-AifbFunction -FallbackText $FunctionText + } else { + try { + Add-AifbLogMessage -Level "ERR" -Message ($response | Get-AifbSemanticFailureReason) + } catch { + Add-AifbLogMessage -Level "ERR" -Message "The function doesn't meet the original intent of the prompt." + } + try { + return $response | ConvertTo-AifbFunction + } catch { + try { + Add-AifbLogMessage -Level "WRN" -Message "Following up with the AI because it didn't return any code." + $response = Get-GPT4CompletionWithRetries -Content $script:OpenAISettings.SemanticReinforcement.Prompts.FollowUp + return $response | ConvertTo-AifbFunction -FallbackText $FunctionText + } catch { + Write-AifbChat + Write-Error "Failed to get something sensible out of ChatGPT, the chat log has been dumped above for debugging." + } + } + } + } +} + +function Initialize-AifbFunction { + <# + .SYNOPSIS + This function creates the first version of the code that will be used to start the function builder loop. + #> + param ( + # The prompt format is "Write a PowerShell function that will do something" + [string] $Prompt, + # The model to use for generating the function + [string] $Model, + # The initial function is usually what this would produce but you can provide your own starting point for the functionbuilder to iterate on + [string] $InitialFunction + ) + + Write-Verbose "Getting initial powershell function with prompt '$Prompt'" + Add-AifbLogMessage -NoRender "Built initial function version." + + $script:OpenAISettings.Model = $Model + + Set-ChatSessionOption ` + -model $script:OpenAISettings.Model ` + -max_tokens $script:OpenAISettings.MaxTokens ` + -temperature $script:OpenAISettings.CodeWriter.Temperature | Out-Null + New-Chat -Content $script:OpenAISettings.CodeWriter.SystemPrompt -Verbose:$false | Out-Null + + if($InitialFunction) { + return $InitialFunction | ConvertTo-AifbFunction + } else { + return Get-GPT4CompletionWithRetries -Content $Prompt | ConvertTo-AifbFunction + } +} + +function Optimize-AifbFunction { + <# + .SYNOPSIS + This function takes a the text of a function and the original prompt used to generate it and iterates on it until it meets the intent + of the original prompt and is also syntacticly correct. + #> + param ( + # The original prompt + [string] $Prompt, + # The initial state of the function + [hashtable] $Function, + # The maximum number of times to loop before giving up + [int] $MaximumReinforcementIterations = 15, + # A runtime error the function needs to fix + [string] $RuntimeError, + # Force semantic re-evaluation + [switch] $Force, + # Don't render partial functions + [switch] $NonInteractive + ) + + $iteration = 1 + while ($true) { + if($iteration -gt $MaximumReinforcementIterations) { + Write-AifbChat + Write-Error "A valid function was not able to generated in $MaximumReinforcementIterations iterations, try again with a higher -MaximumReinforcementIterations value or rethink the initial prompt to be more explicit" -ErrorAction "Stop" + } + + Add-AifbLogMessage "Locally testing the syntax of the function." + $corrections = Test-AifbFunctionSyntax -FunctionText $Function.Body + + if($RuntimeError -and $iteration -eq 1) { + Add-AifbLogMessage -Level "ERR" -Message $RuntimeError + $corrections.IssuesToCorrect = @($corrections.IssuesToCorrect, " - $RuntimeError") -join "`n" + } + + if($corrections.IssuesToCorrect -or ($Force -and $iteration -eq 1)) { + if($corrections.IssuesToCorrect) { + Write-AifbFunctionOutput -FunctionText $Function.Body -Prompt $Prompt -HighlightExtents $corrections.Extents -HighlightLines $corrections.Lines + Add-AifbLogMessage "Waiting for AI to correct any issues present in the script." + Set-ChatSessionOption -model $script:OpenAISettings.Model ` + -max_tokens $script:OpenAISettings.MaxTokens ` + -temperature $script:OpenAISettings.CodeEditor.Temperature | Out-Null + New-Chat -Content $script:OpenAISettings.CodeEditor.SystemPrompt -Verbose:$false | Out-Null + $Function = Get-GPT4CompletionWithRetries -Content ($script:OpenAISettings.CodeEditor.Prompts.SyntaxCorrection -f $corrections.IssuesToCorrect, $Function.Body) | ConvertTo-AifbFunction -FallbackText $Function.Body + Write-AifbFunctionOutput -FunctionText $Function.Body -Prompt $Prompt + } + + $Function = Test-AifbFunctionSemantics -FunctionText $Function.Body -Prompt $Prompt + Write-AifbFunctionOutput -FunctionText $Function.Body -Prompt $Prompt + } else { + Add-AifbLogMessage "Function building is complete!" + Write-AifbFunctionOutput -FunctionText $Function.Body -Prompt $Prompt + Start-Sleep -Seconds 3 + break + } + + $iteration++ + } + + return $Function +} \ No newline at end of file diff --git a/Private/FunctionBuilderParser.ps1 b/Private/FunctionBuilderParser.ps1 new file mode 100644 index 0000000..f597bf5 --- /dev/null +++ b/Private/FunctionBuilderParser.ps1 @@ -0,0 +1,479 @@ +# Cached result of checking if PSScriptAnalyzer is installed +$script:ScriptAnalyzerAvailable = $null +# List of PSScriptAnalyzer rules to ignore when validating functions +$script:ScriptAnalyserIgnoredRules = @( + "PSReviewUnusedParameter", + "PSAvoidUsingWriteHost", + "PSUseSingularNouns", + "PSUseShouldProcessForStateChangingFunctions" +) +# ScriptAnalyzer rules to return custom error messages for rule names that match the keys of the hashtable because the default errors trip up LLM models +$script:ScriptAnalyserCustomRuleResponses = @{ + "PSAvoidOverwritingBuiltInCmdlets" = { "The name of the function is reserved, rename the function to not collide with internal PowerShell commandlets." } + "PSUseApprovedVerbs" = { "The function name has to start with a valid PowerShell verb like $((Get-Verb | Where-Object { $_.Group -eq 'Common' } | Select-Object -ExpandProperty Verb) -join ', ')." } + "*ShouldProcess*" = { "The function has to have the CmdletBinding SupportsShouldProcess and use a process block." } +} +# ScriptAnalyzer custom error messages for messages matching keys in the hashtable because the default errors trip up LLM models +$script:ScriptAnalyserCustomMessageResponses = @{ + "Script definition uses Write-Host*" = { "Avoid using Write-Host because it might not work in all hosts." } + "*Unexpected attribute 'CmdletBinding'*" = { "CmdletBinding must be followed by a param block." } + "*uses a plural noun*" = { "Function name can't be a plural$(Get-AifbUnavailableFunctionNames)" } + "*':' was not followed by a valid variable name character*" = { 'A variable inside a PowerShell string cannot be followed by a colon, rewrite $foo: needs to be ${foo}: to delimit the variable.' } +} +# Simple functions that don't need named parameters to work out if they're being used correctly +$script:CommandletsExemptFromNamedParameters = @( + "Write-Host", + "Write-Output", + "Write-Error", + "Write-Warning", + "Write-Verbose", + "Where-Object", + "ForEach-Object", + "Write-Information", + "Write-Verbose", + "Select-Object", + "Import-Module" +) +$script:UnavailableCommandletNames = @() + +function Get-AifbUnavailableFunctionNames { + <# + .SYNOPSIS + Gets a list of function names that have already been attempted that do not work. + #> + if($script:UnavailableCommandletNames.Count -gt 0) { + return " (other unavailable names are $(($script:UnavailableCommandletNames | Group-Object | Select-Object -ExpandProperty "Name") -join ', '))" + } else { + return "" + } +} + +function Test-AifbScriptAnalyzerAvailable { + <# + .SYNOPSIS + Checks if PSScriptAnalyzer is available on this system and uses a cached response to avoid using get-module all the time. + #> + if($null -eq $script:ScriptAnalyzerAvailable) { + if(Get-Module "PSScriptAnalyzer" -ListAvailable -Verbose:$false) { + $script:ScriptAnalyzerAvailable = $true + } else { + Add-AifbLogMessage -Level "WRN" -Message "This module performs better if you have PSScriptAnalyzer installed" -NoRender + $script:ScriptAnalyzerAvailable = $false + } + } + + return $script:ScriptAnalyzerAvailable +} + +function Write-AifbFunctionParsingOutput { + <# + .SYNOPSIS + Writes parsing output to the output stream and also to the renderer log output as errors. + #> + param ( + # The message to log and format + [string] $Message, + [object] $Extent, + [int] $Line + ) + Add-AifbLogMessage -Level "ERR" -Message $Message + return @{ + Line = $Line + Extent = $Extent + Message = " - $Message" + } +} + +function Write-AifbScriptAnalyzerOutput { + <# + .SYNOPSIS + This function will analyze the function text and return the error details for the first line with errors. + #> + param ( + # A function in a text format to be formatted + [string] $FunctionText + ) + $scriptAnalyzerOutput = Invoke-ScriptAnalyzer -ScriptDefinition $FunctionText ` + -Severity @("Warning", "Error", "ParseError") ` + -ExcludeRule $script:ScriptAnalyserIgnoredRules ` + -Verbose:$false + + if($null -ne $scriptAnalyzerOutput) { + $brokenLines = $scriptAnalyzerOutput | Group-Object Line + + # This originally returned the whole list of errors but it was too much for the LLM to understand, just return the errors for the first line with issues and then fix other errors on future iterations + $firstBrokenLine = $brokenLines[0] + $brokenLineErrors = $firstBrokenLine.Group.Message + $ruleNames = $firstBrokenLine.Group.RuleName + + # Write the first custom error message that matches and violated PSScriptAnalyzer rules + foreach($ruleResponse in $script:ScriptAnalyserCustomRuleResponses.GetEnumerator()) { + if($ruleNames | Where-Object { $_ -like $ruleResponse.Key }) { + Write-AifbFunctionParsingOutput -Message (Invoke-Command $ruleResponse.Value) -Line $firstBrokenLine.Name + return + } + } + + # Write the first custom error message that matches and violated PSScriptAnalyzer message + foreach($messageResponse in $script:ScriptAnalyserCustomMessageResponses.GetEnumerator()) { + if($brokenLineErrors | Where-Object { $_ -like $messageResponse.Key }) { + Write-AifbFunctionParsingOutput -Message (Invoke-Command $messageResponse.Value) -Line $firstBrokenLine.Name + return + } + } + + # Otherwise dump the raw error messages + $brokenLineErrors | ForEach-Object { + Write-AifbFunctionParsingOutput -Message $_ -Line $firstBrokenLine.Name + } + } +} + +function Find-AifbCommandletOnline { + <# + .SYNOPSIS + Finds a commandlet online and installs he module it belongs to if the user wants to. + + .EXAMPLE + Find-AifbCommandletOnline -CommandletName "Get-AzActivityLog" + #> + param ( + # The name of the commandlet to find online + [string] $CommandletName + ) + $command = $null + $onlineModules = Find-Module -Command $CommandletName -Verbose:$false + $localModules = Get-Module -ListAvailable -Verbose:$false + if($onlineModules) { + $matchingLocalModules = (Compare-Object -ReferenceObject $onlineModules.Name -DifferenceObject $localModules.Name -ExcludeDifferent).InputObject + if($matchingLocalModules) { + try { + Import-Module $matchingLocalModules -Global -ErrorAction "Stop" + $command = Get-Command $CommandletName -ErrorAction "Stop" + return $command + } catch { + Write-Warning "Couldn't import command from local module '$($matchingLocalModules)'" + } + } + + Write-Host "There are modules online that include the function '$CommandletName' used by ChatGPT. To validate the usage of commandlets in the function the module needs to be installed locally.`n" + Write-Host ($onlineModules | Select-Object Name, ProjectUri | Out-String).Trim() + while($null -eq $command) { + $onlineModuleToInstall = Read-Host "`nEnter the name of one of the modules to install or press enter to get ChatGPT to try use a different command" + if(![string]::IsNullOrEmpty($onlineModuleToInstall)) { + Install-Module -Name $onlineModuleToInstall.Trim() -Scope CurrentUser -Verbose:$false + Import-Module -Name $onlineModuleToInstall.Trim() -Global -Verbose:$false + $command = Get-Command $CommandletName + Write-Host "" + } else { + Write-Host "Asking ChatGPT to use another command instead of installing the module for '$CommandletName'." + break + } + } + } else { + Write-Verbose "No commands matching the name '$CommandletName' were available online." + } + return $command +} + +function Test-AifbFunctionParsing { + <# + .SYNOPSIS + This function tests the quality of a PowerShell function using PSScriptAnalyzer module. + + .DESCRIPTION + The Test-AifbFunctionParsing function checks the quality of a PowerShell script by using the PSScriptAnalyzer module. + If any errors or warnings are detected, the function outputs a list of lines containing errors and their corresponding error messages. + If the module is not installed, the function silently bypasses script quality validation because it's not critical to the operation of the AI Script Builder. + #> + param ( + # A function in a text format to be tested + [string] $FunctionText + ) + + $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput($FunctionText, [ref]$null, [ref]$null) + $functions = $scriptAst.FindAll({$args[0].GetType().Name -eq "FunctionDefinitionAst"}, $true) + + foreach($function in $functions) { + if(Get-Command $function.Name -ErrorAction "SilentlyContinue") { + Write-AifbFunctionParsingOutput -Message "The name of the function is reserved, rename the function to not collide with common function names$(Get-AifbUnavailableFunctionNames)." -Line $function.Extent.StartLineNumber + $script:UnavailableCommandletNames += $FunctionName.Text + } + } + + foreach($function in $functions) { + if($function.Name -notlike "*-*") { + Write-AifbFunctionParsingOutput -Message "The name of the function should follow the PowerShell format of Verb-Noun$(Get-AifbUnavailableFunctionNames)." -Line $function.Extent.StartLineNumber + $script:UnavailableCommandletNames += $FunctionName.Text + } + } + + if(Test-AifbScriptAnalyzerAvailable) { + Write-Verbose "Using PSScriptAnalyzer to validate script quality" + Write-AifbScriptAnalyzerOutput -FunctionText $FunctionText + } else { + Add-AifbLogMessage -Level "WRN" -Message "PSScriptAnalyzer is not installed so falling back on parsing directly with PS internals." + try { + [scriptblock]::Create($FunctionText) | Out-Null + } catch { + $innerExceptionErrors = $_.Exception.InnerException.Errors + if($innerExceptionErrors) { + Write-AifbFunctionParsingOutput -Message $innerExceptionErrors[0].Message -Line 1 + } else { + Write-AifbFunctionParsingOutput -Message "The script is invalid because of a $($_.FullyQualifiedErrorId)." -Line 1 + } + } + } +} + +function Test-AifbFunctionCommandletUsage { + <# + .SYNOPSIS + This function tests the usage of commandlets in a PowerShell script. + + .DESCRIPTION + The Test-AifbFunctionCommandletUsage function checks the usage of commandlets in a PowerShell script by analyzing the Abstract Syntax Tree (AST) of the script. + For each commandlet found in the script, the function checks whether the commandlet is valid and whether any of the commandlet parameters are invalid. + + .EXAMPLE + $FunctionText = Get-Content -Path "C:\Scripts\MyScript.ps1" -Raw + Test-AifbFunctionCommandletUsage -ScriptAst $scriptAst + + This example tests the usage of commandlets in a PowerShell script. + .NOTES + This could likely be converted to a set of PSScriptAnalyzer custom rules https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/create-custom-rule?view=ps-modules + #> + param ( + # A function in a text format to be tested + [string] $FunctionText + ) + + $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput($FunctionText, [ref]$null, [ref]$null) + + $commandlets = $scriptAst.FindAll({$args[0].GetType().Name -eq "CommandAst"}, $true) + $functions = $scriptAst.FindAll({$args[0].GetType().Name -eq "FunctionDefinitionAst"}, $true) + + # Validate each commandlet and return on the first error found because telling the LLM about too many errors at once results in unpredictable fixes + foreach($commandlet in $commandlets) { + $commandletName = $commandlet.CommandElements[0].Value + $commandletParameterNames = $commandlet.CommandElements.ParameterName + $commandletParameterElements = @() + $hasPipelineInput = $null -ne $commandlet.Parent -and $commandlet.Parent.GetType().Name -eq "PipelineAst" -and $commandlet.Parent.PipelineElements.Count -gt 1 + $extent = $commandlet.Extent + if($commandlet.CommandElements.Count -gt 1) { + $commandletParameterElements = $commandlet.CommandElements[1..($commandlet.CommandElements.Count - 1)] + } + + if($functions.Name -contains $commandletName) { + Add-AifbLogMessage -Message "This function calls one of its own functions." + continue + } + + $command = Get-Command $commandletName -ErrorAction "SilentlyContinue" + + # Check online if no local command is found + if($null -eq $command) { + $command = Find-AifbCommandletOnline -CommandletName $commandletName + } + + if($null -eq $command) { + Write-AifbFunctionParsingOutput -Message "The commandlet $commandletName cannot be found, use a different command or add another function to implement the logic$(Get-AifbUnavailableFunctionNames)." -Extent $extent + $script:UnavailableCommandletNames += $commandletName + return + } + + # Check for missing parameters + foreach($param in $commandletParameterNames) { + if(![string]::IsNullOrEmpty($param)) { + if(!$command.Parameters.ContainsKey($param)) { + Write-AifbFunctionParsingOutput -Message "The commandlet $commandletName does not take a parameter named $param, available parameters are $($command.Parameters.Keys -join ', ')." -Extent $extent + return + } + } + } + + # Check for unnamed parameters, these are harder to validate and makes a generated script less obvious as to what it does + if($commandletParameterElements.Count -gt 0 -and $script:CommandletsExemptFromNamedParameters -notcontains $commandletName -and $commandletName -like "*-*") { + # Ignoring splatting + if($commandletParameterElements[0] -like "@*") { + continue + } + + $previousElementWasParameterName = $false + foreach($element in $commandletParameterElements) { + if($element.GetType().Name -eq "CommandParameterAst") { + $previousElementWasParameterName = $true + } else { + if(!$previousElementWasParameterName) { + Write-AifbFunctionParsingOutput -Message "Use a named parameter when passing $element to $commandletName." -Extent $extent + return + } + $previousElementWasParameterName = $false + } + } + } + + # Check named parameters haven't been specified more than once + $duplicateParameters = $commandletParameterNames | Group-Object | Where-Object { $_.Count -gt 1 } + foreach($duplicateParameter in $duplicateParameters) { + Write-AifbFunctionParsingOutput -Message "The parameter $($duplicateParameter.Name) cannot be provided more than once to $commandletName." -Extent $extent + return + } + + # Check at least one parameter set is satisfied if all parameters to this commandlet have been specified by name + $availableParameterSets = @() + if($script:CommandletsExemptFromNamedParameters -notcontains $commandletName -and $commandletName -like "*-*") { + $parameterSetSatisfied = $false + if($command.ParameterSets.Count -eq 0) { + $parameterSetSatisfied = $true + } else { + foreach($parameterSet in $command.ParameterSets) { + $mandatoryParameters = $parameterSet.Parameters | Where-Object { $_.IsMandatory } + $availableParameterSets += "$($parameterSet.Name) ($($mandatoryParameters.Name -join ', '))" + $mandatoryParametersUsed = [array]($mandatoryParameters | Where-Object { $commandletParameterNames -contains $_.Name }).Name + if($hasPipelineInput -and ($mandatoryParameters | Where-Object { $_.ValueFromPipeline })) { + $mandatoryParametersUsed += "Pipeline Input" + } + if($mandatoryParametersUsed.Count -ge $mandatoryParameters.Count) { + Write-Verbose "Parameter set $($parameterSet.Name) was satisfied" + $parameterSetSatisfied = $true + break + } else { + Write-Verbose "Parameter set $($parameterSet.Name) wasn't satisfied, expected $($mandatoryParameters.Count) but found $($mandatoryParametersUsed.Count)" + } + } + } + if(!$parameterSetSatisfied) { + Write-AifbFunctionParsingOutput -Message "Parameter set cannot be resolved using the specified named parameters for $commandletName, available parameter sets are $($availableParameterSets -join ', ')." -Extent $extent + return + } + } + + # Check if types are real + if("Add-Type" -eq $commandletName) { + $commandletParameterElementsText = $commandletParameterElements.Extent.Text + $typeNameIndex = $commandletParameterElementsText.IndexOf('-AssemblyName') + 1 + if($typeNameIndex -gt 0 -and $commandletParameterElementsText.Count -gt $typeNameIndex) { + $typeName = $commandletParameterElements.Extent.Text[$typeNameIndex] -replace "(^['`"]|['`"]`$)", "" + Write-Verbose "Checking type '$typeName' exists for Add-Type" + $typeSections = $typeName -split "\." + for($i = ($typeSections.Length - 1); $i -ge 0; $i--) { + $assembly = $typeSections[0..$i] -join "." + try { + Add-Type -AssemblyName $assembly -ErrorAction Stop + return + } catch { + Add-AifbLogMessage -Level "WRN" -Message "Failed to Add-Type '$assembly'." + } + } + Write-AifbFunctionParsingOutput "Failed to Add-Type '$typeName', the type doesn't exist." -Extent $extent + return + } + } + if("New-Object" -eq $commandletName) { + $commandletParameterElementsText = $commandletParameterElements.Extent.Text + $typeNameIndex = $commandletParameterElementsText.IndexOf('-TypeName') + 1 + if($typeNameIndex -gt 0 -and $commandletParameterElementsText.Count -gt $typeNameIndex) { + $typeName = $commandletParameterElements.Extent.Text[$typeNameIndex] -replace "(^['`"]|['`"]`$)", "" + Write-Verbose "Checking type '$typeName' exists for New-Object" + + $builtinType = [System.Management.Automation.PSTypeName]"$typeName" + if($null -ne $builtinType.Type) { + Write-Verbose "Built-in type found" + return + } + + $typeSections = $typeName -split "\." + for($i = ($typeSections.Length - 1); $i -ge 0; $i--) { + $assembly = $typeSections[0..$i] -join "." + try { + Add-Type -AssemblyName $assembly -ErrorAction Stop + return + } catch { + Add-AifbLogMessage -Level "WRN" -Message "Failed to Add-Type '$assembly'." + } + } + Write-AifbFunctionParsingOutput "Failed to find type for New-Object -TypeName '$typeName', the type doesn't exist." -Extent $extent + return + } + } + } +} + +function Test-AifbFunctionStaticMethodUsage { + <# + .SYNOPSIS + This function tests the usage .net class static methods. + + .PARAMETER FunctionText + Specifies the text content of the PowerShell script to be tested. + + .NOTES + This could likely be converted to a set of PSScriptAnalyzer custom rules https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/create-custom-rule?view=ps-modules + #> + param ( + # A function in a text format to be tested + [string] $FunctionText + ) + + $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput($FunctionText, [ref]$null, [ref]$null) + + $staticMemberCalls = $scriptAst.FindAll({$args[0].Static -eq $true}, $true) + + # Validate each commandlet and return on the first error found because telling the LLM about too many errors at once results in unpredictable fixes + foreach($memberCall in $staticMemberCalls) { + $className = $memberCall.Expression.TypeName.FullName + $memberName = $memberCall.Member.Value + $arguments = $memberCall.Arguments + $extent = $memberCall.Extent + + $instance = Invoke-Expression "[$className]" -ErrorAction "SilentlyContinue" + $instanceMembers = $instance | Get-Member -Static -ErrorAction "SilentlyContinue" | Where-Object { $_.Name -eq $memberName } + + if(!$instance) { + Write-AifbFunctionParsingOutput "The class $className doesn't exist." -Extent $extent + return + } + + if(!$instanceMembers) { + Write-AifbFunctionParsingOutput "The member $memberName doesn't exist on $className." -Extent $extent + return + } + + if($instanceMembers[0].MemberType -eq "Property") { + Write-Verbose "Member is a property" + return + } + + if($memberName -eq "new") { + $constructorArgCounts = ($instance.GetConstructors() | Foreach-Object { $_.GetParameters().Count } | Group-Object).Name + if($constructorArgCounts -notcontains $arguments.Count) { + Write-AifbFunctionParsingOutput "There is no constructor for $className that takes $($arguments.Count) parameters." -Extent $extent + return + } else { + Write-Verbose "Constructor is correct" + return + } + } + + $methods = $instanceMembers | Where-Object { $_.MemberType -eq "Method" } + $methodDefinitions = $methods.Definition -split "static [a-z\.]+ " | Where-Object { ![string]::IsNullOrWhiteSpace($_) } + $foundMethodDefinitionThatHasCorrectArgNumber = $false + foreach($methodDefinition in $methodDefinitions) { + $possibleMethodArgs = @() + if($methodDefinition -notlike "*()*") { + $possibleMethodArgs = ($methodDefinition | Select-String "\((.+)\)").Matches.Groups[1].Value + $possibleMethodArgs = $possibleMethodArgs -split "," | ForEach-Object { $_.Trim() } + } + if($arguments.Count -eq $possibleMethodArgs.Count) { + Write-Verbose "Found a static method that takes the correct number of arguments" + $foundMethodDefinitionThatHasCorrectArgNumber = $true + break + } + } + if(!$foundMethodDefinitionThatHasCorrectArgNumber) { + Write-AifbFunctionParsingOutput "The method $memberName doesn't take $($arguments.Count) arguments." -Extent $extent + return + } + } +} \ No newline at end of file diff --git a/Private/FunctionBuilderRenderer.ps1 b/Private/FunctionBuilderRenderer.ps1 new file mode 100644 index 0000000..91519f5 --- /dev/null +++ b/Private/FunctionBuilderRenderer.ps1 @@ -0,0 +1,165 @@ +$script:LogMessages = [System.Collections.Queue]::new() +$script:LogMessageColors = @{ + "INF" = "White" + "WRN" = "Yellow" + "ERR" = "Red" +} +$script:LogMessagesMaxCount = 8 +$script:FunctionTopLeft = @{X = 0; Y = 0} +$script:RendererBackground = @{ R = 35; G = 35; B = 35 } +$script:FunctionVersion = 1 +$script:InitialPrePrompt = $null +$script:InitialPrompt = $null +$script:NonInteractive = $false + +function Initialize-AifbRenderer { + <# + .SYNOPSIS + Setup the function renderer at the current cursor position, this will be considered the top left of the function for each draw + #> + param ( + [string] $InitialPrePrompt, + [string] $InitialPrompt, + [bool] $NonInteractive + ) + $script:FunctionTopLeft.X = $Host.UI.RawUI.CursorPosition.X + $script:FunctionTopLeft.Y = $Host.UI.RawUI.CursorPosition.Y + 1 + $script:LogMessages = [System.Collections.Queue]::new() + $script:FunctionVersion = 0 + $script:InitialPrePrompt = $InitialPrePrompt + $script:InitialPrompt = $InitialPrompt + $script:NonInteractive = $NonInteractive +} + +function Write-AifbFunctionOutput { + <# + .SYNOPSIS + This function writes a function to the terminal with optional syntax highlighting + + .DESCRIPTION + Using some cursor manipulation and Write-Host this re-renders overtop of itself and clears the rest of the text on the terminal. + Then the function text is drawn and the log data is written underneath it. + #> + param ( + # The text of the function to render + [string] $FunctionText, + # Prompt info + [string] $Prompt, + # Extents to highlight as issues + [array] $HighlightExtents, + # Lines to highlight as issues + [array] $HighlightLines, + # Whether to syntax highlight the function + [switch] $SyntaxHighlight, + # The background color for the code block + [hashtable] $BackgroundRgb = $script:RendererBackground, + # Don't output the log viewer + [switch] $NoLogMessages + ) + + if($script:NonInteractive) { + return + } + + $FunctionText = $FunctionText.Trim() + "`n`n<#`nAIFunctionBuilder Iteration $([int]$script:FunctionVersion++)`n$Prompt`n#>" + $script:FunctionLines = @() + + # Write it all to the terminal and don't overwrite on every render in verbose mode, this makes debugging easier + if($VerbosePreference -ne "SilentlyContinue") { + Write-Verbose "Function text:`n$FunctionText" + return + } + + # Draw from the top left of the terminal window + [Console]::CursorVisible = $false + [Console]::SetCursorPosition(0, 0) + if($script:InitialPrePrompt) { + Write-Host -ForegroundColor Cyan -NoNewline "$($script:InitialPrePrompt): " + Write-Host -NoNewline $script:InitialPrompt + } else { + Write-Host -NoNewline $script:InitialPrompt + } + [Console]::WriteLine(" " * ($Host.UI.RawUI.WindowSize.Width - $Host.UI.RawUI.CursorPosition.X)) + [Console]::WriteLine(" " * ($Host.UI.RawUI.WindowSize.Width)) + + Write-Codeblock -Text $FunctionText -ShowLineNumbers -HighlightExtents $HighlightExtents -HighlightLines $HighlightLines -SyntaxHighlight:$SyntaxHighlight + + # Blank out the rest of the terminal + $endOfFunctionPosition = $Host.UI.RawUI.CursorPosition + $clearingBuffer = "" + 1..($Host.UI.RawUI.WindowSize.Height - $Host.UI.RawUI.CursorPosition.Y) | Foreach-Object { + $clearingBuffer += (" " * $Host.UI.RawUI.WindowSize.Width) + } + [Console]::Write($clearingBuffer) + [Console]::SetCursorPosition($endOfFunctionPosition.X, $endOfFunctionPosition.Y) + Write-Host "" + + # Write the log messages under the function + if(!$NoLogMessages) { + Write-AifbLogMessages + } + [Console]::CursorVisible = $true +} + +function Add-AifbLogMessage { + <# + .SYNOPSIS + Add a log message to the function builder log. + #> + param ( + # The message to add + [string] $Message, + # The level to log it at + [ValidateSet("INF", "WRN", "ERR")] + [string] $Level = "INF", + # Whether to skip rendering the latest log to the terminal + [switch] $NoRender + ) + + Write-Verbose "$Level $Message" + + $logItem = @{ + Date = (Get-Date).ToString("HH:mm:ss") + Message = $Message + Level = $Level + } + $script:LogMessages.Enqueue($logItem) + + if($script:LogMessages.Count -gt $script:LogMessagesMaxCount) { + $script:LogMessages.Dequeue() | Out-Null + } +} + +function Write-AifbLogMessages { + <# + .SYNOPSIS + Write out the current list of log messages to the terminal. + #> + if($VerbosePreference) { + return + } + + $consoleWidth = $Host.UI.RawUI.WindowSize.Width + $script:LogMessages | Foreach-Object { + $logPrefix = "$($_.Date) $($_.Level.PadRight(4))" + $line = $_.Message -replace "`n", ". " -replace "`r", "" + $messageWidth = $consoleWidth - $logPrefix.Length - 1 + if($line.Length -gt $messageWidth) { + $lines = ($line | Select-String "(.{1,$messageWidth})+").Matches.Groups[1].Captures.Value + } else { + $lines = @($line) + } + $lineNumber = 0 + foreach($line in $lines) { + if($lineNumber -eq 0) { + $message = $logPrefix + $line + Write-Host -NoNewline -ForegroundColor $script:LogMessageColors[$_.Level] ($message + (" " * ($consoleWidth - $message.Length))) + } else { + $message = (" " * $logPrefix.Length) + $line + Write-Host -NoNewline -ForegroundColor $script:LogMessageColors[$_.Level] ($message + (" " * ($consoleWidth - $message.Length))) + } + $lineNumber++ + } + } + Write-Host "`n" +} \ No newline at end of file diff --git a/Private/Get-OpenAIKey.ps1 b/Private/Get-LocalOpenAIKey.ps1 similarity index 92% rename from Private/Get-OpenAIKey.ps1 rename to Private/Get-LocalOpenAIKey.ps1 index d44444a..d052314 100644 --- a/Private/Get-OpenAIKey.ps1 +++ b/Private/Get-LocalOpenAIKey.ps1 @@ -1,10 +1,10 @@ -function Get-OpenAIKey { +function Get-LocalOpenAIKey { <# .SYNOPSIS Gets the OpenAIKey module scope variable or environment variable. .EXAMPLE - Get-OpenAIKey + Get-LocalOpenAIKey #> if ($null -ne $Script:OpenAIKey) { if ($PSVersionTable.PSVersion.Major -gt 5) { diff --git a/Private/Invoke-RestMethodWithProgress.ps1 b/Private/Invoke-RestMethodWithProgress.ps1 new file mode 100644 index 0000000..4ec7567 --- /dev/null +++ b/Private/Invoke-RestMethodWithProgress.ps1 @@ -0,0 +1,123 @@ +# Start with a guess at how long this API call will take +$script:DefaultResponseTimeSeconds = 10 +$script:EndpointResponseTimeSeconds = @{} +$script:SupportedHosts = @("ConsoleHost") + +function Reset-APIEstimatedResponseTimes { + $script:DefaultResponseTimeSeconds = 10 + $script:EndpointResponseTimeSeconds = @{} +} + +function Get-APIEstimatedResponseTime { + param ( + [string] $Method, + [string] $Uri + ) + + $endpointResponseTimeKey = $Method + $Uri + $estimatedResponseTime = $script:EndpointResponseTimeSeconds[$endpointResponseTimeKey] + + if($null -eq $estimatedResponseTime -or $estimatedResponseTime -lt $script:DefaultResponseTimeSeconds) { + $estimatedResponseTime = $script:DefaultResponseTimeSeconds + } + + return $estimatedResponseTime +} + +function Set-APIResponseTime { + param ( + [string] $Method, + [string] $Uri, + [int] $ResponseTimeSeconds + ) + + $endpointResponseTimeKey = $Method + $Uri + $script:EndpointResponseTimeSeconds[$endpointResponseTimeKey] = $ResponseTimeSeconds +} + +function Test-HostSupportsRestMethodWithProgress { + # Check if the current host meets all the requirements to be able to send the restmethod to the background + + if($script:SupportedHosts -notcontains (Get-Host).Name) { + return $false + } + + $currentLocation = Get-Location + if($currentLocation.Provider.Name -ne "FileSystem") { + return $false + } + + if($null -ne [System.Net.WebRequest]::DefaultWebProxy.Address -or $null -ne $env:HTTP_PROXY) { + return $false + } + + return $true +} + +function Invoke-RestMethodWithProgress { + param ( + [hashtable] $Params, + $ProgressActivity = "Thinking..." + ) + + # Some hosts can't support background jobs. It's best to opt-in to this feature by using a list of supported hosts + if(-not (Test-HostSupportsRestMethodWithProgress)) { + return Invoke-RestMethod @Params + } + + $estimatedResponseTime = Get-APIEstimatedResponseTime -Method $Params["Method"] -Uri $Params["Uri"] + + try { + try { [Console]::CursorVisible = $false } + catch [System.IO.IOException] { <# unit tests don't have a console #> } + + Push-Location -StackName "RestMethodWithProgress" + $currentLocation = Get-Location + if($currentLocation.Path -ne $currentLocation.ProviderPath) { + Set-Location $currentLocation.ProviderPath + } + + $job = Start-Job { + $restParameters = $using:Params + $response = Invoke-RestMethod @restParameters + return @{ + Response = $response + } + } + + $start = Get-Date + + while($job.State -eq "Running") { + $percent = ((Get-Date) - $start).TotalSeconds / $estimatedResponseTime * 100 + + # Slow the progress towards the end of the progress bar because the api is a bit all over the show for response times, this makes sure the bar doesn't fill up linearly + $logPercent = [int][math]::Min([math]::Max(1, $percent * [math]::Log(1.5)), 100) + $status = "$logPercent% Completed" + if($logPercent -eq 100) { + $status = "API is taking longer than expected" + } + Write-Progress -Id 1 -Activity $ProgressActivity -Status $status -PercentComplete $logPercent + Start-Sleep -Milliseconds 50 + } + Write-Progress -Id 1 -Activity $ProgressActivity -Completed + + # If Invoke-RestMethod failed in the job rethrow this up to the caller so it's like a normal web error + if($job.State -eq "Failed") { + throw $job.ChildJobs[0].JobStateInfo.Reason + } + + Set-APIResponseTime -Method $Params["Method"] -Uri $Params["Uri"] -ResponseTimeSeconds ((Get-Date) - $start).TotalSeconds + + return (Receive-Job $job).Response + } catch { + throw $_ + } finally { + Pop-Location -StackName "RestMethodWithProgress" -ErrorAction "SilentlyContinue" + if($null -ne $job) { + Stop-Job $job -ErrorAction "SilentlyContinue" + Remove-Job $job -Force -ErrorAction "SilentlyContinue" + } + try { [Console]::CursorVisible = $true } + catch [System.IO.IOException] { <# unit tests don't have a console #> } + } +} \ No newline at end of file diff --git a/Private/Write-Codeblock.ps1 b/Private/Write-Codeblock.ps1 new file mode 100644 index 0000000..39a96a2 --- /dev/null +++ b/Private/Write-Codeblock.ps1 @@ -0,0 +1,323 @@ +$script:Themes = @{ + Github = @{ + Function = @{ R = 255; G = 123; B = 114 } + Generic = @{ R = 199; G = 159; B = 252 } + String = @{ R = 143; G = 185; B = 221 } + Variable = @{ R = 255; G = 255; B = 255 } + Identifier = @{ R = 110; G = 174; B = 231 } + Number = @{ R = 255; G = 255; B = 255 } + Keyword = @{ R = 255; G = 123; B = 114 } + Default = @{ R = 200; G = 200; B = 200 } + ForegroundRgb = @{ R = 102; G = 102; B = 102 } + BackgroundRgb = @{ R = 35; G = 35; B = 35 } + HighlightRgb = @{ R = 231; G = 72; B = 86 } + } + Matrix = @{ + Function = @{ R = 255; G = 255; B = 255 } + Generic = @{ R = 113; G = 255; B = 96 } + String = @{ R = 202; G = 255; B = 194 } + Variable = @{ R = 200; G = 255; B = 200 } + Identifier = @{ R = 131; G = 193; B = 26 } + Number = @{ R = 255; G = 255; B = 255 } + Keyword = @{ R = 40; G = 220; B = 20 } + Default = @{ R = 0; G = 120; B = 0 } + ForegroundRgb = @{ R = 102; G = 190; B = 102 } + BackgroundRgb = @{ R = 15; G = 45; B = 15 } + HighlightRgb = @{ R = 255; G = 221; B = 0 } + } +} + +function Write-Codeblock { + <# + .SYNOPSIS + Writes a code block to the host. + Intended for internal use only when you want to show a code block with some nicer formatting. + + .DESCRIPTION + The Write-Codeblock function outputs a code block to the host console with optional line numbers, + syntax highlighting, and line or extent highlighting. The function also supports custom foreground + and background colors. + + .NOTES + Author: Shaun Lawrie + This was originally going to be using a screenbuffer but I wanted to support really long functions that may scroll the + terminal so this streams the lines out from top to bottom which isn't the fastest way to render but it was the most + reliable way I found to avoid mangling the code as it was being written out. + #> + param ( + # The text containing the code to write to the host + [Parameter(ValueFromPipeline=$true, Mandatory=$true)] + [string] $Text, + # Show a gutter with line numbers + [switch] $ShowLineNumbers, + # Syntax highlight the code block + [switch] $SyntaxHighlight, + # Extents to highlight in the code block + [array] $HighlightExtents, + # Lines to highlight in the code block + [array] $HighlightLines, + # The theme to use to render the code + [ValidateSet("Github", "Matrix")] + [string] $Theme = "Github" + ) + + # Fallback if the console host doesn't expose window dimensions, these are required for token highlighting + if(!$Host.UI.RawUI.WindowSize.Width) { + Write-Verbose "Could not write codeblock with syntax highlighting because this console host is not exposing window dimentsions." + Write-Host -ForegroundColor Gray $Text + return + } + + $ForegroundRgb = $script:Themes[$Theme].ForegroundRgb + $BackgroundRgb = $script:Themes[$Theme].BackgroundRgb + + # Work out the width of the console minus the line-number gutter + $gutterSize = 0 + if($ShowLineNumbers) { + $gutterSize = $Text.Split("`n").Count.ToString().Length + 1 + } + $codeWidth = $Host.UI.RawUI.WindowSize.Width - $gutterSize + + try { + [Console]::CursorVisible = $false + + $functionLineNumber = 1 + $resetEscapeCode = "$([Char]27)[0m" + $foregroundColorEscapeCode = "$([Char]27)[38;2;{0};{1};{2}m" -f $ForegroundRgb.R, $ForegroundRgb.G, $ForegroundRgb.B + $backgroundColorEscapeCode = "$([Char]27)[48;2;{0};{1};{2}m" -f $BackgroundRgb.R, $BackgroundRgb.G, $BackgroundRgb.B + + # Get all code tokens + $tokens = @() + [System.Management.Automation.Language.Parser]::ParseInput($Text, [ref]$tokens, [ref]$null) | Out-Null + $lineTokens = Expand-Tokens -Tokens $tokens | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Text) } | Group-Object { $_.Extent.StartLineNumber } + $lineExtents = $HighlightExtents | Group-Object { $_.StartLineNumber } + + $functionLinesToRender = $Text.Split("`n") + foreach($line in $functionLinesToRender) { + $gutterText = "" + if($ShowLineNumbers) { + $gutterText = $functionLineNumber.ToString().PadLeft($gutterSize - 1) + " " + } + + # Disable syntax highlighting for specifically highlighted lines + $lineSyntax = $SyntaxHighlight + $lineHighlight = $false + if($HighlightLines -contains $functionLineNumber) { + $lineSyntax = $false + $lineHighlight = $true + } + + # Work out the lines that will be wrapped in the terminal because they're too long and draw the background + $lineBackground = $foregroundColorEscapeCode + $gutterText + $backgroundColorEscapeCode + (" " * $codeWidth) + $resetEscapeCode + if($line.Length -gt $codeWidth) { + # How many times can this line be wrapped in the code editor width available + $wrappedLineSegments = ($line | Select-String -Pattern ".{1,$codeWidth}" -AllMatches).Matches.Value + # Render the background line plus additional background lines without the gutter line number for each wrapped line + $wrappedLinesBackground = (" " * $gutterSize) + $backgroundColorEscapeCode + (" " * $codeWidth) + $resetEscapeCode + [Console]::WriteLine($lineBackground + ($wrappedLinesBackground * ($wrappedLineSegments.Count - 1))) + # Correct terminal line position if the window scrolled + $terminalLine = $Host.UI.RawUI.CursorPosition.Y - $wrappedLineSegments.Count + } else { + # Render the background + [Console]::WriteLine($lineBackground) + $terminalLine = $Host.UI.RawUI.CursorPosition.Y - 1 + } + + # Render the tokens that are on this line + ($lineTokens | Where-Object { $_.Name -eq $functionLineNumber }).Group | ForEach-Object { + if($null -ne $_) { + Write-Token -Token $_ -TerminalLine $terminalLine -BackgroundRgb $BackgroundRgb -GutterSize $gutterSize -Highlight:$lineHighlight -Theme $Theme -SyntaxHighlight:$lineSyntax + } + } + + # Highlight all extents on this line that have been requested to be emphasized + ($lineExtents | Where-Object { $_.Name -eq $functionLineNumber }).Group | Foreach-Object { + if($null -ne $_) { + Write-Token -Extent $_ -TerminalLine $terminalLine -BackgroundRgb $BackgroundRgb -GutterSize $gutterSize -Highlight -Theme $Theme + } + } + + $functionLineNumber++ + } + } catch { + throw $_ + } finally { + [Console]::CursorVisible = $true + } +} + +function Expand-Tokens { + <# + .SYNOPSIS + Split multiline tokens into "single line tokens" represented as hashtables. + .DESCRIPTION + Tokens can be multiline which makes rendering especially difficult when line wrapping of long tokens gets involved, it's + easier to pre-split these multiline tokens into single line tokens before rendering them. + #> + param ( + [array] $Tokens + ) + $splitTokens = @() + if($null -eq $Tokens -or $Tokens.Count -eq 0) { + return $splitTokens + } + foreach($token in $Tokens) { + $tokenLines = $token.Text.Split("`n") + $lineOffset = 0 + foreach($tokenLine in $tokenLines) { + # If it's the first line this tokens column is not set to 1 it has its own x position + $startColumnNumber = 1 + if($lineOffset -eq 0) { + $startColumnNumber = $token.Extent.StartColumnNumber + } + $splitTokens += @{ + Text = $tokenLine + Extent = @{ + Text = $tokenLine + StartColumnNumber = $startColumnNumber + StartLineNumber = $token.Extent.StartLineNumber + $lineOffset + } + Kind = $token.Kind + TokenFlags = $token.TokenFlags + NestedTokens = @() + } + $lineOffset++ + } + # Append nested tokens so they're expanded later than the parent and drawn overtop e.g. interpolated string variables + $splitTokens += Expand-Tokens -Tokens $token.NestedTokens + } + return $splitTokens +} + +function Get-TokenColor { + <# + .SYNOPSIS + Given a syntax token provide a color based on its type. + #> + param ( + # The kind of token identified by the PowerShell language parser + [System.Management.Automation.Language.TokenKind] $Kind, + # TokenFlags identified by the PowerShell language parser + [System.Management.Automation.Language.TokenFlags] $TokenFlags, + # The theme to use to choose token colors + [string] $Theme + ) + $ForegroundRgb = switch -wildcard ($Kind) { + "Function" { $script:Themes[$Theme].Function } + "Generic" { $script:Themes[$Theme].Generic } + "*String*" { $script:Themes[$Theme].String } + "Variable" { $script:Themes[$Theme].Variable } + "Identifier" { $script:Themes[$Theme].Identifier } + "Number" { $script:Themes[$Theme].Number } + default { $script:Themes[$Theme].Default } + } + if($TokenFlags -like "*operator*" -or $TokenFlags -like "*keyword*") { + $ForegroundRgb = $script:Themes[$Theme].Keyword + } + return $ForegroundRgb +} + +function Write-Token { + <# + .SYNOPSIS + Writes colored text to the console at a specific token location. + #> + param ( + # The token to write, this can be a hashtable/object representing a (System.Management.Automation.Language.Token) or a real one, I'm faking it to deal with multiline tokens + [object] $Token, + # The text to write from an extent (System.Management.Automation.Language.InternalScriptExtent) + [object] $Extent, + # The terminal line to start rendering from + [int] $TerminalLine, + # Render the token with syntax highlighting + [switch] $SyntaxHighlight, + # Highlight this token in a bright overlay color for emphasis + [switch] $Highlight, + # The width of the gutter for this codeblock + [int] $GutterSize, + # The color theme to use + [string] $Theme + ) + + $ForegroundRgb = $script:Themes[$Theme].ForegroundRgb + $BackgroundRgb = $script:Themes[$Theme].BackgroundRgb + + if($Highlight) { + $ForegroundRgb = $script:Themes[$Theme].HighlightRgb + } + + if(!$Extent) { + $Extent = $Token.Extent + } + + $text = $Extent.Text + $column = $Extent.StartColumnNumber + + $colorEscapeCode = "" + if($SyntaxHighlight -and $null -ne $Token) { + $ForegroundRgb = Get-TokenColor -Kind $Token.Kind -TokenFlags $Token.TokenFlags -Theme $Theme + } + $colorEscapeCode += "$([Char]27)[38;2;{0};{1};{2}m" -f $ForegroundRgb.R, $ForegroundRgb.G, $ForegroundRgb.B + if($BackgroundRgb) { + $colorEscapeCode += "$([Char]27)[48;2;{0};{1};{2}m" -f $BackgroundRgb.R, $BackgroundRgb.G, $BackgroundRgb.B + } + + $consoleWidth = $Host.UI.RawUI.WindowSize.Width - $GutterSize + + try { + $initialCursorSetting = [Console]::CursorVisible + } catch { + $initialCursorSetting = $true + } + $initialCursorPosition = $Host.UI.RawUI.CursorPosition + [Console]::CursorVisible = $false + try { + $textToRender = @() + # Overruns are parts of this extent that extend beyond the width of the terminal and need their own line wrapping + $overrunText = @() + # This extent might be on a wrapped part of this line, make sure to find the correct start point + $columnIndex = $Column - 1 + $wrappedLineIndex = [Math]::Floor($columnIndex / $consoleWidth) + $x = ($columnIndex % $consoleWidth) + $GutterSize + $y = $wrappedLineIndex + # Handle extent running beyond the width of the terminal + if(($x + $text.Length) -gt ($consoleWidth + $GutterSize)) { + $fullExtentLine = $text + $endOfTextOnCurrentLine = $consoleWidth - $x + $GutterSize + $text = $text.Substring(0, $endOfTextOnCurrentLine) + $remainingText = $fullExtentLine.Substring($endOfTextOnCurrentLine, $fullExtentLine.Length - $endOfTextOnCurrentLine) + if($remainingText.Length -gt $consoleWidth) { + $overrunText += ($remainingText | Select-String "(.{1,$consoleWidth})+").Matches.Groups[1].Captures.Value + } else { + $overrunText += $remainingText + } + } + + $textToRender += @{ + Text = $text + X = $x + Y = $y + } + + # Prepare any parts of this line that extended beyond the width of the terminal + $overruns = 0 + foreach($overrun in $overrunText) { + $overruns++ + $textToRender += @{ + Text = $overrun + X = $GutterSize + Y = $y + $overruns + } + } + + $textToRender | Foreach-Object { + [Console]::SetCursorPosition($_.X, $TerminalLine + $_.Y) + [Console]::Write($colorEscapeCode + $_.Text + "$([Char]27)[0m") + } + } catch { + throw $_ + } finally { + [Console]::CursorVisible = $initialCursorSetting + [Console]::SetCursorPosition($initialCursorPosition.X, $initialCursorPosition.Y) + } +} \ No newline at end of file diff --git a/Public/ConvertFrom-GPTMarkdownTable.ps1 b/Public/ConvertFrom-GPTMarkdownTable.ps1 index 04689e3..eb82cc9 100644 --- a/Public/ConvertFrom-GPTMarkdownTable.ps1 +++ b/Public/ConvertFrom-GPTMarkdownTable.ps1 @@ -25,12 +25,12 @@ function ConvertFrom-GPTMarkdownTable { $lines = $markdown.Trim() -split "`n" - $( - foreach ($line in $lines) { - if ($line -match '[A-Za-z0-9]') { - $line.Trim() -replace "^\|", "" - } + $data = foreach ($line in $lines) { + if ($line -match '[A-Za-z0-9]') { + $line.Trim() -replace "^\|", "" -replace "\| ", "|" -replace " \|", "|" } - ) | ConvertFrom-Csv -Delimiter '|' + } + + $data | ConvertFrom-Csv -Delimiter '|' } } \ No newline at end of file diff --git a/Public/ConvertTo-JsonL.ps1 b/Public/ConvertTo-JsonL.ps1 new file mode 100644 index 0000000..ef3e02f --- /dev/null +++ b/Public/ConvertTo-JsonL.ps1 @@ -0,0 +1,44 @@ +<# +.SYNOPSIS +Converts a collection of PowerShell objects to a single line JSON string. + +.DESCRIPTION +The ConvertTo-JsonL function takes a collection of PowerShell objects and converts them to a single line JSON string. This is useful for scenarios where you need to pass JSON data as a command line argument or when you need to write JSON data to a file. + +.PARAMETER InputObject +The collection of PowerShell objects to convert to JSON. + +.EXAMPLE +PS C:\> Get-Process | ? Company| select Company, name,Handles -First 5 | ConvertTo-JsonL +{"Company":"Microsoft Corporation","Name":"ai","Handles":191} +{"Company":"Microsoft Corporation","Name":"ApplicationFrameHost","Handles":425} +{"Company":"Dell Technologies","Name":"AWCC","Handles":866} +{"Company":"Dell Technologies","Name":"AWCC.Background.Server","Handles":1086} +{"Company":"A-Volute","Name":"awscns","Handles":948} + +Converts the output of Get-Process to a single line JSON string. +#> + + +# Define the function ConvertTo-JsonL +function ConvertTo-JsonL { + [CmdletBinding()] + param ( + [Parameter(Mandatory, ValueFromPipeline)] + $InputObject # The collection of PowerShell objects to convert to JSON. + ) + + begin { + $sb = [System.Text.StringBuilder]::new() # Create a new StringBuilder object to store the JSON string. + } + + process { + foreach ($obj in $InputObject) { # Loop through each object in the collection. + $null = $sb.AppendLine(($obj | ConvertTo-Json -Compress)) # Convert the object to JSON and append it to the StringBuilder object. + } + } + + end { + $sb.ToString() # Convert the StringBuilder object to a string and return it. + } +} \ No newline at end of file diff --git a/Public/Disable-ChatPersistence.ps1 b/Public/Disable-ChatPersistence.ps1 new file mode 100644 index 0000000..e445262 --- /dev/null +++ b/Public/Disable-ChatPersistence.ps1 @@ -0,0 +1,13 @@ +function Disable-ChatPersistence { + <# + .SYNOPSIS + Disables chat persistence. + + .DESCRIPTION + This function disables chat persistence by setting the $ChatPersistence variable to $false. + + .EXAMPLE + Disable-ChatPersistence + #> + $Script:ChatPersistence = $false +} \ No newline at end of file diff --git a/Public/Enable-ChatPersistence.ps1 b/Public/Enable-ChatPersistence.ps1 new file mode 100644 index 0000000..659537d --- /dev/null +++ b/Public/Enable-ChatPersistence.ps1 @@ -0,0 +1,14 @@ +function Enable-ChatPersistence { + <# + .SYNOPSIS + Enables chat persistence. + + .DESCRIPTION + The Enable-ChatPersistence function sets the $Script:ChatPersistence variable to $true, which enables chat persistence. + + .EXAMPLE + Enable-ChatPersistence + #> + + $Script:ChatPersistence = $true +} \ No newline at end of file diff --git a/Public/Get-AOAIDalleImage.ps1 b/Public/Get-AOAIDalleImage.ps1 new file mode 100644 index 0000000..6222ef0 --- /dev/null +++ b/Public/Get-AOAIDalleImage.ps1 @@ -0,0 +1,108 @@ +function Get-AOAIDalleImage { + <# + .SYNOPSIS + Get a DALL-E image from the Azure OpenAI API + + .DESCRIPTION + Given a description, the model will return an image + + .PARAMETER Description + The description to generate an image for + + .PARAMETER Size + The size of the image to generate. Defaults to 1024 + + .PARAMETER Images + The number of images to generate. Defaults to 1 + + .PARAMETER ApiVersion + API Version to use. Defaults to 2023-06-01-preview + + .PARAMETER Raw + If set, the raw response will be returned. Otherwise, the image will be saved to a temporary file and the path to that file will be returned + + .EXAMPLE + Get-AOAIDalleImage -Description "a painting of the Sydney Opera house in the style of Rembrant on a sunny day" + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Description, + [ValidateSet('256', '512', '1024')] + $Size = 1024, + $Images = 1, + [Switch]$Raw, + [string]$apiVersion = "2023-06-01-preview" + ) + + $targetSize = switch ($Size) { + 256 { '256x256' } + 512 { '512x512' } + 1024 { '1024x1024' } + } + + $body = [ordered]@{ + prompt = $Description + size = $targetSize + n = $Images + } | ConvertTo-Json + + # Header for authentication + $headers = @{ + 'api-key' = $env:AzureOpenAIKey + } + + if ($null -ne (Get-AzureOpenAIOptions).Endpoint) { + # Invoke-OpenAIAPI -Uri "$(Get-AzureOpenAIOptions).Endpoint)/openai/images/generations:submit?api-version=$($apiVersion)" + $baseEndPoint = (Get-AzureOpenAIOptions).Endpoint + $AOAIDalleURL = "$($baseEndPoint)/openai/images/generations:submit?api-version=$($apiVersion)" + + Invoke-RestMethod -Uri $AOAIDalleURL -Headers $headers -Body $body -Method Post -ContentType 'application/json' -ResponseHeadersVariable submissionHeaders + $operation_location = $submissionHeaders['operation-location'][0] + + $status = '' + while ($status -ne 'succeeded') { + Start-Sleep -Seconds 1 + $response = Invoke-RestMethod -Uri $operation_location -Headers $headers + if ($response.status -eq 'failed') { + Write-Error "Image Generation Failed with: $($response.error.code) and message: $($response.error.message)" + exit + } + $status = $response.status + } + + # Retrieve the generated image + $generatedImages = @() + + # Set the directory for the stored image + $image_dir = Join-Path -Path $pwd -ChildPath 'images' + + # If the directory doesn't exist, create it + if (-not(Resolve-Path $image_dir -ErrorAction Ignore)) { + New-Item -Path $image_dir -ItemType Directory + } + + $i = 1 + foreach ($generatedImage in $response.result.data.url) { + $image_url = $generatedImage + # Initialize the image path (note the filetype should be png) + $ts = (get-date -Uformat %T).ToString().Replace(":", "-") + $image_path = Join-Path -Path $image_dir -ChildPath "$($ts)-$($i).png" + + if ($Raw) { + return (Invoke-WebRequest -Uri $image_url).content + } + else { + Invoke-WebRequest -Uri $image_url -OutFile $image_path # download the image + $generatedImages += $image_path + $i = $i + 1 + } + } + if (!$Raw) { + return $generatedImages + } + } + else { + throw 'Please set your Azure OpenAI EndPoint by using the Set-AzureOpenAI cmdlet' + } +} \ No newline at end of file diff --git a/Public/Get-ChatPersistence.ps1 b/Public/Get-ChatPersistence.ps1 new file mode 100644 index 0000000..d2bde7f --- /dev/null +++ b/Public/Get-ChatPersistence.ps1 @@ -0,0 +1,7 @@ +function Get-ChatPersistence { + <# + .SYNOPSIS + Retrieves the chat persistence flag. + #> + $Script:ChatPersistence +} \ No newline at end of file diff --git a/Public/Get-CompletionFromMessages.ps1 b/Public/Get-CompletionFromMessages.ps1 new file mode 100644 index 0000000..c5aeebb --- /dev/null +++ b/Public/Get-CompletionFromMessages.ps1 @@ -0,0 +1,40 @@ +function Get-CompletionFromMessages { + <# + .SYNOPSIS + Gets completion suggestions based on the array of messages. + + .DESCRIPTION + The Get-CompletionFromMessages function returns completion suggestion based on the messages. + + .PARAMETER Messages + Specifies the chat messages to use for generating completion suggestions. + + .EXAMPLE + Get-CompletionFromMessages $( + New-ChatMessageTemplate -Role system 'You are a PowerShell expert' + New-ChatMessageTemplate -Role user 'List even numbers between 1 and 10' + ) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + $Messages + ) + + $payload = (Get-ChatSessionOptions).Clone() + + $payload.messages = $messages + $payload = $payload | ConvertTo-Json -Depth 10 + + $body = [System.Text.Encoding]::UTF8.GetBytes($payload) + + if ((Get-ChatAPIProvider) -eq 'OpenAI') { + $uri = Get-OpenAIChatCompletionUri + } + elseif ((Get-ChatAPIProvider) -eq 'AzureOpenAI') { + $uri = Get-ChatAzureOpenAIURI + } + + $result = Invoke-OpenAIAPI -Uri $uri -Method 'Post' -Body $body + $result.choices.message +} \ No newline at end of file diff --git a/Public/Get-DalleImage.ps1 b/Public/Get-DalleImage.ps1 index 810981c..84410bf 100644 --- a/Public/Get-DalleImage.ps1 +++ b/Public/Get-DalleImage.ps1 @@ -15,6 +15,9 @@ function Get-DalleImage { .PARAMETER Raw If set, the raw response will be returned. Otherwise, the image will be saved to a temporary file and the path to that file will be returned + .PARAMETER NoProgress + The option to hide write-progress if you want, you could also set $ProgressPreference to SilentlyContinue + .EXAMPLE Get-DalleImage -Description "A cat sitting on a table" #> @@ -24,7 +27,8 @@ function Get-DalleImage { $Description, [ValidateSet('256', '512', '1024')] $Size = 256, - [Switch]$Raw + [Switch]$Raw, + [Switch]$NoProgress ) $targetSize = switch ($Size) { @@ -45,7 +49,11 @@ function Get-DalleImage { } else { $DestinationPath = [IO.Path]::GetTempFileName() -replace ".tmp", ".png" - Invoke-RestMethod $result.data.url -OutFile $DestinationPath + $params = @{ + Uri = $result.data.url + OutFile = $DestinationPath + } + Invoke-RestMethodWithProgress -Params $params -NoProgress:$NoProgress $DestinationPath } } \ No newline at end of file diff --git a/Public/Get-GPT3Completion.ps1 b/Public/Get-GPT3Completion.ps1 index 58bb35c..388f4a5 100644 --- a/Public/Get-GPT3Completion.ps1 +++ b/Public/Get-GPT3Completion.ps1 @@ -10,7 +10,7 @@ function Get-GPT3Completion { The prompt to generate completions for .PARAMETER model - ID of the model to use. Defaults to 'text-davinci-003' + ID of the model to use. Defaults to 'gpt-3.5-turbo-instruct' .PARAMETER temperature The temperature used to control the model's likelihood to take risky actions. Higher values means the model will take more risks. Try 0.9 for more creative applications, and 0 (argmax sampling) for ones with a well-defined answer. Defaults to 0 @@ -38,7 +38,7 @@ function Get-GPT3Completion { param( [Parameter(Mandatory)] $prompt, - $model = 'text-davinci-003', + $model = 'gpt-3.5-turbo-instruct', [ValidateRange(0, 2)] [decimal]$temperature = 0.0, [ValidateRange(1, 2048)] @@ -53,10 +53,6 @@ function Get-GPT3Completion { [Switch]$Raw ) - # if (!(Test-OpenAIKey)) { - # throw 'You must set the $env:OpenAIKey environment variable to your OpenAI API key. https://beta.openai.com/account/api-keys' - # } - $body = [ordered]@{ model = $model prompt = $prompt diff --git a/Public/Get-GPT4Completion.ps1 b/Public/Get-GPT4Completion.ps1 new file mode 100644 index 0000000..1697bec --- /dev/null +++ b/Public/Get-GPT4Completion.ps1 @@ -0,0 +1,504 @@ +function Get-AzureOpenAIOptions { + [CmdletBinding()] + param() + + $Script:AzureOpenAIOptions +} + +function Set-AzureOpenAIOptions { + [CmdletBinding()] + param( + $Endpoint, + $DeploymentName, + $ApiVersion + ) + + $options = @{} + $PSBoundParameters + + foreach ($key in $options.Keys) { + $Script:AzureOpenAIOptions[$key] = $options[$key] + } +} + + +function Reset-AzureOpenAIOptions { + [CmdletBinding()] + param() + + $Script:AzureOpenAIOptions = @{ + Endpoint = 'not set' + DeploymentName = 'not set' + ApiVersion = 'not set' + } +} + +function Get-ChatAzureOpenAIURI { + <# + .SYNOPSIS + Get the URI for the Azure OpenAI API. + .EXAMPLE + Get-ChatAzureOpenAIURI + #> + [CmdletBinding()] + param() + + $options = Get-AzureOpenAIOptions + + if ($options.Endpoint -eq 'not set') { + throw 'Azure Open AI Endpoint not set' + } + elseif ($options.DeploymentName -eq 'not set') { + throw 'Azure Open AI DeploymentName not set' + } + elseif ($options.ApiVersion -eq 'not set') { + throw 'Azure Open AI ApiVersion not set' + } + + $uri = "$($options.Endpoint)/openai/deployments/$($options.DeploymentName)/chat/completions?api-version=$($options.ApiVersion)" + + $uri +} + +function Get-ChatAPIProvider { + <# + .SYNOPSIS + Get the current chat API provider. + .EXAMPLE + Get-ChatAPIProvider + #> + [CmdletBinding()] + param() + + $Script:ChatAPIProvider +} + +function Set-ChatAPIProvider { + <# + .SYNOPSIS + Set the chat API provider. + .PARAMETER Provider + The chat API provider to use. + Valid values are 'AzureOpenAI' and 'OpenAI'. + Default value is 'OpenAI'. + .EXAMPLE + Set-ChatAPIProvider -Provider 'AzureOpenAI' + #> + [CmdletBinding()] + param( + [ValidateSet('AzureOpenAI', 'OpenAI')] + $Provider = 'OpenAI' + ) + + $Script:ChatAPIProvider = $Provider +} + +function Get-ChatSessionOptions { + <# + .SYNOPSIS + Get the current chat session options. + .EXAMPLE + Get-ChatSessionOptions + #> + [CmdletBinding()] + param() + + $Script:ChatSessionOptions +} + +function Set-ChatSessionOption { + <# + .SYNOPSIS + Set a chat session option. + + .PARAMETER model + The model to use for the chat session. + Valid values are 'gpt-4' and 'gpt-3.5-turbo'. + Default value is 'gpt-4'. + .PARAMETER max_tokens + The maximum number of tokens to generate. + Default value is 256. + .PARAMETER temperature + The temperature of the model. + Default value is 0. + .PARAMETER top_p + The top_p of the model. + Default value is 1. + .PARAMETER frequency_penalty + The frequency penalty of the model. + Default value is 0. + .PARAMETER presence_penalty + The presence penalty of the model. + Default value is 0. + .PARAMETER stop + The stop sequence of the model. + Default value is $null. + .EXAMPLE + Set-ChatSessionOption -model 'gpt-4' + .EXAMPLE + Set-ChatSessionOption -max_tokens 512 + + #> + [CmdletBinding()] + param( + [ValidateSet('gpt-4','gpt-3.5-turbo-1106', 'gpt-4-1106-preview', 'gpt-4-0613', 'gpt-3.5-turbo', 'gpt-3.5-turbo-16k', 'gpt-3.5-turbo-0613')] + $model, + $max_tokens = 256, + $temperature = 0, + $top_p = 1, + $frequency_penalty = 0, + $presence_penalty = 0, + $stop + ) + + $options = @{} + $PSBoundParameters + + foreach ($entry in $options.GetEnumerator()) { + $Script:ChatSessionOptions["$($entry.Name)"] = $entry.Value + } +} + +function Reset-ChatSessionOptions { + <# + .SYNOPSIS + Reset the chat session options to their default values. + .EXAMPLE + Reset-ChatSessionOptions + #> + [CmdletBinding()] + param() + + $Script:ChatSessionOptions = @{ + 'model' = 'gpt-4' + 'temperature' = 0.0 + 'max_tokens' = 256 + 'top_p' = 1.0 + 'frequency_penalty' = 0 + 'presence_penalty' = 0 + 'stop' = $null + } + + Enable-ChatPersistence +} + +function Clear-ChatMessages { + <# + .SYNOPSIS + Clear the chat messages in the current chat session. + .EXAMPLE + Clear-ChatMessages + #> + [CmdletBinding()] + param() + + $Script:ChatMessages.Clear() +} + +function Add-ChatMessage { + <# + .SYNOPSIS + Add a chat message to the current chat session. + .PARAMETER Message + The chat message to add. + .EXAMPLE + Add-ChatMessage -Message <#PSCustomObject#> + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + $Message + ) + + $null = $Script:ChatMessages.Add($Message) +} + +function New-ChatMessageTemplate { + <# + .SYNOPSIS + Create a new chat message template. + .PARAMETER Role + The role of the chat message. + Valid values are 'user', 'system', and 'assistant'. + .PARAMETER Content + The content of the chat message. + .PARAMETER Name + The name of the author of this message. name is required if role is function, and it should be the name of the function whose response is in the content + .EXAMPLE + New-ChatMessageTemplate -Role 'user' -Content <#string#> + #> + [CmdletBinding()] + param( + [ValidateSet('user', 'system', 'assistant', 'function')] + $Role, + $Content, + $Name + ) + + $returnObject = [ordered]@{ + role = $Role + content = $Content + } + + if ($Role -eq 'function' -and $null -eq $Name) { + throw 'Name is required if role is function' + } + + if ($Name) { + $returnObject.name = $Name + } + + [PSCustomObject]$returnObject +} + +function New-ChatMessage { + <# + .SYNOPSIS + Create a new chat message. + .DESCRIPTION + Create a new chat message and add it to the current chat session. + .PARAMETER Role + The role of the chat message. + Valid values are 'user', 'system', and 'assistant'. + .PARAMETER Content + The content of the chat message. + .EXAMPLE + New-ChatMessage -Role 'user' -Content <#string + #> + param( + [Parameter(Mandatory)] + [ValidateSet('user', 'system', 'assistant')] + $Role, + [Parameter(Mandatory)] + $Content + ) + + $Script:ChatInProgress = $Script:true + + $message = New-ChatMessageTemplate -Role $Role -Content $Content + + Add-ChatMessage -Message $message + + #Export-ChatSession +} + +function New-ChatSystemMessage { + <# + .SYNOPSIS + Create a new chat system message. + .DESCRIPTION + Create a new chat system message and add it to the current chat session. + .PARAMETER Content + The content of the chat message. + .EXAMPLE + New-ChatSystemMessage -Content <#string#> + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + $Content + ) + + New-ChatMessage -Role 'system' -Content $Content +} + +function New-ChatUserMessage { + <# + .SYNOPSIS + Create a new chat user message. + .DESCRIPTION + Create a new chat user message and add it to the current chat session. + .PARAMETER Content + The content of the chat message. + .EXAMPLE + New-ChatUserMessage -Content <#string#> + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + $Content + ) + + New-ChatMessage -Role 'user' -Content $Content +} + +function New-ChatAssistantMessage { + <# + .SYNOPSIS + Create a new chat assistant message. + .DESCRIPTION + Create a new chat assistant message and add it to the current chat session. + .PARAMETER Content + The content of the chat message. + .EXAMPLE + New-ChatAssistantMessage -Content <#string + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + $Content + ) + + New-ChatMessage -Role 'assistant' -Content $Content +} + +function Get-ChatMessages { + <# + .SYNOPSIS + Get the chat messages in the current chat session. + .EXAMPLE + Get-ChatMessages + #> + [CmdletBinding()] + param() + + @($Script:ChatMessages) +} + +function Get-ChatPayload { + <# + .SYNOPSIS + Get the chat payload. + .DESCRIPTION + Get the chat payload as a PSCustomObject. + .PARAMETER AsJson + Return the chat payload as a JSON string. + .EXAMPLE + Get-ChatPayload + #> + [CmdletBinding()] + param( + [Switch]$AsJson + ) + + $payload = (Get-ChatSessionOptions).Clone() + + $payload.messages = @(Get-ChatMessages) + + if ($AsJson) { + return $payload | ConvertTo-Json -Depth 10 + } + else { + return $payload + } + +} + +function New-Chat { + <# + .SYNOPSIS + Start a new chat session. + .DESCRIPTION + Start a new chat session and optionally send a message to the assistant. + .PARAMETER Content + The content of the chat message. + .EXAMPLE + New-Chat + .EXAMPLE + New-Chat -Content <#string#> + #> + [CmdletBinding()] + param( + $Content + ) + + Stop-Chat + $Script:ChatInProgress = $true + + if (![string]::IsNullOrEmpty($Content)) { + New-ChatSystemMessage -Content $Content + Get-GPT4Response + } + +} + +function Test-ChatInProgress { + <# + .SYNOPSIS + Test if a chat session is in progress. + .EXAMPLE + Test-ChatInProgress + #> + [CmdletBinding()] + param() + $Script:ChatInProgress +} + +function Stop-Chat { + <# + .SYNOPSIS + Stop the current chat session. + .EXAMPLE + Stop-Chat + #> + [CmdletBinding()] + param() + + $Script:ChatInProgress = $false + + Clear-ChatMessages + Reset-ChatSessionTimeStamp +} + +function Get-GPT4Completion { + <# + .SYNOPSIS + Get a GPT-4 completion. + .DESCRIPTION + Get a GPT-4 completion from the OpenAI API. + .EXAMPLE + chat "use powershell: what is my IP address?" + .EXAMPLE + Get-GPT4Completion -Prompt <#string#> + #> + [CmdletBinding()] + [alias("chat")] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + $Content, + [Parameter(Mandatory = $false, ValueFromPipeline = $true)] + [decimal]$temperature, + # The maximum number of tokens to generate. default 256 + [Parameter(Mandatory = $false, ValueFromPipeline = $true)] + [ValidateRange(1, 4000)] + $max_tokens = 256 + ) + + New-ChatUserMessage -Content $Content + + Get-GPT4Response -Temperature $temperature -max_tokens $max_tokens +} + +function Get-GPT4Response { + [CmdletBinding()] + param( + [decimal]$Temperature, + $max_tokens + ) + + $payload = Get-ChatPayload -AsJson + $body = [System.Text.Encoding]::UTF8.GetBytes($payload) + + if ((Get-ChatAPIProvider) -eq 'OpenAI') { + $uri = Get-OpenAIChatCompletionUri + } + elseif ((Get-ChatAPIProvider) -eq 'AzureOpenAI') { + $uri = Get-ChatAzureOpenAIURI + } + + if ($Temperature) { + (Get-ChatSessionOptions)['temperature'] = $Temperature + } + + if ($max_tokens) { + (Get-ChatSessionOptions)['max_tokens'] = $max_tokens + } + + $result = Invoke-OpenAIAPI -Uri $uri -Method 'Post' -Body $body + + if ($result.choices) { + $response = $result.choices[0].message.content + New-ChatAssistantMessage -Content $response + + Export-ChatSession + return $response + } +} \ No newline at end of file diff --git a/Public/Get-OpenAIBaseRestURI.ps1 b/Public/Get-OpenAIBaseRestURI.ps1 index b42a6c9..0d9edeb 100644 --- a/Public/Get-OpenAIBaseRestURI.ps1 +++ b/Public/Get-OpenAIBaseRestURI.ps1 @@ -7,5 +7,5 @@ function Get-OpenAIBaseRestURI { Invoke-OpenAIAPI ((Get-GHBaseRestURI)+'/models') #> - 'https://api.openai.com/v1' + 'https://api.aimlapi.com' } \ No newline at end of file diff --git a/Public/Get-OpenAIChatCompletionUri.ps1 b/Public/Get-OpenAIChatCompletionUri.ps1 new file mode 100644 index 0000000..ab21df5 --- /dev/null +++ b/Public/Get-OpenAIChatCompletionUri.ps1 @@ -0,0 +1,9 @@ + +function Get-OpenAIChatCompletionUri { + <# + .Synopsis + Url for OpenAI Chat Completions API + #> + + (Get-OpenAIBaseRestURI) + '/chat/completions' +} \ No newline at end of file diff --git a/Public/Get-OpenAIEmbeddings.ps1 b/Public/Get-OpenAIEmbeddings.ps1 new file mode 100644 index 0000000..f33d637 --- /dev/null +++ b/Public/Get-OpenAIEmbeddings.ps1 @@ -0,0 +1,43 @@ +function Get-OpenAIEmbeddings { + <# + .SYNOPSIS + Get OpenAI Embeddings + + .DESCRIPTION + Get OpenAI Embeddings + + .PARAMETER Content + The text to embed + + .PARAMETER Raw + Return the raw response + + .EXAMPLE + Get-OpenAIEmbeddings -Content "Hello world" + + .LINK + https://platform.openai.com/docs/api-reference/embeddings + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string]$Content, + [Switch]$Raw + ) + + $body = @{ + "input" = $Content + "model" = "text-embedding-ada-002" + } | ConvertTo-Json + + $response = Invoke-OpenAIAPI -Uri (Get-OpenAIEmbeddingsUri) -Method Post -Body $body + + if ($Raw) { + $response + } + else { + # $response.choices | Select-Object text + # return everything till we figure out what info is needed + $response.data + } +} \ No newline at end of file diff --git a/Public/Get-OpenAIEmbeddingsUri.ps1 b/Public/Get-OpenAIEmbeddingsUri.ps1 new file mode 100644 index 0000000..7809cc3 --- /dev/null +++ b/Public/Get-OpenAIEmbeddingsUri.ps1 @@ -0,0 +1,6 @@ +function Get-OpenAIEmbeddingsUri { + [CmdletBinding()] + param () + + (Get-OpenAIBaseRestURI) + '/embeddings' +} \ No newline at end of file diff --git a/Public/Get-OpenAIKey.ps1 b/Public/Get-OpenAIKey.ps1 new file mode 100644 index 0000000..ca63171 --- /dev/null +++ b/Public/Get-OpenAIKey.ps1 @@ -0,0 +1,21 @@ +function Get-OpenAIKey { + <# + .SYNOPSIS + Get a list of OpenAI Keys + + .DESCRIPTION + Returns a list of OpenAI Keys + + .EXAMPLE + Get-OpenAIKey + + .NOTES + This function requires the 'OpenAIKey' environment variable to be defined before being invoked + Reference: https://platform.openai.com/docs/models/overview + Reference: https://platform.openai.com/docs/api-reference/models + #> + + $uri = 'https://api.openai.com/dashboard/user/api_keys' + + Invoke-OpenAIAPI -Uri $uri +} \ No newline at end of file diff --git a/Public/Get-OpenAIUsage.ps1 b/Public/Get-OpenAIUsage.ps1 new file mode 100644 index 0000000..30b773f --- /dev/null +++ b/Public/Get-OpenAIUsage.ps1 @@ -0,0 +1,44 @@ +function Get-OpenAIUsage { + <# + .SYNOPSIS + Get a summary of OpenAI API usage + + .DESCRIPTION + Returns a summary of OpenAI API usage for your organization. All dates and times are UTC-based, and data may be delayed up to 5 minutes. + + .PARAMETER StartDate + The Start Date of the usage period to return in YYYY-MM-DD format + + .PARAMETER EndDate + The End Date of the usage period to return in YYYY-MM-DD format + + .EXAMPLE + Get-OpenAIUsage -StartDate '2023-03-01' -EndDate '2023-03-31' + + .NOTES + This function requires the 'OpenAIKey' environment variable to be defined before being invoked + Reference: https://platform.openai.com/docs/models/overview + Reference: https://platform.openai.com/docs/api-reference/models + #> + + [CmdletBinding()] + param( + [datetime]$StartDate = (Get-Date).AddDays(-1), + [datetime]$EndDate = (Get-Date), + [Switch]$OnlyLineItems + ) + + $url = 'https://api.openai.com/dashboard/billing/usage?end_date={0}&start_date={1}' -f $($endDate.toString("yyyy-MM-dd")), $($startDate.ToString("yyyy-MM-dd")) + + $result = Invoke-OpenAIAPI $url | + Add-Member -PassThru -MemberType NoteProperty -Name StartDate -Value $StartDate.ToShortDateString() -Force | + Add-Member -PassThru -MemberType NoteProperty -Name EndDate -Value $EndDate.ToShortDateString() -Force + + #(get-openaiusage 3/1).daily_costs.line_items | sort name + if ($OnlyLineItems) { + $result.daily_costs.line_items | Sort-Object name + } + else { + $result + } +} \ No newline at end of file diff --git a/Public/Get-OpenAIUser.ps1 b/Public/Get-OpenAIUser.ps1 new file mode 100644 index 0000000..f77c994 --- /dev/null +++ b/Public/Get-OpenAIUser.ps1 @@ -0,0 +1,30 @@ +function Get-OpenAIUser { + <# + .SYNOPSIS + Get OpenAI User Information + + .DESCRIPTION + Returns an overview of the user's OpenAI organization information + + .PARAMETER OrganizationId + The Identifier for this organization sometimes used in API requests + + .EXAMPLE + Get-OpenAIUser -OrganizationId 'org-IkLeiQaK1fZi6271T9u18jO5' + + .NOTES + This function requires the 'OpenAIKey' environment variable to be defined before being invoked + Reference: https://platform.openai.com/docs/models/overview + Reference: https://platform.openai.com/docs/api-reference/models + #> + + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$OrganizationId + ) + + $url = '{0}/organizations/{1}/users' -f (Get-OpenAIBaseRestURI), $organizationId + + Invoke-OpenAIAPI $url +} \ No newline at end of file diff --git a/Public/Invoke-AIErrorHelper.ps1 b/Public/Invoke-AIErrorHelper.ps1 new file mode 100644 index 0000000..24c202d --- /dev/null +++ b/Public/Invoke-AIErrorHelper.ps1 @@ -0,0 +1,31 @@ +function Invoke-AIErrorHelper { + <# + .SYNOPSIS + Inspect the last error record and offer some suggestions on how to resolve it + .DESCRIPTION + Invoke-AIErrorHelper is a function that uses the OpenAI GPT-3 API to provide insights into errors that occur in a powershell script. + .EXAMPLE + Invoke-AIErrorHelper + #> + [CmdletBinding()] + [alias("ieh")] + param() + + $lastError = $global:Error[0] + + if ($null -ne $lastError) { + $message = $lastError.Exception.Message + $errorType = $lastError.FullyQualifiedErrorId + + $promptPrefix = "Provide a detailed summary of the following powershell error and offer a potential powershell solution (using code if it's a confident solution):" + + $errorDetails = "${errorType}`n$message" + + $response = (Get-GPT3Completion -prompt "$promptPrefix`n`n$errorDetails" -max_tokens 2048).Trim() + Write-Host -ForegroundColor Cyan "$errorDetails`n" + Write-Host -ForegroundColor DarkGray $response + } + else { + Write-Host "No error has occurred" + } +} \ No newline at end of file diff --git a/Public/Invoke-AIExplain.ps1 b/Public/Invoke-AIExplain.ps1 new file mode 100644 index 0000000..c77bfec --- /dev/null +++ b/Public/Invoke-AIExplain.ps1 @@ -0,0 +1,58 @@ +function Invoke-AIExplain { + <# + .SYNOPSIS + Explain the last command or a command by id + .DESCRIPTION + Invoke-AIExplain is a function that uses the OpenAI GPT-3 API to provide explain the last command or a command by id. + .EXAMPLE + explain + .EXAMPLE + explain 10 # where 10 is the id of the command in the history + .EXAMPLE + explain 10 13 # the start and end id of the commands in the history + .EXAMPLE + explain -Value "Get-Process" + #> + [CmdletBinding()] + [alias("explain")] + param( + $Id, + $IdEnd, + $Value, + $max_tokens = 300 + ) + + if ($Id -and $IdEnd) { + foreach ($targetId in ($Id..$IdEnd)) { + $cli += (Get-History -Id $targetId).CommandLine + "`r`n" + } + } + elseif ($Value) { + $cli = $Value + } + elseif ($Id) { + $cli = (Get-History -Id $Id).CommandLine + } + else { + $cli = (Get-History | Select-Object -last 1).CommandLine + } + + write-host $cli + $prompt = 'You are running powershell on ' + $PSVersionTable.Platform + $prompt += " Please explain the following: " + + # Dynamically determine which OpenAI service is being used + $provider = $null + $provider = Get-ChatAPIProvider + switch ($provider.tolower()) { + openai { $result = $cli | ai $prompt -max_tokens $max_tokens } + azureopenai { + $prompt += $cli + $result = Get-GPT4Completion -Content $prompt -max_tokens $max_tokens + } + Default { $result = $cli | ai $prompt -max_tokens $max_tokens } + } + + Write-Codeblock -Text $cli -SyntaxHighlight + $result +} \ No newline at end of file diff --git a/Public/Invoke-AIFunctionBuilder.ps1 b/Public/Invoke-AIFunctionBuilder.ps1 new file mode 100644 index 0000000..ebef62c --- /dev/null +++ b/Public/Invoke-AIFunctionBuilder.ps1 @@ -0,0 +1,172 @@ +function Invoke-AIFunctionBuilder { + <# + .SYNOPSIS + Create a PowerShell function with the help of ChatGPT + .DESCRIPTION + Invoke-AIFunctionBuilder is a function that uses ChatGPT to generate an initial PowerShell function to achieve the goal defined + in the prompt by the user but goes a few steps beyond the typical interaction with an LLM by auto-validating the result + of the AI generated script using parsing techniques that feed common issues back to the model until it resolves them. + .EXAMPLE + PS>Invoke-AIFunctionBuilder + # The function builder renders the UI and asks the user to enter a prompt to generate a function + .EXAMPLE + PS>Invoke-AIFunctionBuilder -Prompt "Write a powershell function that will show a date and time in timestamp form" -NonInteractive + function Get-Timestamp { + return (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss.fffffZ") + } + .EXAMPLE + PS>$function = 'function Write-Hello { Write-Output "hello world" }' + PS>Invoke-AIFunctionBuilder -InitialFunction $function -Prompt "write a powershell function that says hello" + # The function builder renders the UI and validates the function provided meets the goal of the prompt + .NOTES + Author: Shaun Lawrie / @shaun_lawrie + #> + [CmdletBinding(DefaultParameterSetName = 'Interactive')] + [alias("ifb")] + param( + # A prompt in the format "Write a powershell function that will sing me happy birthday" + [Parameter(ParameterSetName="Interactive", ValueFromPipeline = $true)] + [Parameter(ParameterSetName="NonInteractive", ValueFromPipeline = $true, Mandatory=$true)] + [string] $Prompt, + # The maximum loop iterations to attempt to generate the function within + [Parameter(ParameterSetName="Interactive")] + [Parameter(ParameterSetName="NonInteractive")] + [int] $MaximumReinforcementIterations = 15, + # Return the code result without showing any interactions + [Parameter(ParameterSetName="NonInteractive")] + [switch] $NonInteractive, + # The model to use + [Parameter(ParameterSetName="Interactive")] + [Parameter(ParameterSetName="NonInteractive")] + [ValidateSet("gpt-3.5-turbo", "gpt-4")] + [string] $Model = "gpt-3.5-turbo", + # A seed function to use as the function builder starting point, this can allow you to iterate on an existing idea + [Parameter(ParameterSetName="Interactive")] + [Parameter(ParameterSetName="NonInteractive")] + [string] $InitialFunction + ) + + $fullPrompt = $Prompt + + if(-not $NonInteractive) { + Clear-Host + $prePrompt = $null + if([string]::IsNullOrEmpty($Prompt)) { + $version = if($PSVersionTable.PSVersion.Major -gt 5) { "core" } else { $PSVersionTable.PSVersion.Major } + $prePrompt = "Write a PowerShell $version function that will" + Write-Host -ForegroundColor Cyan -NoNewline "${prePrompt}: " + $Prompt = Read-Host + if([string]::IsNullOrWhiteSpace($Prompt)) { + Write-Host "No prompt was provided, I guess you're feeling lucky..." + $Prompt = "do something" + } + } + $fullPrompt = (@($prePrompt, $Prompt) | Where-Object { $null -ne $_ }) -join ' ' + } + + try { + $function = Initialize-AifbFunction -Prompt $fullPrompt -Model $Model -InitialFunction $InitialFunction + + Initialize-AifbRenderer -InitialPrePrompt $prePrompt -InitialPrompt $Prompt -NonInteractive $NonInteractive + Write-AifbFunctionOutput -FunctionText $function.Body -Prompt $fullPrompt + + $function = Optimize-AifbFunction -Function $function -Prompt $fullPrompt -Force:(![string]::IsNullOrWhiteSpace($InitialFunction)) + + if($NonInteractive) { + return $function.Body + } + + Write-AifbFunctionOutput -FunctionText $function.Body -SyntaxHighlight -NoLogMessages -Prompt $fullPrompt + + $finished = $false + while(-not $finished) { + $action = Get-AifbUserAction -Function $function + + switch($action) { + "Edit" { + $editPrePrompt = "`nI also want the function to" + Write-Host -ForegroundColor Cyan -NoNewline "${editPrePrompt}: " + $editPrompt = Read-Host + Write-Verbose "Re-running function optimizer with a request to edit functionality: '$editPrompt'" + $fullPrompt = (@($fullPrompt, $editPrompt) | Where-Object { ![string]::IsNullOrWhiteSpace($_) }) -join ' and the function must ' + Write-AifbFunctionOutput -FunctionText $function.Body -Prompt $fullPrompt + $function = Optimize-AifbFunction -Function $function -Prompt $fullPrompt -RuntimeError "The function does not meet all conditions in the prompt ($fullPrompt)." + Write-AifbFunctionOutput -FunctionText $function.Body -SyntaxHighlight -NoLogMessages -Prompt $fullPrompt + } + "Copy" { + Set-Clipboard -Value $function.Body + Write-Host "The function code has been copied to your clipboard!" + if($IsLinux) { + Write-Warning "This might not work under WSL, you can try the 'Save' option to save the function to your local filesystem instead." + } + Write-Host "" + } + "Explain" { + $explanation = (Get-GPT3Completion -prompt "Explain how the function below meets all of the requirements the following requirements, list the requirements and how each is met in a numbered list. Also provide a summary of what the function can do.`nRequirements: $fullPrompt`n`n``````powershell`n$($function.Body)``````" -max_tokens 2000).Trim() + Write-AifbFunctionOutput -FunctionText $function.Body -SyntaxHighlight -NoLogMessages -Prompt $fullPrompt + Write-Host $explanation + Write-Host "" + } + "Run" { + $tempFile = New-TemporaryFile + $tempFilePsm1 = "$($tempFile.FullName).psm1" + Set-Content -Path $tempFile -Value $function.Body + Move-Item -Path $tempFile.FullName -Destination $tempFilePsm1 + Write-Host "" + Import-Module $tempFilePsm1 -Global + $commands = (Get-Module | Where-Object { $_.Path -eq $tempFilePsm1 }).ExportedCommands.Keys + $command = Get-Command $commands[0] + if($commands.Count -gt 1) { + while($null -eq $command) { + $commandName = (Read-Host "There are multiple functions in this module ($($commands -join ', ')), enter the name of the one you want to use as the entry point").Trim() + $command = Get-Command $commandName -ErrorAction "SilentlyContinue" + if(!$command) { + Write-Warning "Command name '$commandName' failed to import a command." + } + } + } + $params = @{} + if($command.ParameterSets) { + $command.ParameterSets.GetEnumerator()[0].Parameters | Where-Object { $_.Position -ge 0 } | Foreach-Object { + $params[$_.Name] = Read-Host "$($_.Name) ($($_.ParameterType))" + } + } + $previousErrorActionPreference = $ErrorActionPreference + try { + & $function.Name @params -ErrorAction "Stop" | Out-Host + Get-Module | Where-Object { $_.Path -eq $tempFilePsm1 } | Remove-Module -Force + $answer = Read-Host -Prompt "Are there any issues that need correcting? (y/n)" + if($answer -eq "y") { + $issueDescription = Read-Host -Prompt "Describe the issues" + Write-AifbFunctionOutput -FunctionText $function.Body -Prompt $fullPrompt + $function = Optimize-AifbFunction -Function $function -Prompt $fullPrompt -RuntimeError $issueDescription + Write-AifbFunctionOutput -FunctionText $function.Body -SyntaxHighlight -NoLogMessages -Prompt $fullPrompt + } + } catch { + Get-Module | Where-Object { $_.Path -eq $tempFilePsm1 } | Remove-Module -Force + Write-Error $_ + $answer = Read-Host -Prompt "An error occurred, do you want to try auto-fix the function? (y/n)" + if($answer -eq "y") { + Write-AifbFunctionOutput -FunctionText $function.Body -Prompt $fullPrompt + $function = Optimize-AifbFunction -Function $function -Prompt $fullPrompt -RuntimeError $_.Exception.Message + Write-AifbFunctionOutput -FunctionText $function.Body -SyntaxHighlight -NoLogMessages -Prompt $fullPrompt + } + } + Write-Host "" + $ErrorActionPreference = $previousErrorActionPreference + } + "Save" { + $moduleLocation = Save-AifbFunctionOutput -FunctionText $function.Body -FunctionName $function.Name -Prompt $fullPrompt + Import-Module $moduleLocation -Global + Write-Host "The function is available as '$($function.Name)' in your current terminal session. To import this function in the future use 'Import-Module $moduleLocation' or add the directory with all your PowerShellAI modules to your `$env:PSModulePath to have them auto import for every session." + $finished = $true + } + "Quit" { + $finished = $true + } + } + } + } finally { + Stop-Chat + } +} \ No newline at end of file diff --git a/Public/Invoke-OpenAIAPI.ps1 b/Public/Invoke-OpenAIAPI.ps1 index a2324dc..a02218c 100644 --- a/Public/Invoke-OpenAIAPI.ps1 +++ b/Public/Invoke-OpenAIAPI.ps1 @@ -15,6 +15,9 @@ function Invoke-OpenAIAPI { .PARAMETER Body The body to send with the request + .PARAMETER NoProgress + The option to hide write-progress if you want, you could also set $ProgressPreference to SilentlyContinue + .EXAMPLE Invoke-OpenAIAPI -Uri "https://api.openai.com/v1/images/generations" -Method Post -Body $body #> @@ -24,13 +27,10 @@ function Invoke-OpenAIAPI { $Uri, [ValidateSet('Default', 'Delete', 'Get', 'Head', 'Merge', 'Options', 'Patch', 'Post', 'Put', 'Trace')] $Method = 'Get', - $Body + $Body, + [switch] $NoProgress ) - if (!(Test-OpenAIKey)) { - throw 'Please set your OpenAI API key using Set-OpenAIKey or by configuring the $env:OpenAIKey environment variable (https://beta.openai.com/account/api-keys)' - } - $params = @{ Uri = $Uri Method = $Method @@ -38,14 +38,41 @@ function Invoke-OpenAIAPI { body = $Body } - if (($apiKey = Get-OpenAIKey) -is [SecureString]) { - #On PowerShell 6 and higher use Invoke-RestMethod with Authentication parameter and secure Token - $params['Authentication'] = 'Bearer' - $params['Token'] = $apiKey - } else { - #On PowerShell 5 and lower, or when using the $env:OpenAIKey environment variable, use Invoke-RestMethod with plain text header - $params['Headers'] = @{Authorization = "Bearer $apiKey"} - } + if ((Get-ChatAPIProvider) -eq 'OpenAI') { + if (!(Test-OpenAIKey)) { + throw 'Please set your OpenAI API key using Set-OpenAIKey or by configuring the $env:OpenAIKey environment variable (https://platform.openai.com/account/api-keys)' + } + + if (($apiKey = Get-LocalOpenAIKey) -is [SecureString]) { + #On PowerShell 6 and higher use Invoke-RestMethod with Authentication parameter and secure Token + $params['Authentication'] = 'Bearer' + $params['Token'] = $apiKey + } + else { + #On PowerShell 5 and lower, or when using the $env:OpenAIKey environment variable, use Invoke-RestMethod with plain text header + $params['Headers'] = @{Authorization = "Bearer $apiKey" } + } + } + elseif ((Get-ChatAPIProvider) -eq 'AzureOpenAI') { + $callingFunction = (Get-PSCallStack)[1].FunctionName + # if($callingFunction -ne 'Get-GPT4Completion'){ + if ($callingFunction -notin 'Get-GPT4Response', "Get-ChatCompletion") { + $msg = "$callingFunction is not supported by Azure OpenAI. Use 'Set-ChatAPIProvider OpenAI' and then try again." + #Write-Warning $msg + throw $msg + }` - Invoke-RestMethod @params + if (!(Test-AzureOpenAIKey)) { + throw 'Please set your Azure OpenAI API key by configuring the $env:AzureOpenAIKey environment variable' + } + else { + $params['Headers'] = @{'api-key' = $env:AzureOpenAIKey } + } + } + + Write-Verbose ($params | ConvertTo-Json) + + Write-Information "Thinking ..." + + Invoke-RestMethodWithProgress -Params $params -NoProgress:$NoProgress } diff --git a/Public/NotebookCopilot.ps1 b/Public/NotebookCopilot.ps1 new file mode 100644 index 0000000..86d0e57 --- /dev/null +++ b/Public/NotebookCopilot.ps1 @@ -0,0 +1,105 @@ +function Test-InNotebook { + <# + .SYNOPSIS + Returns true if the current session is in a Polyglot Interactive Notebook + .DESCRIPTION + Returns true if the current session is in a Polyglot Interactive Notebook + This is a helper function for the other functions in this module + It is not intended to be used directly + + .EXAMPLE + if (Test-InNotebook) { 'in notebook' } + #> + [CmdletBinding()] + param() + + $typename = 'Microsoft.DotNet.Interactive.Kernel' + $null -ne ($typename -as [type]) +} + +function New-NBCell { + <# + .SYNOPSIS + Creates a new cell in a Polyglot Interactive Notebook + .DESCRIPTION + Creates a new cell in a Polyglot Interactive Notebook + This is a helper function for the other functions in this module + It is not intended to be used directly + + .EXAMPLE + New-NBCell -cellType 'pwsh' -code 'Get-Process' + #> + [CmdletBinding()] + param( + [ValidateSet('pwsh', 'csharp', 'fsharp', 'html', 'markdown', 'javascript', 'sql', 'mermaid', 'kql')] + $cellType = 'pwsh', + [Parameter(ValueFromPipeline)] + $code + ) + + Begin { + if (-not (Test-InNotebook)) { + throw 'This can only be used in a Polyglot Interactive Notebook' + } + } + + Process { + $cellContent = New-Object Microsoft.DotNet.Interactive.Commands.SendEditableCode -ArgumentList $cellType, $code.Trim() + $null = [Microsoft.DotNet.Interactive.Kernel]::Root.SendAsync($cellContent) + } +} + +function New-PwshCell { + <# + .SYNOPSIS + Creates a new PowerShell cell in a Polyglot Interactive Notebook + .DESCRIPTION + Creates a new PowerShell cell in a Polyglot Interactive Notebook + This is a helper function for the other functions in this module + + .EXAMPLE + New-PwshCell -code 'Get-Process' + #> + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline)] + $code + ) + + Process { + $code | New-NBCell + } +} + +function NBCopilot { + <# + .SYNOPSIS + Interactes with GPT and sends the result to a Polyglot Interactive Notebook cell + + .EXAMPLE + NBCopilot 'Write a PowerShell core function, just code, no explanation, do not show how to use it, that will: show a date and time in timestamp form' + + .EXAMPLE + NBCopilot 'add comment based help to your code' + + .EXAMPLE + $prompt = 'Write c#, just the function, no explanation, do not show how to use it, that will: show a date and time in a regular timestamp form' + + NBCopilot $prompt -cellType csharp + #> + [CmdletBinding()] + param( + $prompt, + [ValidateSet('pwsh', 'csharp', 'fsharp', 'html', 'markdown', 'javascript', 'sql', 'mermaid', 'kql')] + $cellType = 'pwsh' + ) + + if (-not (Test-InNotebook)) { + throw 'This can only be used in a Polyglot Interactive Notebook' + } + + $result = chat $prompt + + $result = $result -replace '```powershell', '' -replace '```', '' + $result | New-NBCell -cellType $cellType +} \ No newline at end of file diff --git a/Public/SessionManagement.ps1 b/Public/SessionManagement.ps1 new file mode 100644 index 0000000..d8bad08 --- /dev/null +++ b/Public/SessionManagement.ps1 @@ -0,0 +1,192 @@ +$Script:timeStamp +$Script:chatSessionPath + +function Get-ChatSessionTimeStamp { + <# + .SYNOPSIS + Get chat session time stamp + .DESCRIPTION + Get chat session time stamp, if not set, set it to current time + .EXAMPLE + Get-ChatSessionTimeStamp + #> + [CmdletBinding()] + param () + + if ($null -eq $Script:timeStamp) { + $Script:timeStamp = (Get-Date).ToString("yyyyMMddHHmmss") + } + + $Script:timeStamp +} + +function Reset-ChatSessionTimeStamp { + <# + .SYNOPSIS + Reset chat session time stamp + .DESCRIPTION + Reset chat session time stamp to $null + .EXAMPLE + Reset-ChatSessionTimeStamp + #> + [CmdletBinding()] + param () + + $Script:timeStamp = $null +} + +function Reset-ChatSessionPath { + <# + .SYNOPSIS + Reset chat session path + .DESCRIPTION + Reset chat session path to default value + .EXAMPLE + Reset-ChatSessionPath + #> + [CmdletBinding()] + param () + + if ($PSVersionTable.Platform -eq 'Unix') { + $Script:chatSessionPath = Join-Path $env:HOME '~/PowerShellAI/ChatGPT' + } + elseif ($env:APPDATA) { + $Script:chatSessionPath = Join-Path $env:APPDATA 'PowerShellAI/ChatGPT' + } + +} + +function Get-ChatSessionPath { + <# + .SYNOPSIS + Get chat session path + .DESCRIPTION + Get chat session path, if not set, set it to default value + .EXAMPLE + Get-ChatSessionPath + #> + [CmdletBinding()] + param () + + if ($null -eq $Script:chatSessionPath) { + Reset-ChatSessionPath + } + + $Script:chatSessionPath +} + +function Set-ChatSessionPath { + <# + .SYNOPSIS + Set chat session path + .PARAMETER Path + Path of the chat session + .EXAMPLE + Set-ChatSessionPath -Path 'C:\Users\user\Documents\PowerShellAI\ChatGPT' + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + $Path + ) + + $Script:chatSessionPath = $Path +} + +function Get-ChatSessionFile { + <# + .SYNOPSIS + Get chat session file + .DESCRIPTION + Get chat session file from current time + .PARAMETER timeStamp + Time stamp of the chat session file + .EXAMPLE + Get-ChatSessionFile + #> + [CmdletBinding()] + param ( + $timeStamp + ) + + if (-not $timeStamp) { + $timeStamp = Get-ChatSessionTimeStamp + } + + Join-Path (Get-ChatSessionPath) ("{0}-ChatGPTSession.xml" -f $timeStamp) +} + +function Get-ChatSession { + <# + .SYNOPSIS + Get chat session files + .DESCRIPTION + Get chat session files from all time + .PARAMETER Name + Name of the chat session file, can be a regular expression + .EXAMPLE + Get-ChatSession + .EXAMPLE + Get-ChatSession -Name '20200101120000-ChatGPTSession' + #> + [CmdletBinding()] + param ( + $Name + ) + + $path = Get-ChatSessionPath + + if (Test-Path $path) { + $results = Get-ChildItem -Path $path -Filter "*.xml" | Where-Object { $_.Name -match $Name } + $results + } +} + +function Get-ChatSessionContent { + <# + .SYNOPSIS + Get chat session content + .DESCRIPTION + Get chat session content from a chat session file + .PARAMETER Path + Path of the chat session file + .EXAMPLE + Get-ChatSessionContent -Path 'C:\Users\user\Documents\PowerShellAI\ChatGPT\20200101120000-ChatGPTSession.xml' + #> + [CmdletBinding()] + param ( + [Alias('FullName')] + [Parameter(ValueFromPipelineByPropertyName)] + $Path + ) + + Process { + if (Test-Path $Path) { + Import-Clixml -Path $Path + } + } +} + +function Export-ChatSession { + <# + .SYNOPSIS + Export chat session + .DESCRIPTION + Export chat session to a chat session file + .EXAMPLE + Export-ChatSession + #> + + + [CmdletBinding()] + param () + + if ((Get-ChatPersistence) -eq $false) { return } + + $sessionPath = Get-ChatSessionPath + if (-not (Test-Path $sessionPath)) { + New-Item -ItemType Directory -Path $sessionPath -Force | Out-Null + } + + Get-ChatMessages | Export-Clixml -Path (Get-ChatSessionFile) -Force +} \ No newline at end of file diff --git a/Public/Set-AzureOpenAI.ps1 b/Public/Set-AzureOpenAI.ps1 new file mode 100644 index 0000000..c261b5a --- /dev/null +++ b/Public/Set-AzureOpenAI.ps1 @@ -0,0 +1,32 @@ +function Set-AzureOpenAI { + <# + .SYNOPSIS + Sets the Azure OpenAI API endpoint, deployment name, API version, and API key. + .DESCRIPTION + Sets up Azure OpenAI as the chat API provider. Use `Set-ChatAPIProvider -Provider OpenAI` to point to the public OpenAI + .EXAMPLE + Set-AzureOpenAI ` + -Endpoint https://anEndpoint.openai.azure.com/ ` + -DeploymentName aName ` + -ApiVersion 2023-03-15-preview ` + -ApiKey aKey + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + $Endpoint, + [Parameter(Mandatory)] + $DeploymentName, + [Parameter(Mandatory)] + $ApiVersion, + [Parameter(Mandatory)] + $ApiKey + ) + + $p = @{} + $PSBoundParameters + $p.Remove("ApiKey") + + Set-AzureOpenAIOptions @p + $env:AzureOpenAIKey = $ApiKey + Set-ChatAPIProvider -Provider AzureOpenAI +} \ No newline at end of file diff --git a/Public/Test-AzureOpenAIKey.ps1 b/Public/Test-AzureOpenAIKey.ps1 new file mode 100644 index 0000000..cdaaef2 --- /dev/null +++ b/Public/Test-AzureOpenAIKey.ps1 @@ -0,0 +1,10 @@ +function Test-AzureOpenAIKey { + <# + .SYNOPSIS + Tests if the AzureOpenAIKey module scope variable or environment variable is set. + + .EXAMPLE + Test-AzureOpenAIKey + #> + -not [string]::IsNullOrEmpty($env:AzureOpenAIKey) +} diff --git a/Public/ai.ps1 b/Public/ai.ps1 index e1c8810..d36dc76 100644 --- a/Public/ai.ps1 +++ b/Public/ai.ps1 @@ -14,6 +14,7 @@ function ai { .EXAMPLE git status | ai "create a detailed git message" #> + [CmdletBinding()] param( $inputPrompt, [Parameter(ValueFromPipeline = $true)] @@ -38,6 +39,8 @@ $(($lines | Out-String).Trim()) "@ + Write-Verbose $fullPrompt.Trim() + (Get-GPT3Completion -prompt $fullPrompt.Trim() -max_tokens $max_tokens -temperature $temperature).Trim() } } diff --git a/Public/copilot.ps1 b/Public/copilot.ps1 index 559e384..1700cf4 100644 --- a/Public/copilot.ps1 +++ b/Public/copilot.ps1 @@ -1,3 +1,36 @@ +function Get-Runnable { + <# + .SYNOPSIS + Gets the runnable code from the result + + .DESCRIPTION + Gets the runnable code from the result + + .EXAMPLE + Get-Runnable -result $result + #> + [CmdletBinding()] + param( + $result + ) + + $runnable = for ($idx = 1; $idx -lt $result.Count; $idx++) { + $line = $result[$idx] + if ([string]::IsNullOrEmpty($line)) { + continue + } + + $line = $line.Trim() + if ($line.StartsWith('#')) { + continue + } + + $line + } + + return ($runnable -join "`n") +} + function copilot { <# .SYNOPSIS @@ -26,12 +59,16 @@ function copilot { .EXAMPLE copilot 'Find all enabled users that have a samaccountname similar to Mazi; List SAMAccountName and DisplayName' #> + [CmdletBinding()] + [alias("??")] param( [Parameter(Mandatory)] $inputPrompt, - [ValidateRange(0,2)] + $SystemPrompt = 'using powershell, just code:', + [ValidateRange(0, 2)] [decimal]$temperature = 0.0, # The maximum number of tokens to generate. default 256 + [ValidateRange(1, 4000)] $max_tokens = 256, # Don't show prompt for choice [Switch]$Raw @@ -39,17 +76,25 @@ function copilot { # $inputPrompt = $args -join ' ' - $shell = 'powershell, just code:' + #$shell = 'powershell, just code:' $promptComments = ', include comments' if (-not $IncludeComments) { $promptComments = '' } - $prompt = "using {0} {1}: {2}`n" -f $shell, $promptComments, $inputPrompt + $prompt = "{0} {1}: {2}`n" -f $SystemPrompt, $promptComments, $inputPrompt $prompt += '```' - $completion = Get-GPT3Completion -prompt $prompt -max_tokens $max_tokens -temperature $temperature -stop '```' + # Dynamically determine which OpenAI service is being used + $provider = $null + $provider = Get-ChatAPIProvider + switch ($provider.tolower()) { + openai { $completion = Get-GPT3Completion -prompt $prompt -max_tokens $max_tokens -temperature $temperature -stop '```' } + azureopenai { $completion = Get-GPT4Completion -Content $prompt -max_tokens $max_tokens -temperature $temperature } + Default { $completion = Get-GPT3Completion -prompt $prompt -max_tokens $max_tokens -temperature $temperature -stop '```' } + } + $completion = $completion -split "`n" if ($completion[0] -ceq 'powershell') { @@ -60,33 +105,152 @@ function copilot { return $completion } else { + $result = @($inputPrompt) $result += '' $result += $completion - $result | CreateBoxText + $runnable = Get-Runnable -result $result + + if (Test-AifbScriptAnalyzerAvailable) { + $runnable = Invoke-Formatter -ScriptDefinition $runnable -Verbose:$false + } - $userInput = CustomReadHost + Write-Codeblock -Text $runnable -ShowLineNumbers -SyntaxHighlight - if ($userInput -eq 0) { - $runnable = for ($idx = 1; $idx -lt $result.Count; $idx++) { - $line = $result[$idx] - if ([string]::IsNullOrEmpty($line)) { - continue + do { + $userInput = CustomReadHost + + switch ($userInput) { + 0 { + (Get-Runnable -result $result) | Invoke-Expression } - - $line = $line.Trim() - if ($line.StartsWith('#')) { - continue + 1 { + explain -Value (Get-Runnable -result $result) + write-output "`n" + } + 2 { + Get-Runnable -result $result | Set-Clipboard + } + 3 { + if (Test-VSCodeInstalled) { + (Get-Runnable $result) | code - + } + else { + "Not running" + } + } + default { + "Not running" } - - $line } + } while ($userInput -eq 1) + } +} - ($runnable -join "`n") | Invoke-Expression - } - else { - "Not running" - } + +function git? { + <# + .SYNOPSIS + A brief description of what the cmdlet does. + + .DESCRIPTION + A detailed description of what the cmdlet does. + + .PARAMETER inputPrompt + Prompt to be sent to GPT + + .PARAMETER temperature + The sampling temperature to use when generating text. Default is 0.0. + + .PARAMETER max_tokens + The maximum number of tokens to generate. Default is 256. + + .PARAMETER Raw + Don't show prompt for choice. Default is false. + + .EXAMPLE + git? 'compare this branch to master, just the files' + + #> + [CmdletBinding()] + param( + $inputPrompt, + [ValidateRange(0, 2)] + [decimal]$temperature = 0.0, + # The maximum number of tokens to generate. default 256 + $max_tokens = 256, + # Don't show prompt for choice + [Switch]$Raw + ) + + $params = @{ + inputPrompt = $inputPrompt + SystemPrompt = 'you are an expert at using git command line, just code: ' + temperature = $temperature + max_tokens = $max_tokens + Raw = $Raw } + + copilot @params } + +function gh? { + <# + .SYNOPSIS + A brief description of what the cmdlet does. + + .DESCRIPTION + A detailed description of what the cmdlet does. + + .PARAMETER inputPrompt + Prompt to be sent to GPT + + .PARAMETER temperature + The sampling temperature to use when generating text. Default is 0.0. + + .PARAMETER max_tokens + The maximum number of tokens to generate. Default is 256. + + .PARAMETER Raw + Don't show prompt for choice. Default is false. + + .EXAMPLE + gh? 'list all closed PRs opened by dfinke and find the word fix' + + .EXAMPLE + gh? 'list issues on dfinke/importexcel' + #> + [CmdletBinding()] + param( + $inputPrompt, + [ValidateRange(0, 2)] + [decimal]$temperature = 0.0, + # The maximum number of tokens to generate. default 256 + $max_tokens = 256, + # Don't show prompt for choice + [Switch]$Raw + ) + + $params = @{ + inputPrompt = $inputPrompt + SystemPrompt = ' +1. You are an expert at using GitHub gh cli. +2. You are working with GitHub Repositories. +3. If no owner/repo, default to current dir. +4. Handle owner/repo correctly with --repo. +5. Map the prompt to the correct syntax of the gh cli. +6. Some commands require a flag, like --state +7. Handle pluralization to singular correctly for the gh cli syntax. +8. Handle removing spaces in the command and map to the correct syntax of the gh cli. +9. Do not provide an explanation or usage example. +10. Do not tell me about the command to use. +11. Just output the command: + ' + temperature = $temperature + max_tokens = $max_tokens + Raw = $Raw + } + + copilot @params +} \ No newline at end of file diff --git a/README.md b/README.md index 45e4a3a..59effb7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,85 @@ +![alt text](Repo-Closing.png) +
+
+ +## Notice of Repository Closure + +This repository is now closed. I have moved it to a new home to better serve its needs and continue development. + +Thank you for your support and collaboration! + +The new repository includes key updates from the latest OpenAI announcements. Please visit [PSAI](https://github.com/dfinke/PSAI) to stay updated and contribute. + +

+drawing +

+ +
+
+
+
+
+
+
+
+
+ +

OpenAI at your Fingertips! ✨

+

using PowerShell

+ +

+ + + +
+
+ + + + + + + + + + + + + + + +

+ +

+ • Documentation • +

+ +# **Big Announcement** +- I created a new module called [PowerShellAIAssistant](https://github.com/dfinke/PowerShellAIAssistant). +- You can get it: `Install-Module -Name PowerShellAIAssistant` +- Highly recommend you check it out. + +> You can just type 'ai "your question here"' and it will return an answer. + +> The module exposes a lot of handy functions that interact directly with the new Assistants API, so you can also build scripts or spin up and manage different assistants/threads yourself. + +``` +The Assistants API is a tool that lets you create your own AI assistants in your applications. +Think of it as building a digital helper that can follow instructions and use different tools and knowledge to answer questions or solve problems. + +It has three types of tools for different tasks like coding, information retrieval, and executing specific functions. + +OpenAI is adding more, and you'll also be able to add your own tools to customize your assistant's abilities. +``` + +> All can programmatically automate via PowerShell. + # PowerShellAI + ## A User-Friendly Module for OpenAI's GPT-3 and DALL-E API `PowerShellAI` is a ***community-maintained*** PowerShell module designed to simplify the use of OpenAI's GPT-3 language model and DALL-E API. It empowers users to effortlessly build complex language-powered applications, regardless of their level of experience. @@ -36,28 +117,38 @@ In the PowerShell console: Install-Module -Name PowerShellAI ``` -Get/Create your OpenAI API key from [https://beta.openai.com/account/api-keys](https://beta.openai.com/account/api-keys) and then set as *secure string* with `Set-OpenAIKey` or as *plain text* with `$env:OpenAIKey`. +Get/Create your OpenAI API key from [ https://platform.openai.com/account/api-keys]( https://platform.openai.com/account/api-keys) and then set as *secure string* with `Set-OpenAIKey` or as *plain text* with `$env:OpenAIKey`. ## Examples Check out these PowerShell scripts to see how easy it is to get started with AI in PowerShell: |PS Script | Description | Location |--|--|--| -| Disable-AIShortCutKey | Disable the ctrl+g shortcut key go getting completions | [Disable-AIShortCutKey.ps1](./Public/Disable-AIShortCutKey.ps1) | -| Enable-AIShortCutKey | Enable the ctrl+g | [Enable-AIShortCutKey.ps1](./Public/Enable-AIShortCutKey.ps1) | -| Get-OpenAIEdit | Given a prompt and an instruction, the model will return an edited version of the prompt | [Get-OpenAIEdit.ps1](./Public/Get-OpenAIEdit.ps1) -| Get-GPT3Completion | Get a completion from the OpenAI GPT-3 API | [Get-GPT3Completion.ps1](./Public/Get-GPT3Completion.ps1) -| Get-DalleImage | Get an image from the OpenAI DALL-E API | [Get-DalleImage.ps1](./Public/Get-DalleImage.ps1) | ai | Experimental AI function that you can pipe all sorts of things into and get back a completion | [ai.ps1](./Public/ai.ps1) | copilot | Makes the request to GPT, parses the response and displays it in a box and then prompts the user to run the code or not. | [copilot.ps1](./Public/copilot.ps1) +| Get-GPT3Completion - alias `gpt` | Get a completion from the OpenAI GPT-3 API | [Get-GPT3Completion.ps1](./Public/Get-GPT3Completion.ps1) +| Invoke-AIErrorHelper | Helper function let ChatGPT add more info about errors | [Invoke-AIErrorHelper.ps1](./Public/Invoke-AIErrorHelper.ps1) +| Invoke-AIExplain | Utilizes the OpenAI GPT-3 API to offer explanations for the most recently run command, and more. | [Invoke-AIExplain.ps1](./Public/Invoke-AIExplain.ps1) +| Get-OpenAIEdit | Given a prompt and an instruction, the model will return an edited version of the prompt | [Get-OpenAIEdit.ps1](./Public/Get-OpenAIEdit.ps1) | Get-DalleImage | Get an image from the OpenAI DALL-E API | [Get-DalleImage.ps1](./Public/Get-DalleImage.ps1) +| Get-AOAIDalleImage | Get an image from the Azure OpenAI DALL-E API | [Get-AOAIDalleImage.ps1](./Public/Get-AOAIDalleImage.ps1) | Set-DalleImageAsWallpaper | Set the image from the OpenAI DALL-E API as the wallpaper | [Set-DalleImageAsWallpaper.ps1](./Public/Set-DalleImageAsWallpaper.ps1) +| Get-OpenAIUsage |Returns a billing summary of OpenAI API usage for your organization +| Disable-AIShortCutKey | Disable the ctrl+g shortcut key go getting completions | [Disable-AIShortCutKey.ps1](./Public/Disable-AIShortCutKey.ps1) | +| Enable-AIShortCutKey | Enable the ctrl+g | [Enable-AIShortCutKey.ps1](./Public/Enable-AIShortCutKey.ps1) | + + +## Polyglot Interactive Notebooks + +| Notebook | Description | Location +|--|--|--| +| OpenAI Settings | A notebook shows how to get OpenAI dashboard info | [Settings.ipynb](CommunityContributions/05-Settings/Settings.ipynb)
## Demos of the PowerShellAI -Here are some vidoes of `PowerShellAI` in action: +Here are some videos of `PowerShellAI` in action: | Description | YouTube Video | |--|--| @@ -66,12 +157,41 @@ Here are some vidoes of `PowerShellAI` in action: | PowerShell AI - `copilot` at the command line | | | PowerShell AI - new `ai` function | | | New-Spreadsheet script: PowerShell + ChatGPT + Excel | | +| Invoke-AIErrorHelper: Lets ChatGPT provide additional information and context about errors | | +| Invoke-AIExplain: Utilizes the OpenAI GPT-3 API to offer explanations for the most recently run command, and more. | |

## What it looks like +### Setting AzureOpenAI as the default chat API provider +```powershell +set-chatAPIProvider AzureOpenAI +``` + +```powershell +# Setting AzureOpenAI parameters +Set-AzureOpenAI -ApiKey 'API Key' -Endpoint https://endpoint.azure-api.net -DeploymentName gpt-35-turbo-16k -ApiVersion 2023-03-15-preview +``` + +### Setting OpenAI as the default chat API provider +```powershell +Set-ChatAPIProvider OpenAI +``` + +```powershell +# Fetch 'OpenAIKey' which is stored in the System environment variables +$pass = [Environment]::GetEnvironmentVariable('OpenAIKey', 'Machine') +``` + +```powershell +# Set 'OpenAIKey' +Set-OpenAIKey -Key ($pass | ConvertTo-SecureString -AsPlainText -Force) +``` + +***Note:*** One of the above chat API provider has to be set before executing any of the below commands. + > ***Note:*** You can use the `gpt` alias for `Get-GPT3Completion` ```powershell @@ -218,15 +338,23 @@ Thank you to [Clem Messerli](https://twitter.com/ClemMesserli/status/16163122382 Check out the [video of `copilot` in action]() +# Ask ChatGPT for help with an error message + +If you get an error after executing some PowerShell. You can now ask ChatGPT for help. The new `Invoke-AIErrorInsights` function will take the last error message and ask ChatGPT for help. + +You can also use the alias `ieh`. + +![Alt text](media/AIErrorInsights.png) + # Code editing example Unlike completions, edits takes two inputs: the `text` to edit and an `instruction`. Here the `model` is set to `code-davinci-edit-001` because we're working with PowerShell code. -- Here you're passing in the string that is a PowerShell function. +- Here you're passing in the string (`InputText`) that is a PowerShell function. - The `instruction` is to `add a comment-based help detailed description` ```powershell -Get-OpenAIEdit @' +Get-OpenAIEdit -InputText @' function greet { param($n) @@ -283,7 +411,7 @@ Try it out: `New-Spreadsheet "list of first 5 US presidents name, term"` # DALL-E -The [DALL-E](https://openai.com/blog/dall-e/) API is a new API from OpenAI that allows you to generate images from text. The API is currently in beta and is free to use. +The [DALL-E](https://openai.com/blog/dall-e/) API is a new API from OpenAI that allows you to generate images from text Use this function to generate an image from text and set it as your desktop background. @@ -295,4 +423,19 @@ You can also use the `Get-DalleImage` function to get the image and it saves to ```powershell Get-DalleImage "A picture of a cat" -``` \ No newline at end of file +``` + +## Azure OpenAI DALL-E + +Azure OpenAI DALL-E provides additional options for text to image generation. Images will by default be put in an Images directory under your script path. + +- Use -Images to specify how many images to generate for the same description +- Use -Raw to return the raw image data and not output to a PNG file + +```powershell +Get-AOAIDalleImage -Description "a painting of the Sydney Opera house in the style of Rembrant on a sunny day" +``` + +```powershell +Get-AOAIDalleImage -Description "a painting of the Sydney Opera house in the style of Rembrant on a sunny day" -Images 3 +``` diff --git a/Repo-Closing.png b/Repo-Closing.png new file mode 100644 index 0000000..778832c Binary files /dev/null and b/Repo-Closing.png differ diff --git a/__tests__/ChatMessage.tests.ps1 b/__tests__/ChatMessage.tests.ps1 new file mode 100644 index 0000000..705f886 --- /dev/null +++ b/__tests__/ChatMessage.tests.ps1 @@ -0,0 +1,269 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe "Chat Messages" -Tag ChatMessages { + BeforeAll { + $script:savedKey = $env:OpenAIKey + $env:OpenAIKey = 'sk-1234567890' + } + + AfterAll { + $env:OpenAIKey = $savedKey + Stop-Chat + } + + BeforeEach { + Clear-ChatMessages + } + + It 'Tests New-ChatUserMessage exists' { + $actual = Get-Command New-ChatUserMessage -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Tests New-ChatSystemMessage exists' { + $actual = Get-Command New-ChatSystemMessage -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Tests New-ChatAssistantMessage exists' { + $actual = Get-Command New-ChatAssistantMessage -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Tests Get-ChatMessages exists' { + $actual = Get-Command Get-ChatMessages -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Tests Clear-ChatMessages exists' { + $actual = Get-Command Clear-ChatMessages -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Tests Get-ChatMessages is null as default' { + $actual = Get-ChatMessages + $actual | Should -BeNullOrEmpty + } + + It 'Tests New-ChatMessage exists' { + $actual = Get-Command New-ChatMessage -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Tests New-Chat exists' { + $actual = Get-Command New-Chat -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Clear-ChatMessages clears messages' { + New-ChatMessage -Role 'user' -Content "Hello" + New-ChatUserMessage -Content "Hello message 2" + + $actual = Get-ChatMessages + $actual.Count | Should -Be 2 + + Clear-ChatMessages + $actual = Get-ChatMessages + $actual.Count | Should -Be 0 + } + + It 'Tests Get-ChatMessages retuns messages with proper cased keys' -Tag AIFunctionBuilder { + New-ChatMessage -Role 'user' -Content "Hello" + + $actual = Get-ChatMessages + + $names = $actual[0].PSObject.Properties.Name + + $names[0] | Should -BeExactly "role" + $names[1] | Should -BeExactly "content" + } + + It 'Tests New-ChatMessage has these parameters' { + $actual = Get-Command New-ChatMessage -ErrorAction SilentlyContinue + + $keys = $actual.Parameters.keys + + $keys.Contains("role") | Should -BeTrue + $keys.Contains("content") | Should -BeTrue + } + + It 'Tests New-ChatUserMessage has these parameters' { + $actual = Get-Command New-ChatUserMessage -ErrorAction SilentlyContinue + + $keys = $actual.Parameters.keys + + $keys.Contains("content") | Should -BeTrue + } + + It 'Tests New-ChatSystemMessage has these parameters' { + $actual = Get-Command New-ChatSystemMessage -ErrorAction SilentlyContinue + + $keys = $actual.Parameters.keys + + $keys.Contains("content") | Should -BeTrue + } + + It 'Tests adding a user message with New-ChatMessage' { + + New-ChatMessage -Role 'user' -Content "Hello" + + $actual = Get-ChatMessages + + $actual.Count | Should -Be 1 + $actual[0].Role | Should -Be "user" + $actual[0].Content | Should -Be "Hello" + } + + It 'Tests adding a user message with New-ChatUserMessage' { + Clear-ChatMessages + New-ChatUserMessage -content "Hello" + + $actual = Get-ChatMessages + + $actual.Count | Should -Be 1 + $actual[0].Role | Should -Be "user" + $actual[0].Content | Should -Be "Hello" + } + + It 'Tests state gets reset after New-Chat' { + Mock Invoke-RestMethodWithProgress -ModuleName PowerShellAI -ParameterFilter { + $Params.Method -eq 'Post' -and $Params.Uri -eq (Get-OpenAIChatCompletionUri) + } -MockWith { + [PSCustomObject]@{ + choices = @( + [PSCustomObject]@{ + message = [PSCustomObject]@{ + content = 'Mocked Get-GPT4Completion call' + } + } + ) + } + } + + New-ChatUserMessage -content "Hello" + (Get-ChatMessages).Count | Should -Be 1 + + New-Chat + + $chatMessages = @(Get-ChatMessages) + $chatMessages.Count | Should -Be 0 + } + + It 'Tests adding new chat system messages' { + Mock Invoke-RestMethodWithProgress -ModuleName PowerShellAI -ParameterFilter { + $Params.Method -eq 'Post' -and $Params.Uri -eq (Get-OpenAIChatCompletionUri) + } -MockWith { + [PSCustomObject]@{ + choices = @( + [PSCustomObject]@{ + message = [PSCustomObject]@{ + content = 'Mocked Get-GPT4Completion call' + } + } + ) + } + } + New-ChatSystemMessage -Content "Hello" + New-ChatMessage -Role 'system' -Content "World" + + $actual = Get-ChatMessages + + $actual.Count | Should -Be 2 + + $actual[0].Role | Should -Be "system" + $actual[0].Content | Should -Be "Hello" + + $actual[1].Role | Should -Be "system" + $actual[1].Content | Should -Be "World" + } + + It 'Tests adding new chat assistant messages' { + New-ChatAssistantMessage -Content "Hello" + New-ChatMessage -Role 'assistant' -Content "World" + + $actual = Get-ChatMessages + + $actual.Count | Should -Be 2 + + $actual[0].Role | Should -Be "assistant" + $actual[0].Content | Should -Be "Hello" + + $actual[1].Role | Should -Be "assistant" + $actual[1].Content | Should -Be "World" + } + + It 'Tests New-Chat with a starting message' { + + Mock Invoke-RestMethodWithProgress -ModuleName PowerShellAI -ParameterFilter { + $Params.Method -eq 'Post' -and $Params.Uri -eq (Get-OpenAIChatCompletionUri) + } -MockWith { + [PSCustomObject]@{ + choices = @( + [PSCustomObject]@{ + message = [PSCustomObject]@{ + content = 'Mocked Get-GPT4Completion call' + } + } + ) + } + } + + New-Chat -Content "You are a powershell bot" + + $actual = Get-ChatMessages + + $actual.Count | Should -Be 2 + + $actual[0].Role | Should -BeExactly 'system' + $actual[0].Content | Should -BeExactly 'You are a powershell bot' + + $actual[1].Role | Should -BeExactly 'assistant' + $actual[1].Content | Should -BeExactly 'Mocked Get-GPT4Completion call' + } + + It 'Tests creating a chat and sending a message' { + Mock Invoke-RestMethodWithProgress -ModuleName PowerShellAI -ParameterFilter { + $Params.Method -eq 'Post' -and $Params.Uri -eq (Get-OpenAIChatCompletionUri) + } -MockWith { + [PSCustomObject]@{ + choices = @( + [PSCustomObject]@{ + message = [PSCustomObject]@{ + content = 'Mocked Get-GPT4Completion call' + } + } + ) + } + } + + New-Chat -Content "You are a powershell bot" + $result = chat "Hello" + + $result | Should -BeExactly "Mocked Get-GPT4Completion call" + + $actual = Get-ChatMessages + + $actual.Count | Should -Be 4 + + <# + role content + ---- ------- + system You are a powershell bot + assistant Mocked Get-GPT4Completion call + user Hello + assistant Mocked Get-GPT4Completion call + #> + + $actual[0].Role | Should -BeExactly 'system' + $actual[0].Content | Should -BeExactly 'You are a powershell bot' + + $actual[1].Role | Should -BeExactly 'assistant' + $actual[1].Content | Should -BeExactly 'Mocked Get-GPT4Completion call' + + $actual[2].Role | Should -BeExactly 'user' + $actual[2].Content | Should -BeExactly 'Hello' + + $actual[3].Role | Should -BeExactly 'assistant' + $actual[3].Content | Should -BeExactly 'Mocked Get-GPT4Completion call' + } +} \ No newline at end of file diff --git a/__tests__/ConvertFrom-GPTMarkdownTable.tests.ps1 b/__tests__/ConvertFrom-GPTMarkdownTable.tests.ps1 index e443d08..23279b5 100644 --- a/__tests__/ConvertFrom-GPTMarkdownTable.tests.ps1 +++ b/__tests__/ConvertFrom-GPTMarkdownTable.tests.ps1 @@ -1,6 +1,6 @@ Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force -Describe "ConvertFrom-GPTMarkdownTable" { +Describe "ConvertFrom-GPTMarkdownTable" -Tag GPTMarkdownTable { It "ConvertFrom-GPTMarkdownTable" { $markdown = @" | p1 | p2 | p3 | @@ -8,7 +8,7 @@ Describe "ConvertFrom-GPTMarkdownTable" { | 1 | 2 | 3 | | 4 | 5 | 6 | "@ - $actual = ConvertFrom-GPTMarkdownTable $markdown + $actual = ConvertFrom-GPTMarkdownTable $markdown.Trim() $actual | Should -Not -BeNullOrEmpty @@ -16,17 +16,18 @@ Describe "ConvertFrom-GPTMarkdownTable" { $names = $actual[0].psobject.Properties.Name $names.Count | Should -Be 3 - $names[0] | Should -Be 'p1 ' - $names[1] | Should -Be 'p2 ' - $names[2] | Should -Be 'p3 ' + $names[0] | Should -Be 'p1' + $names[1] | Should -Be 'p2' + $names[2] | Should -Be 'p3' + - $actual[0].'p1 ' | Should -Be 1 - $actual[0].'p2 ' | Should -Be 2 - $actual[0].'p3 ' | Should -Be 3 + $actual[0].'p1' | Should -Be 1 + $actual[0].'p2' | Should -Be 2 + $actual[0].'p3' | Should -Be 3 - $actual[1].'p1 ' | Should -Be 4 - $actual[1].'p2 ' | Should -Be 5 - $actual[1].'p3 ' | Should -Be 6 + $actual[1].'p1' | Should -Be 4 + $actual[1].'p2' | Should -Be 5 + $actual[1].'p3' | Should -Be 6 } @@ -50,22 +51,22 @@ Celery | 16 | 0 | 3 | 0 $names = $actual[0].psobject.Properties.Name $names.Count | Should -Be 5 - $names[0] | Should -Be 'Vegetable ' - $names[1] | Should -Be 'Calories ' - $names[2] | Should -Be 'Protein (g) ' - $names[3] | Should -Be 'Carbs (g) ' + $names[0] | Should -Be 'Vegetable' + $names[1] | Should -Be 'Calories' + $names[2] | Should -Be 'Protein (g)' + $names[3] | Should -Be 'Carbs (g)' $names[4] | Should -Be 'Fat (g)' - $actual[0].'Vegetable ' | Should -Be 'Carrot ' - $actual[0].'Calories ' | Should -Be 41 - $actual[0].'Protein (g) ' | Should -Be 1 - $actual[0].'Carbs (g) ' | Should -Be 9 + $actual[0].'Vegetable' | Should -Be 'Carrot' + $actual[0].'Calories' | Should -Be 41 + $actual[0].'Protein (g)' | Should -Be 1 + $actual[0].'Carbs (g)' | Should -Be 9 $actual[0].'Fat (g)' | Should -Be 0 - $actual[-1].'Vegetable ' | Should -Be 'Celery ' - $actual[-1].'Calories ' | Should -Be 16 - $actual[-1].'Protein (g) ' | Should -Be 0 - $actual[-1].'Carbs (g) ' | Should -Be 3 + $actual[-1].'Vegetable' | Should -Be 'Celery' + $actual[-1].'Calories' | Should -Be 16 + $actual[-1].'Protein (g)' | Should -Be 0 + $actual[-1].'Carbs (g)' | Should -Be 3 $actual[-1].'Fat (g)' | Should -Be 0 } @@ -88,17 +89,17 @@ Celery | 16 | 0 | 3 | 0 $names = $actual[0].psobject.Properties.Name $names.Count | Should -Be 3 - $names[0] | Should -Be 'President ' - $names[1] | Should -Be 'Term ' - $names[2] | Should -Be 'Vice President ' + $names[0] | Should -Be 'President' + $names[1] | Should -Be 'Term' + $names[2] | Should -Be 'Vice President' - $actual[0].'President ' | Should -Be 'George Washington ' - $actual[0].'Term ' | Should -Be '1789-1797 ' - $actual[0].'Vice President ' | Should -Be 'John Adams ' + $actual[0].'President' | Should -Be 'George Washington' + $actual[0].'Term' | Should -Be '1789-1797' + $actual[0].'Vice President' | Should -Be 'John Adams' - $actual[-1].'President ' | Should -Be 'James Monroe ' - $actual[-1].'Term ' | Should -Be '1817-1825 ' - $actual[-1].'Vice President ' | Should -Be 'Daniel D. Tompkins ' + $actual[-1].'President' | Should -Be 'James Monroe' + $actual[-1].'Term' | Should -Be '1817-1825' + $actual[-1].'Vice President' | Should -Be 'Daniel D. Tompkins' } It "ConvertFrom-GPTMarkdownTable - with whitespace" { @@ -118,16 +119,16 @@ Celery | 16 | 0 | 3 | 0 $names = $actual[0].psobject.Properties.Name $names.Count | Should -Be 3 - $names[0] | Should -Be 'Column 1 ' - $names[1] | Should -Be 'Column 2 ' - $names[2] | Should -Be 'Column 3 ' + $names[0] | Should -Be 'Column 1' + $names[1] | Should -Be 'Column 2' + $names[2] | Should -Be 'Column 3' - $actual[0].'Column 1 ' | Should -Be 'Cell 1 ' - $actual[0].'Column 2 ' | Should -Be 'Cell 2 ' - $actual[0].'Column 3 ' | Should -Be 'Cell 3 ' + $actual[0].'Column 1' | Should -Be 'Cell 1 ' + $actual[0].'Column 2' | Should -Be 'Cell 2 ' + $actual[0].'Column 3' | Should -Be 'Cell 3 ' - $actual[-1].'Column 1 ' | Should -Be 'Cell 7 ' - $actual[-1].'Column 2 ' | Should -Be 'Cell 8 ' - $actual[-1].'Column 3 ' | Should -Be 'Cell 9 ' + $actual[-1].'Column 1' | Should -Be 'Cell 7 ' + $actual[-1].'Column 2' | Should -Be 'Cell 8 ' + $actual[-1].'Column 3' | Should -Be 'Cell 9 ' } } \ No newline at end of file diff --git a/__tests__/ConvertTo-JsonL.tests.ps1 b/__tests__/ConvertTo-JsonL.tests.ps1 new file mode 100644 index 0000000..d87a356 --- /dev/null +++ b/__tests__/ConvertTo-JsonL.tests.ps1 @@ -0,0 +1,77 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + + +Describe "ConvertTo-JsonL" -Tag 'ConvertTo-JsonL' { + + It 'Tests ConvertTo-JsonL function exists' { + $actual = Get-Command ConvertTo-JsonL -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Tests ConvertTo-JsonL has these parameters' { + $actual = Get-Command ConvertTo-JsonL -ErrorAction SilentlyContinue + + $actual | Should -Not -BeNullOrEmpty + $keys = $actual.Parameters.Keys + $keys.Contains('InputObject') | Should -BeTrue + } + + It 'Tests ConvertTo-JsonL InputObject parameter has these attributes' { + $actual = Get-Command ConvertTo-JsonL -ErrorAction SilentlyContinue + + $actual | Should -Not -BeNullOrEmpty + + $actual.Parameters['InputObject'].Attributes.Mandatory | Should -BeTrue + $actual.Parameters['InputObject'].Attributes.ValueFromPipeline | Should -BeTrue + $actual.Parameters['InputObject'].Attributes.ValueFromPipelineByPropertyName | Should -BeFalse + } + + It 'Tests ConvertTo-JsonL returns a string of JsonL' { + $actual = ConvertTo-JsonL -InputObject @( + [Ordered]@{Name = 'Test'; Value = 'Test' } + ) + $actual | Should -Not -BeNullOrEmpty + $actual | Should -BeOfType [string] + + $result = $actual.Split([System.Environment]::NewLine) + + $result.Count | Should -Be 2 + $result[0] | Should -Be '{"Name":"Test","Value":"Test"}' + } + + It 'Tests ConvertTo-JsonL handles more than one object' { + $actual = ConvertTo-JsonL -InputObject @( + [Ordered]@{Name = 'Test'; Value = 'Test' } + [Ordered]@{Name = 'Test2'; Value = 'Test2' } + ) + + $actual | Should -Not -BeNullOrEmpty + $actual | Should -BeOfType [string] + + $result = $actual.Split([System.Environment]::NewLine) + + $result.Count | Should -Be 3 + $result[0] | Should -Be '{"Name":"Test","Value":"Test"}' + $result[1] | Should -Be '{"Name":"Test2","Value":"Test2"}' + + } + + It 'Tests ConvertTo-JsonL handles converted csv' { + + $data = ConvertFrom-Csv @" +Name,Value +Test,Test +Test2,Test2 +"@ + $actual = ConvertTo-JsonL -InputObject $data + + $actual | Should -Not -BeNullOrEmpty + $actual | Should -BeOfType [string] + + $result = $actual.Split([System.Environment]::NewLine) + + $result.Count | Should -Be 3 + $result[0] | Should -Be '{"Name":"Test","Value":"Test"}' + $result[1] | Should -Be '{"Name":"Test2","Value":"Test2"}' + } +} \ No newline at end of file diff --git a/__tests__/Disable-ChatPersistence.tests.ps1 b/__tests__/Disable-ChatPersistence.tests.ps1 new file mode 100644 index 0000000..44bf68e --- /dev/null +++ b/__tests__/Disable-ChatPersistence.tests.ps1 @@ -0,0 +1,20 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe 'Disable-ChatPersistence' -Tag ChatPersistence { + + BeforeEach { + Reset-ChatSessionOptions + } + + It 'tests the function Disable-ChatPersistence exists' { + $actual = Get-Command Disable-ChatPersistence -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'tests that it disables chat persistence' { + Disable-ChatPersistence + $actual = Get-ChatPersistence + + $actual | Should -Be $false + } +} \ No newline at end of file diff --git a/__tests__/Enable-ChatPersistence.tests.ps1 b/__tests__/Enable-ChatPersistence.tests.ps1 new file mode 100644 index 0000000..7dd4609 --- /dev/null +++ b/__tests__/Enable-ChatPersistence.tests.ps1 @@ -0,0 +1,20 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe 'Enable-ChatPersistence' -Tag ChatPersistence { + + AfterEach { + Reset-ChatSessionOptions + } + + It 'tests the function Enable-ChatPersistence exists' { + $actual = Get-Command Enable-ChatPersistence -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'tests that it enables chat persistence' { + Enable-ChatPersistence + $actual = Get-ChatPersistence + + $actual | Should -Be $true + } +} \ No newline at end of file diff --git a/__tests__/Get-ChatPayload.tests.ps1 b/__tests__/Get-ChatPayload.tests.ps1 new file mode 100644 index 0000000..621e52c --- /dev/null +++ b/__tests__/Get-ChatPayload.tests.ps1 @@ -0,0 +1,152 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe "Get-ChatPayload" -Tag ChatPayload { + + BeforeAll { + $script:savedKey = $env:OpenAIKey + $env:OpenAIKey = 'sk-1234567890' + } + + AfterAll { + $env:OpenAIKey = $savedKey + } + + BeforeEach { + Clear-ChatMessages + } + + It 'Tests Get-ChatPayload function exists' { + $actual = Get-Command Get-ChatPayload -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Tests Get-ChatPayload has initial correct data' { + $actual = Get-ChatPayload + $actual | Should -Not -BeNullOrEmpty + + $actual.keys.count | Should -Be 8 + + $actual.model | Should -Be 'gpt-4' + $actual.temperature | Should -Be 0.0 + $actual.max_tokens | Should -Be 256 + $actual.top_p | Should -Be 1.0 + $actual.frequency_penalty | Should -Be 0 + $actual.presence_penalty | Should -Be 0 + $actual.stop | Should -BeNullOrEmpty + $actual.messages | Should -BeNullOrEmpty + } + + It 'Tests Get-ChatPayload has correct data after New-ChatUserMessage' { + New-ChatUserMessage -Content "Hello" + + $actual = Get-ChatPayload + + $actual.messages.count | Should -Be 1 + $actual.messages[0].role | Should -Be 'user' + $actual.messages[0].content | Should -Be 'Hello' + } + + It 'Tests Get-ChatPayload has correct data after New-ChatMessage' { + New-ChatMessage -Role 'user' -Content "Hello" + + $actual = Get-ChatPayload + + $actual.messages.count | Should -Be 1 + $actual.messages[0].role | Should -Be 'user' + $actual.messages[0].content | Should -Be 'Hello' + } + + It 'Tests Get-ChatPayload has correct data after New-ChatMessage and New-ChatUserMessage' { + New-ChatMessage -Role 'user' -Content "Hello" + New-ChatUserMessage -Content "Hello message 2" + + $actual = Get-ChatPayload + + $actual.messages.count | Should -Be 2 + $actual.messages[0].role | Should -Be 'user' + $actual.messages[0].content | Should -Be 'Hello' + $actual.messages[1].role | Should -Be 'user' + $actual.messages[1].content | Should -Be 'Hello message 2' + } + + It 'Tests Get-ChatPayload has correct data after New-ChatMessage and New-ChatUserMessage and Clear-ChatMessages' { + New-ChatMessage -Role 'user' -Content "Hello" + New-ChatUserMessage -Content "Hello message 2" + Clear-ChatMessages + + $actual = Get-ChatPayload + + $actual.messages.count | Should -Be 0 + } + + It 'Tests Get-ChatPayload has correct data after New-ChatMessage and New-ChatUserMessage and Clear-ChatMessages and New-ChatMessage' { + New-ChatMessage -Role 'user' -Content "Hello" + New-ChatUserMessage -Content "Hello message 2" + Clear-ChatMessages + New-ChatMessage -Role 'user' -Content "Hello" + + $actual = Get-ChatPayload + + $actual.messages.count | Should -Be 1 + $actual.messages[0].role | Should -Be 'user' + $actual.messages[0].content | Should -Be 'Hello' + } + + It 'Tests Get-ChatPayload after Get-GPT4Completion' { + Mock Invoke-RestMethodWithProgress -ModuleName PowerShellAI -ParameterFilter { + $Params.Method -eq 'Post' -and $Params.Uri -eq (Get-OpenAIChatCompletionUri) + } -MockWith { + [PSCustomObject]@{ + choices = @( + [PSCustomObject]@{ + message = [PSCustomObject]@{ + content = 'Mocked Get-GPT4Completion call' + } + } + ) + } + } + Get-GPT4Completion -Content "Hello World" + + $actual = Get-ChatPayload + + $actual.messages.count | Should -Be 2 + + $actual.messages[0].role | Should -Be 'user' + $actual.messages[0].content | Should -Be 'Hello World' + + $actual.messages[1].role | Should -Be 'assistant' + $actual.messages[1].content | Should -Be 'Mocked Get-GPT4Completion call' + } + + It 'Tests Get-ChatPayload as Json' { + Mock Invoke-RestMethodWithProgress -ModuleName PowerShellAI -ParameterFilter { + $Params.Method -eq 'Post' -and $Params.Uri -eq (Get-OpenAIChatCompletionUri) + } -MockWith { + [PSCustomObject]@{ + choices = @( + [PSCustomObject]@{ + message = [PSCustomObject]@{ + content = 'Mocked Get-GPT4Completion call' + } + } + ) + } + } + + Get-GPT4Completion -Content "Hello World" + + $actual = Get-ChatPayload -AsJson + $actual | Should -Not -BeNullOrEmpty + + $obj = ConvertFrom-Json $actual + + $obj.messages.count | Should -Be 2 + + $obj.messages[0].role | Should -Be 'user' + $obj.messages[0].content | Should -Be 'Hello World' + + $obj.messages[1].role | Should -Be 'assistant' + $obj.messages[1].content | Should -Be 'Mocked Get-GPT4Completion call' + } +} \ No newline at end of file diff --git a/__tests__/Get-ChatPersistence.tests.ps1 b/__tests__/Get-ChatPersistence.tests.ps1 new file mode 100644 index 0000000..6f5e8cc --- /dev/null +++ b/__tests__/Get-ChatPersistence.tests.ps1 @@ -0,0 +1,13 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe 'Get-ChatPersistence' -Tag ChatPersistence { + It 'tests the function Get-ChatPersistence exists' { + $actual = Get-Command Get-ChatPersistence -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'tests the function Get-ChatPersistence returns a boolean' { + $actual = Get-ChatPersistence + $actual.GetType().Name | Should -Be 'Boolean' + } +} diff --git a/__tests__/Get-ChatSessionOptions.tests.ps1 b/__tests__/Get-ChatSessionOptions.tests.ps1 new file mode 100644 index 0000000..d435d31 --- /dev/null +++ b/__tests__/Get-ChatSessionOptions.tests.ps1 @@ -0,0 +1,294 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe "ChatSessionOptions" -Tag ChatSessionOptions { + + AfterEach { + Reset-ChatSessionOptions + Reset-AzureOpenAIOptions + Set-ChatAPIProvider -Provider 'OpenAI' + } + + It "Test Get-ChatSessionOptions function exists" { + $actual = Get-Command Get-ChatSessionOptions -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Tests default Get-ChatSessionOptions' { + $actual = Get-ChatSessionOptions + + $actual | Should -Not -BeNullOrEmpty + + $actual.model | Should -BeExactly 'gpt-4' + $actual.temperature | Should -Be 0.0 + $actual.max_tokens | Should -Be 256 + $actual.top_p | Should -Be 1.0 + $actual.frequency_penalty | Should -Be 0 + $actual.presence_penalty | Should -Be 0 + $actual.stop | Should -BeNullOrEmpty + } + + It 'Test Set-ChatSessionOption exists' { + $actual = Get-Command Set-ChatSessionOption -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Set-ChatSessionOption model param has these set of values' { + $actual = Get-Command Set-ChatSessionOption -ErrorAction SilentlyContinue + + $values = $actual.Parameters['model'].Attributes.ValidValues + $values.Count | Should -Be 7 + + $values[0] | Should -BeExactly 'gpt-4' + $values[1] | Should -BeExactly 'gpt-3.5-turbo-1106' + $values[2] | Should -BeExactly 'gpt-4-1106-preview' + $values[3] | Should -BeExactly 'gpt-4-0613' + $values[4] | Should -BeExactly 'gpt-3.5-turbo' + $values[5] | Should -BeExactly 'gpt-3.5-turbo-16k' + $values[6] | Should -BeExactly 'gpt-3.5-turbo-0613' + } + + It 'Test Set-ChatSessionOption model' { + Set-ChatSessionOption -model 'gpt-4' + $actual = Get-ChatSessionOptions + + $actual | Should -Not -BeNullOrEmpty + + $actual.model | Should -BeExactly 'gpt-4' + $actual.temperature | Should -Be 0.0 + $actual.max_tokens | Should -Be 256 + $actual.top_p | Should -Be 1.0 + $actual.frequency_penalty | Should -Be 0 + $actual.presence_penalty | Should -Be 0 + $actual.stop | Should -BeNullOrEmpty + } + + It 'Test Set-ChatsessionOption max_tokens' { + Set-ChatSessionOption -max_tokens 512 + $actual = Get-ChatSessionOptions + + $actual | Should -Not -BeNullOrEmpty + + $actual.model | Should -BeExactly 'gpt-4' + $actual.temperature | Should -Be 0.0 + $actual.max_tokens | Should -Be 512 + $actual.top_p | Should -Be 1.0 + $actual.frequency_penalty | Should -Be 0 + $actual.presence_penalty | Should -Be 0 + $actual.stop | Should -BeNullOrEmpty + } + + It 'Test Set-ChatSessionOption temperature' { + Set-ChatSessionOption -temperature 0.5 + $actual = Get-ChatSessionOptions + + $actual | Should -Not -BeNullOrEmpty + + $actual.model | Should -BeExactly 'gpt-4' + $actual.temperature | Should -Be 0.5 + $actual.max_tokens | Should -Be 256 + $actual.top_p | Should -Be 1.0 + $actual.frequency_penalty | Should -Be 0 + $actual.presence_penalty | Should -Be 0 + $actual.stop | Should -BeNullOrEmpty + } + + It 'Test set-ChatSessionOption top_p' { + Set-ChatSessionOption -top_p 0.5 + $actual = Get-ChatSessionOptions + + $actual | Should -Not -BeNullOrEmpty + + $actual.model | Should -BeExactly 'gpt-4' + $actual.temperature | Should -Be 0.0 + $actual.max_tokens | Should -Be 256 + $actual.top_p | Should -Be 0.5 + $actual.frequency_penalty | Should -Be 0 + $actual.presence_penalty | Should -Be 0 + $actual.stop | Should -BeNullOrEmpty + } + + It 'Test Set-ChatSessionOption frequency_penalty' { + Set-ChatSessionOption -frequency_penalty 0.5 + $actual = Get-ChatSessionOptions + + $actual | Should -Not -BeNullOrEmpty + + $actual.model | Should -BeExactly 'gpt-4' + $actual.temperature | Should -Be 0.0 + $actual.max_tokens | Should -Be 256 + $actual.top_p | Should -Be 1.0 + $actual.frequency_penalty | Should -Be 0.5 + $actual.presence_penalty | Should -Be 0 + $actual.stop | Should -BeNullOrEmpty + } + + It 'Test Set-ChatSessionOption presence_penalty' { + Set-ChatSessionOption -presence_penalty 0.5 + $actual = Get-ChatSessionOptions + + $actual | Should -Not -BeNullOrEmpty + + $actual.model | Should -BeExactly 'gpt-4' + $actual.temperature | Should -Be 0.0 + $actual.max_tokens | Should -Be 256 + $actual.top_p | Should -Be 1.0 + $actual.frequency_penalty | Should -Be 0 + $actual.presence_penalty | Should -Be 0.5 + $actual.stop | Should -BeNullOrEmpty + } + + It 'Test Set-ChatSessionOption stop' { + Set-ChatSessionOption -stop '!' + $actual = Get-ChatSessionOptions + + $actual | Should -Not -BeNullOrEmpty + + $actual.model | Should -BeExactly 'gpt-4' + $actual.temperature | Should -Be 0.0 + $actual.max_tokens | Should -Be 256 + $actual.top_p | Should -Be 1.0 + $actual.frequency_penalty | Should -Be 0 + $actual.presence_penalty | Should -Be 0 + $actual.stop | Should -BeExactly '!' + } + + It 'Test Reset-ChatSessionOptions function exists' { + $actual = Get-Command Reset-ChatSessionOptions -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Reset-ChatSessionOptions' { + Reset-ChatSessionOptions + $actual = Get-ChatSessionOptions + + $actual | Should -Not -BeNullOrEmpty + + $actual.model | Should -BeExactly 'gpt-4' + $actual.temperature | Should -Be 0.0 + $actual.max_tokens | Should -Be 256 + $actual.top_p | Should -Be 1.0 + $actual.frequency_penalty | Should -Be 0 + $actual.presence_penalty | Should -Be 0 + $actual.stop | Should -BeNullOrEmpty + } + + It 'Test Get-AzureOpenAIOptions function exists' { + $actual = Get-Command Get-AzureOpenAIOptions -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Get-AzureOpenAIOptions' { + $actual = Get-AzureOpenAIOptions + + $actual | Should -Not -BeNullOrEmpty + $actual.Endpoint | Should -BeExactly 'not set' + $actual.DeploymentName | Should -BeExactly 'not set' + $actual.ApiVersion | Should -BeExactly 'not set' + } + + It 'Test Get-ChatAzureOpenAIURI function exists' { + $actual = Get-Command Get-ChatAzureOpenAIURI -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Set-AzureOpenAIOptions function exists' { + $actual = Get-Command Set-AzureOpenAIOptions -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Set-AzureOpenAIOptions' { + Set-AzureOpenAIOptions -Endpoint 'https://westus.api.cognitive.microsoft.com' -DeploymentName 'openai' -ApiVersion '2021-05-01' + $actual = Get-AzureOpenAIOptions + + $actual | Should -Not -BeNullOrEmpty + $actual.Endpoint | Should -BeExactly 'https://westus.api.cognitive.microsoft.com' + $actual.DeploymentName | Should -BeExactly 'openai' + $actual.ApiVersion | Should -BeExactly '2021-05-01' + } + + It 'Test Get-ChatAzureOpenAIURI' { + Set-AzureOpenAIOptions -Endpoint 'https://westus.api.cognitive.microsoft.com' -DeploymentName 'openai' -ApiVersion '2021-05-01' + + $actual = Get-ChatAzureOpenAIURI + + $actual | Should -Not -BeNullOrEmpty + + $actual | Should -BeExactly 'https://westus.api.cognitive.microsoft.com/openai/deployments/openai/chat/completions?api-version=2021-05-01' + } + + It 'Test Reset-AzureOpenAIOptions function exists' { + $actual = Get-Command Reset-AzureOpenAIOptions -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Reset-AzureOpenAIOptions' { + Set-AzureOpenAIOptions -Endpoint 'https://westus.api.cognitive.microsoft.com' -DeploymentName 'openai' -ApiVersion '2021-05-01' + + $actual = Get-AzureOpenAIOptions + + $actual | Should -Not -BeNullOrEmpty + $actual.Endpoint | Should -BeExactly 'https://westus.api.cognitive.microsoft.com' + $actual.DeploymentName | Should -BeExactly 'openai' + $actual.ApiVersion | Should -BeExactly '2021-05-01' + + Reset-AzureOpenAIOptions + + $actual = Get-AzureOpenAIOptions + + $actual | Should -Not -BeNullOrEmpty + $actual.Endpoint | Should -BeExactly 'not set' + $actual.DeploymentName | Should -BeExactly 'not set' + $actual.ApiVersion | Should -BeExactly 'not set' + } + + It 'Test Get-ChatAzureOpenAIURI throws if Endpoint is not set' { + Set-AzureOpenAIOptions -DeploymentName 'openai' -ApiVersion '2021-05-01' + + { Get-ChatAzureOpenAIURI } | Should -Throw -ExpectedMessage 'Azure Open AI Endpoint not set' + } + + It 'Test Get-ChatAzureOpenAIURI throws if DeploymentName is not set' { + Set-AzureOpenAIOptions -Endpoint 'https://westus.api.cognitive.microsoft.com' -ApiVersion '2021-05-01' + + { Get-ChatAzureOpenAIURI } | Should -Throw -ExpectedMessage 'Azure Open AI DeploymentName not set' + } + + It 'Test Get-ChatAzureOpenAIURI throws if ApiVersion is not set' { + Set-AzureOpenAIOptions -Endpoint 'https://westus.api.cognitive.microsoft.com' -DeploymentName 'openai' + + { Get-ChatAzureOpenAIURI } | Should -Throw -ExpectedMessage 'Azure Open AI ApiVersion not set' + } + + It 'Test Set-ChatAPIProvider function exists' { + $actual = Get-Command Set-ChatAPIProvider -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Get-ChatAPIProvider function exists' { + $actual = Get-Command Get-ChatAPIProvider -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Get-ChatAPIProvider returns OpenAI by default' { + $actual = Get-ChatAPIProvider + + $actual | Should -Not -BeNullOrEmpty + $actual | Should -BeExactly 'OpenAI' + } + + It 'Test Set-ChatAPIProvider to AzureOpenAI' { + Set-ChatAPIProvider -Provider AzureOpenAI + $actual = Get-ChatAPIProvider + + $actual | Should -Not -BeNullOrEmpty + $actual | Should -BeExactly 'AzureOpenAI' + } + + It 'Test Set-ChatAPIProvider to OpenAI' { + Set-ChatAPIProvider -Provider OpenAI + $actual = Get-ChatAPIProvider + + $actual | Should -Not -BeNullOrEmpty + $actual | Should -BeExactly 'OpenAI' + } +} \ No newline at end of file diff --git a/__tests__/Get-CompletionFromMessages.tests.ps1 b/__tests__/Get-CompletionFromMessages.tests.ps1 new file mode 100644 index 0000000..0a50d12 --- /dev/null +++ b/__tests__/Get-CompletionFromMessages.tests.ps1 @@ -0,0 +1,47 @@ +Import-Module $PSScriptRoot\..\PowerShellAI.psd1 -Force + +Describe "Get-CompletionFromMessages" -Tag "Get-CompletionFromMessages" { + BeforeAll { + $script:savedKey = $env:OpenAIKey + $env:OpenAIKey = 'sk-1234567890' + } + + AfterAll { + $env:OpenAIKey = $savedKey + } + + It "tests the function Get-CompletionFromMessages exists" { + $actual = Get-Command Get-CompletionFromMessages -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It "tests Get-CompletionFromMessages has a parameter named Messages" { + $actual = Get-Command Get-CompletionFromMessages -ErrorAction SilentlyContinue + $actual.Parameters.Keys | Should -Contain Messages + } + + It "tests Get-CompletionFromMessages returns a response" { + Mock Invoke-RestMethodWithProgress -ModuleName PowerShellAI -ParameterFilter { + $Params.Method -eq 'Post' -and $Params.Uri -eq (Get-OpenAIChatCompletionUri) + } -MockWith { + [PSCustomObject]@{ + choices = @( + [PSCustomObject]@{ + message = [PSCustomObject]@{ + content = 'Mocked Get-GPT4Completion call' + } + } + ) + } + } + + $messages = $( + New-ChatMessageTemplate -Role system "I am a bot" + New-ChatMessageTemplate -Role user "Hello" + ) + + $actual = Get-CompletionFromMessages -Messages $messages + + $actual.content | Should -BeExactly "Mocked Get-GPT4Completion call" + } +} \ No newline at end of file diff --git a/__tests__/Get-GPT4Completion.tests.ps1 b/__tests__/Get-GPT4Completion.tests.ps1 new file mode 100644 index 0000000..d9a29b5 --- /dev/null +++ b/__tests__/Get-GPT4Completion.tests.ps1 @@ -0,0 +1,322 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe "Get-GPT4Completion" -Tag GPT4Completion { + + BeforeAll { + $script:savedKey = $env:OpenAIKey + $env:OpenAIKey = 'sk-1234567890' + Set-chatSessionPath -Path 'TestDrive:\PowerShell\ChatGPT' + + Mock Invoke-RestMethodWithProgress -ModuleName PowerShellAI -ParameterFilter { + $Params.Method -eq 'Post' -and $Params.Uri -eq (Get-OpenAIChatCompletionUri) + } -MockWith { + [PSCustomObject]@{ + choices = @( + [PSCustomObject]@{ + message = [PSCustomObject]@{ + content = 'Mocked Get-GPT4Completion call' + } + } + ) + } + } + } + + BeforeEach { + Stop-Chat + Clear-ChatMessages + Get-ChatSessionPath | Get-ChildItem -ErrorAction SilentlyContinue | Remove-Item -Force + } + + AfterAll { + $env:OpenAIKey = $savedKey + Stop-Chat + } + + It 'Test if chat is in progress initially' { + $actual = Test-ChatInProgress + $actual | Should -BeFalse + } + + It "Test Get-GPT4Completion function exists" { + $actual = Get-Command Get-GPT4Completion -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Test-ChatInProgress function exists' { + $actual = Get-Command Test-ChatInProgress -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Stop-Chat function exists' { + $actual = Get-Command Stop-Chat -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It "Test chat alias exists" { + $actual = Get-Alias chat -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + $actual.Definition | Should -Be Get-GPT4Completion + } + + It 'Test Add-ChatMessage function exists' { + $actual = Get-Command Add-ChatMessage -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test New-ChatMessageTemplate function exists' { + $actual = Get-Command New-ChatMessageTemplate -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test if chat is in progress after message' { + $null = Get-GPT4Completion 'test' + + $actual = Test-ChatInProgress + $actual | Should -BeTrue + } + + It 'Test if chat is in progress after New-Chat' { + $null = New-Chat + + $actual = Test-ChatInProgress + $actual | Should -BeTrue + } + + It 'Test if chat is in progress after New-ChatMessage and then New-Chat' { + $null = New-ChatMessage -Role user -Content 'test' + + $actual = Test-ChatInProgress + $actual | Should -BeTrue + + New-Chat + + $actual = Test-ChatInProgress + $actual | Should -BeTrue + } + + It 'Test New-ChatMessageTemplate function exists' { + $actual = Get-Command New-ChatMessageTemplate -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test New-ChatMessageTemplate has these parameters' { + $actual = Get-Command New-ChatMessageTemplate + + $keys = $actual.Parameters.keys + + $keys.Contains("Role") | Should -BeTrue + $keys.Contains("Content") | Should -BeTrue + $keys.Contains("Name") | Should -BeTrue + } + + It 'Test New-ChatMessageTemplate Role paramater has this set' { + $actual = Get-Command New-ChatMessageTemplate + + $validateSet = $actual.Parameters.Role.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $validateSet | Should -Not -BeNullOrEmpty + $validateSet.ValidValues.Count | Should -Be 4 + + $validateSet.ValidValues[0] | Should -BeExactly 'user' + $validateSet.ValidValues[1] | Should -BeExactly 'system' + $validateSet.ValidValues[2] | Should -BeExactly 'assistant' + $validateSet.ValidValues[3] | Should -BeExactly 'function' + } + + It 'Test New-ChatMessageTemplate throws when role=function and name=null' { + {New-ChatMessageTemplate -Role function} | Should -Throw "Name is required if role is function" + } + + It 'Test New-ChatMessageTemplate role is function and name provided' { + $actual = New-ChatMessageTemplate -Role function -Content 'It is hot!' -Name 'Get-Weather' + + $actual | Should -Not -BeNullOrEmpty + $actual.role | Should -Be 'function' + $actual.name | Should -BeExactly 'Get-Weather' + $actual.content | Should -BeExactly 'It is hot!' + } + + It 'Test if Add-ChatMessage has these parameters' { + $actual = Get-Command Add-ChatMessage + + $keys = $actual.Parameters.keys + $keys.Contains("Message") | Should -BeTrue + } + + It "Tests Get-GPT4Completion has these parameters" { + $actual = Get-Command Get-GPT4Completion -ErrorAction SilentlyContinue + + $keys = $actual.Parameters.keys + + $keys.Contains("Content") | Should -BeTrue + } + + It 'Test Add-ChatMessage adds message' { + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'system' + content = 'system test' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'user' + content = 'user test' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'assistant' + content = 'assistant test' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'function' + content = 'function test' + }) + + $actual = Get-ChatMessages + $actual.Count | Should -Be 4 + + $actual[0].role | Should -Be 'system' + $actual[0].content | Should -Be 'system test' + + $actual[1].role | Should -Be 'user' + $actual[1].content | Should -Be 'user test' + + $actual[2].role | Should -Be 'assistant' + $actual[2].content | Should -Be 'assistant test' + + $actual[3].role | Should -Be 'function' + $actual[3].content | Should -Be 'function test' + } + + It 'Test New-ChatMessageTemplate creates and populates template' { + $actual = New-ChatMessageTemplate -Role user -Content 'test' + + $actual.role | Should -Be 'user' + $actual.content | Should -Be 'test' + } + + It 'Test New-ChatMessageTemplate creates empty template' { + $actual = New-ChatMessageTemplate + + $actual.role | Should -BeNullOrEmpty + $actual.content | Should -BeNullOrEmpty + } + + It 'Test if Stop-Chat stops chat and resets messages' { + $null = New-Chat 'test' + + $actual = Test-ChatInProgress + $actual | Should -BeTrue + + Stop-Chat + + $actual = Test-ChatInProgress + $actual | Should -BeFalse + + (Get-ChatMessages).Count | Should -Be 0 + } + + It 'Test message is added via New-Chat' { + $actual = New-Chat 'test system message' + + $actual | Should -BeExactly 'Mocked Get-GPT4Completion call' + + $messages = Get-ChatMessages + $messages.Count | Should -Be 2 + + $messages[0].role | Should -BeExactly 'system' + $messages[0].content | Should -BeExactly 'test system message' + + $messages[1].role | Should -BeExactly 'assistant' + $messages[1].content | Should -BeExactly 'Mocked Get-GPT4Completion call' + } + + It 'Test message is added via chat' { + $actual = Get-GPT4Completion 'test user message' + + $actual | Should -BeExactly 'Mocked Get-GPT4Completion call' + + $messages = Get-ChatMessages + $messages.Count | Should -Be 2 + + $messages[0].role | Should -BeExactly 'user' + $messages[0].content | Should -BeExactly 'test user message' + + $messages[1].role | Should -BeExactly 'assistant' + $messages[1].content | Should -BeExactly 'Mocked Get-GPT4Completion call' + } + + It 'Test message is added via New-Chat and Test-ChatInProgress' { + Test-ChatInProgress | Should -BeFalse + + $actual = New-Chat 'test system message' + + $actual | Should -BeExactly 'Mocked Get-GPT4Completion call' + + Test-ChatInProgress | Should -BeTrue + + Stop-Chat + Test-ChatinProgress | Should -BeFalse + } + + It 'Test message is added via chat and Test-ChatInProgress' { + Test-ChatInProgress | Should -BeFalse + + $actual = Get-GPT4Completion 'test user message' + + $actual | Should -BeExactly 'Mocked Get-GPT4Completion call' + + Test-ChatInProgress | Should -BeTrue + + Stop-Chat + Test-ChatinProgress | Should -BeFalse + } + + It 'Test system message is added via New-Chat and Export works' { + $actual = New-Chat 'test system message' + + $actual | Should -BeExactly 'Mocked Get-GPT4Completion call' + + $sessions = Get-ChatSession + $sessions.Count | Should -Be 1 + + $content = $sessions | Get-ChatSessionContent + $content.Count | Should -Be 2 + + $content[0].role | Should -BeExactly 'system' + $content[0].content | Should -BeExactly 'test system message' + + $content[1].role | Should -BeExactly 'assistant' + $content[1].content | Should -BeExactly 'Mocked Get-GPT4Completion call' + } + + It 'Test user message is added via chat and Export works' { + $actual = Get-GPT4Completion 'test user message' + + $actual | Should -BeExactly 'Mocked Get-GPT4Completion call' + + $sessions = Get-ChatSession + $sessions.Count | Should -Be 1 + + $content = $sessions | Get-ChatSessionContent + $content.Count | Should -Be 2 + + $content[0].role | Should -BeExactly 'user' + $content[0].content | Should -BeExactly 'test user message' + + $content[1].role | Should -BeExactly 'assistant' + $content[1].content | Should -BeExactly 'Mocked Get-GPT4Completion call' + } + + It 'Test temperature is set calling chat' { + $actual = Get-GPT4Completion 'test user message' -temperature 1 + + (Get-ChatSessionOptions)['temperature'] | Should -Be 1 + $actual | Should -BeExactly 'Mocked Get-GPT4Completion call' + + Reset-ChatSessionOptions + + (Get-ChatSessionOptions)['temperature'] | Should -Be 0.0 + } +} \ No newline at end of file diff --git a/__tests__/Get-GPT4Response.tests.ps1 b/__tests__/Get-GPT4Response.tests.ps1 new file mode 100644 index 0000000..f4d6c26 --- /dev/null +++ b/__tests__/Get-GPT4Response.tests.ps1 @@ -0,0 +1,22 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe 'Get-GPT4Response' -Tag GPT4Response { + It 'tests the function exists' { + $actual = Get-Command Get-GPT4Response -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'test the function has these parameters' { + $actual = Get-Command Get-GPT4Response -ErrorAction SilentlyContinue + + $keys = $actual.Parameters.keys + + $keys.Contains("temperature") | Should -BeTrue + } + + <# + TODO: not all Azure OpenAI models support conversational mode. + Invoke-OpenAIAPI currently for this method name and allows it to be called, otherwise it would throw an error. + Needs to be separated out so it can be unit tested. + #> +} \ No newline at end of file diff --git a/__tests__/Get-LocalOpenAIKey.tests.ps1 b/__tests__/Get-LocalOpenAIKey.tests.ps1 new file mode 100644 index 0000000..119f5a4 --- /dev/null +++ b/__tests__/Get-LocalOpenAIKey.tests.ps1 @@ -0,0 +1,70 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification='BeforeAll block variables are used in tests')] +param() + +Remove-Module 'PowerShellAI' -Force -ErrorAction Ignore +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe "Get-LocalOpenAIKey" -Tag 'GetLocalOpenAIKey' { + InModuleScope 'PowerShellAI' { + BeforeAll { + #Backup existing OpenAIKey + $backupOpenAIKey = $env:OpenAIKey + + $secureStringKey = 'OpenAIKeySecureString' + $environmentVariableKey = 'OpenAIKeyEnvironmentVariable' + } + + AfterEach { + #After each test reset module scope secure string with fake OpenAI key, + #and $env:OpenAIKey with plain text fake OpenAI key + $Script:OpenAIKey, $env:OpenAIKey = $null + } + + It 'Should return value of type [String] when $env:OpenAIKey is set' { + $env:OpenAIKey = $environmentVariableKey + Get-LocalOpenAIKey | Should -BeOfType 'System.String' + } + + It 'Should return the same value as set in $env:OpenAIKey' { + $env:OpenAIKey = $environmentVariableKey + Get-LocalOpenAIKey | Should -BeExactly $env:OpenAIKey + } + + if ($PSVersionTable.PSVersion.Major -lt 6) { + It 'Should return value of type [String] set with Set-OpenAIKey on PowerShell 5 and lower' { + Set-OpenAIKey -Key (ConvertTo-SecureString -String $secureStringKey -AsPlainText -Force) + Get-LocalOpenAIKey | Should -BeOfType 'System.String' + } + + It 'Should return the same value as set with Set-OpenAIKey on PowerShell 5 and lower' { + Set-OpenAIKey -Key (ConvertTo-SecureString -String $secureStringKey -AsPlainText -Force) + Get-LocalOpenAIKey | Should -BeExactly $secureStringKey + } + } else { + It 'Should return value of type [SecureString] set with Set-OpenAIKey on PowerShell 6 and higher' { + Set-OpenAIKey -Key (ConvertTo-SecureString -String $secureStringKey -AsPlainText -Force) + Get-LocalOpenAIKey | Should -BeOfType 'System.Security.SecureString' + } + + It 'Should return the same value as set with Set-OpenAIKey on PowerShell 6 and higher' { + Set-OpenAIKey -Key (ConvertTo-SecureString -String $secureStringKey -AsPlainText -Force) + Get-LocalOpenAIKey | ConvertFrom-SecureString -AsPlainText | Should -BeExactly $secureStringKey + } + } + + It 'OpenAI key set with Set-OpenAIKey has priority over $env:OpenAIKey' { + $env:OpenAIKey = $environmentVariableKey + Set-OpenAIKey -Key (ConvertTo-SecureString -String $secureStringKey -AsPlainText -Force) + if ($PSVersionTable.PSVersion.Major -gt 5) { + Get-LocalOpenAIKey | ConvertFrom-SecureString -AsPlainText | Should -BeExactly $secureStringKey + } else { + Get-LocalOpenAIKey | Should -BeExactly $secureStringKey + } + } + + AfterAll { + #Restore OpenAIKey + $env:OpenAIKey = $backupOpenAIKey + } + } +} diff --git a/__tests__/Get-OpenAIEmbeddings.tests.ps1 b/__tests__/Get-OpenAIEmbeddings.tests.ps1 new file mode 100644 index 0000000..e9f6acd --- /dev/null +++ b/__tests__/Get-OpenAIEmbeddings.tests.ps1 @@ -0,0 +1,17 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe "Get-OpenAIEmbeddings" -Tag 'Get-OpenAIEmbeddings' { + It "Test Get-OpenAIEmbeddings function exists" { + $actual = Get-Command Get-OpenAIEmbeddings -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Get-OpenAIEmbeddings has these parameters' { + $actual = Get-Command Get-OpenAIEmbeddings -ErrorAction SilentlyContinue + + $keys = $actual.Parameters.Keys + + $keys.Contains('Content') | Should -Be $true + $keys.Contains('Raw') | Should -Be $true + } +} diff --git a/__tests__/Get-OpenAIKey.tests.ps1 b/__tests__/Get-OpenAIKey.tests.ps1 deleted file mode 100644 index 121fbfc..0000000 --- a/__tests__/Get-OpenAIKey.tests.ps1 +++ /dev/null @@ -1,60 +0,0 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification='$secureStringKey and $environmentVariableKey variables are used in tests')] -param() - -Describe "Get-OpenAIKey" -Tag 'GetOpenAIKey' { - BeforeEach { - Remove-Module 'PowerShellAI' -Force - Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force - } - - AfterEach { - $env:OpenAIKey = $null - } - - InModuleScope 'PowerShellAI' { - BeforeAll { - $secureStringKey = 'OpenAIKeySecureString' - $environmentVariableKey = 'OpenAIKeyEnvironmentVariable' - } - - It 'Should return value of type [String] when $env:OpenAIKey is set' { - $env:OpenAIKey = $environmentVariableKey - Get-OpenAIKey | Should -BeOfType 'System.String' - } - - It 'Should return the same value as set in $env:OpenAIKey' { - $env:OpenAIKey = $environmentVariableKey - Get-OpenAIKey | Should -BeExactly $env:OpenAIKey - } - - It 'Should return value of type [String] on PowerShell 5 and lower' -Skip:($PSVersionTable.PSVersion.Major -gt 5) { - Set-OpenAIKey -Key (ConvertTo-SecureString -String $secureStringKey -AsPlainText -Force) - Get-OpenAIKey | Should -BeOfType 'System.String' - } - - It 'Should return the same value as set with Set-OpenAIKey on PowerShell 5 and lower' -Skip:($PSVersionTable.PSVersion.Major -gt 5) { - Set-OpenAIKey -Key (ConvertTo-SecureString -String $secureStringKey -AsPlainText -Force) - Get-OpenAIKey | Should -BeExactly $secureStringKey - } - - It 'Should return value of type [SecureString] on PowerShell 6 and higher' -Skip:($PSVersionTable.PSVersion.Major -lt 6) { - Set-OpenAIKey -Key (ConvertTo-SecureString -String $secureStringKey -AsPlainText -Force) - Get-OpenAIKey | Should -BeOfType 'System.Security.SecureString' - } - - It 'Should return the same value as set with Set-OpenAIKey on PowerShell 6 and higher' -Skip:($PSVersionTable.PSVersion.Major -lt 6) { - Set-OpenAIKey -Key (ConvertTo-SecureString -String $secureStringKey -AsPlainText -Force) - Get-OpenAIKey | ConvertFrom-SecureString -AsPlainText | Should -BeExactly $secureStringKey - } - - It 'OpenAI key configured with Set-OpenAIKey has priority over $env:OpenAIKey' { - $env:OpenAIKey = $environmentVariableKey - Set-OpenAIKey -Key (ConvertTo-SecureString -String $secureStringKey -AsPlainText -Force) - if ($PSVersionTable.PSVersion.Major -gt 5) { - Get-OpenAIKey | ConvertFrom-SecureString -AsPlainText | Should -BeExactly $secureStringKey - } else { - Get-OpenAIKey | Should -BeExactly $secureStringKey - } - } - } -} diff --git a/__tests__/Invoke-AIExplain.tests.ps1 b/__tests__/Invoke-AIExplain.tests.ps1 new file mode 100644 index 0000000..e0e69c4 --- /dev/null +++ b/__tests__/Invoke-AIExplain.tests.ps1 @@ -0,0 +1,20 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe "Invoke-AIExplain" -Tag 'Invoke-AIExplain' { + It "Test Invoke-AIExplain function exists" { + $actual = Get-Command Invoke-AIExplain -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It "Test explain alias exists" { + $actual = Get-Alias explain -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It "Should have these parameters" { + $actual = Get-Command Invoke-AIExplain -ErrorAction SilentlyContinue + + $actual.Parameters.keys.Contains("Id") | Should -BeTrue + $actual.Parameters.keys.Contains("Value") | Should -BeTrue + } +} diff --git a/__tests__/Invoke-AIFunctionBuilder.tests.ps1 b/__tests__/Invoke-AIFunctionBuilder.tests.ps1 new file mode 100644 index 0000000..3d4acec --- /dev/null +++ b/__tests__/Invoke-AIFunctionBuilder.tests.ps1 @@ -0,0 +1,68 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe "Invoke-AIFunctionBuilder" -Tag 'Invoke-AIFunctionBuilder' { + It "Test Invoke-AIFunctionBuilder function exists" { + $actual = Get-Command Invoke-AIFunctionBuilder -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It "Test ifb alias exists" { + $actual = Get-Alias ifb -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It "Invoke-AIFunctionBuilder function dependencies support expected parameters" { + $dependencies = @( + @{ + Function = "Get-GPT3Completion" + Parameters = @("prompt", "max_tokens") + Description = "This function is used for quick completions that don't require chat context" + }, + @{ + Function = "Stop-Chat" + Parameters = @() + Description = "This function is used at the end to clear the current chat history" + } + ) + + foreach($dependency in $dependencies) { + $function = Get-Command $dependency.Function -ErrorAction SilentlyContinue + $function | Should -Not -BeNullOrEmpty + foreach($parameter in $dependency.Parameters) { + $function.Parameters.Keys | Should -Contain $parameter + } + } + } + + It "FunctionBuilderCore function dependencies support expected parameters" { + $dependencies = @( + @{ + Function = "Set-ChatSessionOption" + Parameters = @("model", "max_tokens", "temperature") + Description = "This function is used to setup the model parameters for Get-GPT4Completion, each system can use different settings e.g a code editing system uses lower temp than one that is responsible for creating the initial code solution" + }, + @{ + Function = "New-Chat" + Parameters = @("Content") + Description = "This function is used to setup the system prompt for Get-GPT4Completion" + }, + @{ + Function = "Get-GPT4Completion" + Parameters = @("Content", "ErrorAction") + Description = "This function is used for enhanced code completions that require chat context or a system prompt" + }, + @{ + Function = "Get-ChatMessages" + Parameters = @() + Description = "This is used when the function builder doesn't output something sensible, the chat is dumped to help debug issues" + } + ) + foreach($dependency in $dependencies) { + $function = Get-Command $dependency.Function -ErrorAction SilentlyContinue + $function | Should -Not -BeNullOrEmpty + foreach($parameter in $dependency.Parameters) { + $function.Parameters.Keys | Should -Contain $parameter + } + } + } +} diff --git a/__tests__/Invoke-RestMethodWithProgress.tests.ps1 b/__tests__/Invoke-RestMethodWithProgress.tests.ps1 new file mode 100644 index 0000000..6b03c06 --- /dev/null +++ b/__tests__/Invoke-RestMethodWithProgress.tests.ps1 @@ -0,0 +1,203 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe "Invoke-RestMethodWithProgress" -Tag InvokeRestMethodWithProgress { + + InModuleScope 'PowerShellAI' { + + $script:StartJobCommand = Get-Command "Start-Job" + + BeforeEach { + Reset-APIEstimatedResponseTimes + Push-Location -StackName "MOCK-IRMWP" -Path $PSScriptRoot + New-PSDrive -Name "MOCK-IRMWP" -PSProvider "FileSystem" -Root $PSScriptRoot + } + + AfterEach { + Pop-Location -StackName "MOCK-IRMWP" -ErrorAction "SilentlyContinue" + Remove-PSDrive -Name "MOCK-IRMWP" -ErrorAction "SilentlyContinue" + } + + It "should return the known response if the API call is successful" { + + Mock Start-Job { + return & $script:StartJobCommand { } + } + + Mock Receive-Job { + return @{ + Response = "a happy web response from a web server" + } + } + + $params = @{ + "Method" = "GET"; + "Uri" = "http://localhost"; + } + + $response = Invoke-RestMethodWithProgress -Params $params + $response | Should -BeExactly "a happy web response from a web server" + } + + It "should work when run from a psdrive" { + + Mock Start-Job { + return & $script:StartJobCommand { } + } + + Mock Receive-Job { + return @{ + Response = "a happy web response from a web server" + } + } + + $params = @{ + "Method" = "GET"; + "Uri" = "http://localhost"; + } + + Set-Location "MOCK-IRMWP:\" + + $response = Invoke-RestMethodWithProgress -Params $params + $response | Should -BeExactly "a happy web response from a web server" + } + + It "should work when run from a non-filesystem provider" { + + Mock Invoke-RestMethod { + return "a happy web response from a web server" + } + + Mock Start-Job { } + + $params = @{ + "Method" = "GET"; + "Uri" = "http://localhost"; + } + + Push-Location -StackName "MOCK-IRMWP" + Set-Location "Env:\" + + $response = Invoke-RestMethodWithProgress -Params $params + $response | Should -BeExactly "a happy web response from a web server" + Should -Invoke -CommandName Invoke-RestMethod -Times 1 + Should -Invoke -CommandName Start-Job -Times 0 + } + + It "should throw if the API call fails" { + + $params = @{ + "Method" = "GET" + "Uri" = "yeet://the.yeet.scheme.is.not.supported" + } + + Mock Start-Job { + return & $script:StartJobCommand { + Invoke-RestMethod $using:params.Uri + } + } + + $errorRecord = { Invoke-RestMethodWithProgress -Params $params } | Should -Throw -PassThru + $errorRecord.Exception.Message | Should -BeLike "*'yeet' scheme is not supported*" + } + + It "should not use a background job in unsupported hosts" { + Mock Get-Host { + return @{ + Name = "UnitTestHost" + } + } + + Mock Invoke-RestMethod { + return "a happy web response from a web server" + } + + Mock Start-Job { } + + $params = @{ + "Method" = "GET"; + "Uri" = "http://localhost"; + } + + $response = Invoke-RestMethodWithProgress -Params $params + $response | Should -BeExactly "a happy web response from a web server" + + Should -Invoke -CommandName Invoke-RestMethod -Times 1 + Should -Invoke -CommandName Start-Job -Times 0 + } + + It "should not use a background job if a proxy is configured by env var" { + Mock Get-Host { + return @{ + Name = "ConsoleHost" + } + } + + Mock Invoke-RestMethod { + return "a happy web response from a web server" + } + + Mock Start-Job { } + + $params = @{ + "Method" = "GET"; + "Uri" = "http://localhost"; + } + + $previousProxySettings = $env:HTTP_PROXY + $env:HTTP_PROXY = "http://example.com:3128" + $response = Invoke-RestMethodWithProgress -Params $params + $env:HTTP_PROXY = $previousProxySettings + + $response | Should -BeExactly "a happy web response from a web server" + + Should -Invoke -CommandName Invoke-RestMethod -Times 1 + Should -Invoke -CommandName Start-Job -Times 0 + } + + It "should not use a background job if a defaultproxy is configured for the current session" { + Mock Get-Host { + return @{ + Name = "ConsoleHost" + } + } + + Mock Invoke-RestMethod { + return "a happy web response from a web server" + } + + Mock Start-Job { } + + $params = @{ + "Method" = "GET"; + "Uri" = "http://localhost"; + } + + $previousProxySettings = [System.Net.WebRequest]::DefaultWebProxy + $proxyUri = New-Object System.Uri("http://example.com:3128") + $proxy = New-Object System.Net.WebProxy($proxyUri) + [System.Net.WebRequest]::DefaultWebProxy = $proxy + $response = Invoke-RestMethodWithProgress -Params $params + [System.Net.WebRequest]::DefaultWebProxy = $previousProxySettings + + $response | Should -BeExactly "a happy web response from a web server" + + Should -Invoke -CommandName Invoke-RestMethod -Times 1 + Should -Invoke -CommandName Start-Job -Times 0 + } + + Describe "Get-APIEstimatedResponseTime Tests" { + It "should return default response time when there's no record for a specific endpoint" { + $responseTime = Get-APIEstimatedResponseTime -Method "POST" -Uri "http://localhost/new-endpoint" + $responseTime | Should -Be 10 + } + } + + Describe "Set-APIResponseTime Tests" { + It "should record response time for a specific endpoint" { + Set-APIResponseTime -Method "PUT" -Uri "http://localhost/new-endpoint" -ResponseTimeSeconds 30 + $responseTime = Get-APIEstimatedResponseTime -Method "PUT" -Uri "http://localhost/new-endpoint" + $responseTime | Should -Be 30 + } + } + } +} \ No newline at end of file diff --git a/__tests__/NotebookCopilot.tests.ps1 b/__tests__/NotebookCopilot.tests.ps1 new file mode 100644 index 0000000..85fcb51 --- /dev/null +++ b/__tests__/NotebookCopilot.tests.ps1 @@ -0,0 +1,12 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe 'Notebook Copilot' -Tag NotebookCopilot { + It 'Test Notebook Copilot function exists' { + $actual = Get-Command NBCopilot -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test NBCopilot throws when not in a notebook' { + {NBCopilot 'test'} | Should -Throw 'This can only be used in a Polyglot Interactive Notebook' + } +} \ No newline at end of file diff --git a/__tests__/OpenAIUri.tests.ps1 b/__tests__/OpenAIUri.tests.ps1 index d3deb71..61ba22a 100644 --- a/__tests__/OpenAIUri.tests.ps1 +++ b/__tests__/OpenAIUri.tests.ps1 @@ -36,4 +36,16 @@ Describe "OpenAIUri" -Tag 'OpenAIUri' { $actual | Should -Be 'https://api.openai.com/v1/edits' } + + It "Should return the OpenAI Chat URI" { + $actual = Get-OpenAIChatCompletionUri + + $actual | Should -Be 'https://api.openai.com/v1/chat/completions' + } + + It "Should return the OpenAI Embedding URI" { + $actual = Get-OpenAIEmbeddingsUri + + $actual | Should -Be 'https://api.openai.com/v1/embeddings' + } } \ No newline at end of file diff --git a/__tests__/SessionManagement.tests.ps1 b/__tests__/SessionManagement.tests.ps1 new file mode 100644 index 0000000..9cb1f8c --- /dev/null +++ b/__tests__/SessionManagement.tests.ps1 @@ -0,0 +1,422 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe "Session Management" -Tag SessionManagement { + BeforeAll { + function Test-ChatSessionTimeStamp { + param( + [Parameter(Mandatory)] + [string]$expected + ) + + ($expected.Length -eq 14) -and ($expected -match "^[0-9]+$") + } + } + + AfterEach { + Reset-ChatSessionPath + Reset-ChatSessionOptions + Clear-ChatMessages + } + + AfterAll { + Clear-ChatMessages + } + + It 'Test Get-ChatSessionTimeStamp function exists' { + $actual = Get-Command Get-ChatSessionTimeStamp -ErrorAction SilentlyContinue + + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Reset-ChatSessionTimeStamp function exists' { + $actual = Get-Command Reset-ChatSessionTimeStamp -ErrorAction SilentlyContinue + + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Get-ChatSessionPath function exists' { + $actual = Get-Command Get-ChatSessionPath -ErrorAction SilentlyContinue + + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Set-ChatSessionPath function exists' { + $actual = Get-Command Set-ChatSessionPath -ErrorAction SilentlyContinue + + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Get-ChatSessionFile function exists' { + $actual = Get-Command Get-ChatSessionFile -ErrorAction SilentlyContinue + + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Export-ChatSession function exists' { + $actual = Get-Command Export-ChatSession -ErrorAction SilentlyContinue + + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Reset-ChatSessionPath function exists' { + $actual = Get-Command Reset-ChatSessionPath -ErrorAction SilentlyContinue + + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Get-ChatSessionContent function exists' { + $actual = Get-Command Get-ChatSessionContent -ErrorAction SilentlyContinue + + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Get-ChatSessionTimeStamp returns a string in the correct format' { + $actual = Get-ChatSessionTimeStamp + + Test-ChatSessionTimeStamp $actual | Should -BeTrue + } + + It 'Test Reset-ChatSessionTimeStamp resets the session timestamp' { + $expected = Get-ChatSessionTimeStamp + Reset-ChatSessionTimeStamp + + # need to wait a second to ensure the timestamp is different + Start-Sleep 1 + $actual = Get-ChatSessionTimeStamp + + $actual | Should -Not -Be $expected + } + + It 'Test Get-ChatSessionPath returns correct path for Windows' { + $actual = Get-ChatSessionPath + + if ($IsWindows -or $null -eq $IsWindows) { + $actual | Should -BeExactly "$env:APPDATA\PowerShellAI\ChatGPT" + } + } + + # It 'Test Get-ChatSessionPath returns correct path for Linux' -Skip { + # $actual = Get-ChatSessionPath + # $actual | Should -Be ($env:HOME + "~/PowerShellAI/ChatGPT") + # } + + It 'Test Get-ChatSessionFile returns correct file name for Windows' { + + if ($IsLinux -or $IsMacOS) { + # skip + return + } + + Reset-ChatSessionTimeStamp + $timeStamp = Get-ChatSessionTimeStamp + + $actual = Get-ChatSessionFile + + $expected = "$(Get-ChatSessionPath)\$($timeStamp)-ChatGPTSession.xml" + + $actual | Should -BeExactly $expected + } + + It 'Test exporting chat messages' { + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'system' + content = 'system test' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'user' + content = 'user test' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'assistant' + content = 'assistant test' + }) + + Set-chatSessionPath -Path 'TestDrive:\PowerShell\ChatGPT' + + Export-ChatSession + + $totalChats = Get-ChatSession + + $totalChats.Count | Should -Be 1 + } + + It 'Test Get-ChatSession function exists' { + $actual = Get-Command Get-ChatSession -ErrorAction SilentlyContinue + + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test setting and resetting the chat session path' { + + if ($IsLinux -or $IsMacOS) { + # skip + return + } + + $expected = 'TestDrive:\PowerShell\ChatGPT' + Set-ChatSessionPath -Path $expected + + $actual = Get-ChatSessionPath + $actual | Should -BeExactly $expected + + Reset-ChatSessionPath + + $actual = Get-ChatSessionPath + + if ($IsWindows -or $null -eq $IsWindows) { + $actual | Should -BeExactly "$env:APPDATA\PowerShellAI\ChatGPT" + } + } + + It 'Test Get-ChatSessionContent returns correct content' { + + Set-ChatSessionPath "TestDrive:\PowerShell\ChatGPT" + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'system' + content = 'system test' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'user' + content = 'user test' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'assistant' + content = 'assistant test' + }) + + Export-ChatSession + + $sessions = Get-ChatSession + $sessions.Count | Should -Be 1 + + $content = Get-ChatSessionContent $sessions + + $content | Should -Not -BeNullOrEmpty + $content.Count | Should -Be 3 + + $content[0].role | Should -BeExactly 'system' + $content[0].content | Should -BeExactly 'system test' + + $content[1].role | Should -BeExactly 'user' + $content[1].content | Should -BeExactly 'user test' + + $content[2].role | Should -BeExactly 'assistant' + $content[2].content | Should -BeExactly 'assistant test' + } + + It 'Test Get-ChatSessionContent returns correct content with multiple sessions' { + Set-ChatSessionPath "TestDrive:\PowerShell\ChatGPT" + Get-ChatSessionPath | Get-ChildItem | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'system' + content = 'system test' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'user' + content = 'user test' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'assistant' + content = 'assistant test' + }) + + Export-ChatSession + + Stop-Chat + Reset-ChatSessionTimeStamp + Start-Sleep 1 + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'system' + content = 'system test 2' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'user' + content = 'user test 2' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'assistant' + content = 'assistant test 2' + }) + + Export-ChatSession + + Stop-Chat + Reset-ChatSessionTimeStamp + + $sessions = Get-ChatSession + $sessions.Count | Should -Be 2 + + $result = $sessions | Get-ChatSessionContent + + $result.Count | Should -Be 6 + + $result[0].role | Should -BeExactly 'system' + $result[0].content | Should -BeExactly 'system test' + + $result[1].role | Should -BeExactly 'user' + $result[1].content | Should -BeExactly 'user test' + + $result[2].role | Should -BeExactly 'assistant' + $result[2].content | Should -BeExactly 'assistant test' + + $result[3].role | Should -BeExactly 'system' + $result[3].content | Should -BeExactly 'system test 2' + + $result[4].role | Should -BeExactly 'user' + $result[4].content | Should -BeExactly 'user test 2' + + $result[5].role | Should -BeExactly 'assistant' + $result[5].content | Should -BeExactly 'assistant test 2' + } + + It 'Test Get-ChatSessionContent piping sessions to it' { + Set-ChatSessionPath "TestDrive:\PowerShell\ChatGPT" + Get-ChatSessionPath | Get-ChildItem | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'system' + content = 'system test' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'user' + content = 'user test' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'assistant' + content = 'assistant test' + }) + + Export-ChatSession + + Stop-Chat + Reset-ChatSessionTimeStamp + Start-Sleep 1 + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'system' + content = 'system test 2' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'user' + content = 'user test 2' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'assistant' + content = 'assistant test 2' + }) + + Export-ChatSession + + Stop-Chat + Reset-ChatSessionTimeStamp + Start-Sleep 1 + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'system' + content = 'system test 3' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'user' + content = 'user test 3' + }) + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'assistant' + content = 'assistant test 3' + }) + + Export-ChatSession + + Stop-Chat + Reset-ChatSessionTimeStamp + + $sessions = Get-ChatSession + $sessions.Count | Should -Be 3 + + $result = $sessions | Get-ChatSessionContent + + $result.Count | Should -Be 9 + + $result[0].role | Should -BeExactly 'system' + $result[0].content | Should -BeExactly 'system test' + + $result[1].role | Should -BeExactly 'user' + $result[1].content | Should -BeExactly 'user test' + + $result[2].role | Should -BeExactly 'assistant' + $result[2].content | Should -BeExactly 'assistant test' + + $result[3].role | Should -BeExactly 'system' + $result[3].content | Should -BeExactly 'system test 2' + + $result[4].role | Should -BeExactly 'user' + $result[4].content | Should -BeExactly 'user test 2' + + $result[5].role | Should -BeExactly 'assistant' + $result[5].content | Should -BeExactly 'assistant test 2' + + $result[6].role | Should -BeExactly 'system' + $result[6].content | Should -BeExactly 'system test 3' + + $result[7].role | Should -BeExactly 'user' + $result[7].content | Should -BeExactly 'user test 3' + + $result[8].role | Should -BeExactly 'assistant' + $result[8].content | Should -BeExactly 'assistant test 3' + } + + It "tests Export-ChatSession respects ChatPersistence flag" { + Set-ChatSessionPath "TestDrive:\PowerShell\ChatGPT" + Get-ChatSessionPath | Get-ChildItem | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'assistant' + content = 'assistant test 2' + }) + + Export-ChatSession + + (Get-ChatSessionPath | Get-ChildItem ).Count | Should -Be 1 + + Get-ChatSessionPath | Get-ChildItem | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + + Disable-ChatPersistence + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'assistant' + content = 'assistant test 2' + }) + + Export-ChatSession + + (Get-ChatSessionPath | Get-ChildItem ).Count | Should -Be 0 + + Enable-ChatPersistence + Get-ChatSessionPath | Get-ChildItem | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + + Add-ChatMessage -Message ([PSCustomObject]@{ + role = 'assistant' + content = 'assistant test 2' + }) + + Export-ChatSession + + (Get-ChatSessionPath | Get-ChildItem ).Count | Should -Be 1 + } +} \ No newline at end of file diff --git a/__tests__/Set-AzureOpenAI.tests.ps1 b/__tests__/Set-AzureOpenAI.tests.ps1 new file mode 100644 index 0000000..2c7b63e --- /dev/null +++ b/__tests__/Set-AzureOpenAI.tests.ps1 @@ -0,0 +1,62 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe 'Set-AzureOpenAI' -Tag SetAzureOpenAI { + + BeforeEach { + $savedAzureOpenAIKey = $env:AzureOpenAIKey + } + + AfterEach { + $env:AzureOpenAIKey = $savedAzureOpenAIKey + } + + AfterEach { + Reset-ChatSessionOptions + Reset-AzureOpenAIOptions + Set-ChatAPIProvider -Provider 'OpenAI' + } + + It 'Test if Set-AzureOpenAI function exists' { + $actual = Get-Command Set-AzureOpenAI -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Set-AzureOpenAI has these parameters' { + $actual = Get-Command Set-AzureOpenAI -ErrorAction SilentlyContinue + + $keys = $actual.Parameters.keys + + $keys.Contains("Endpoint") | Should -BeTrue + $keys.Contains("DeploymentName") | Should -BeTrue + $keys.Contains("ApiVersion") | Should -BeTrue + $keys.Contains("ApiKey") | Should -BeTrue + } + + It 'Test if Set-AzureOpenAI parameter attributes' { + $actual = Get-Command Set-AzureOpenAI | Select-Object -ExpandProperty Parameters + + $actual.Endpoint.Attributes.Mandatory | Should -BeTrue + $actual.DeploymentName.Attributes.Mandatory | Should -BeTrue + $actual.ApiVersion.Attributes.Mandatory | Should -BeTrue + $actual.ApiKey.Attributes.Mandatory | Should -BeTrue + } + + It 'Test passing in data to Set-AzureOpenAI' { + Set-AzureOpenAI ` + -Endpoint https://myopenaiinstance.openai.azure.com ` + -DeploymentName myopenaiinstance ` + -ApiVersion 2023-03-15-preview ` + -ApiKey aayyzzbbcc + + $actual = Get-AzureOpenAIOptions + + $actual.Endpoint | Should -Be "https://myopenaiinstance.openai.azure.com" + $actual.DeploymentName | Should -Be "myopenaiinstance" + $actual.ApiVersion | Should -Be "2023-03-15-preview" + + Test-AzureOpenAIKey | Should -BeTrue + Get-ChatAPIProvider | Should -Be "AzureOpenAI" + + $env:AzureOpenAIKey | Should -BeExactly "aayyzzbbcc" + } +} \ No newline at end of file diff --git a/__tests__/Set-OpenAIKey.tests.ps1 b/__tests__/Set-OpenAIKey.tests.ps1 index 619273e..6ddccfd 100644 --- a/__tests__/Set-OpenAIKey.tests.ps1 +++ b/__tests__/Set-OpenAIKey.tests.ps1 @@ -1,3 +1,4 @@ +Remove-Module 'PowerShellAI' -Force -ErrorAction Ignore Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force Describe "Set-OpenAIKey" -Tag 'SetOpenAIKey' { @@ -16,4 +17,11 @@ Describe "Set-OpenAIKey" -Tag 'SetOpenAIKey' { It "Should accept valid secure string as Key parameter value" { {Set-OpenAIKey -Key (ConvertTo-SecureString -String 'FakeOpenAIKey' -AsPlainText -Force)} | Should -Not -Throw } + + AfterAll { + InModuleScope 'PowerShellAI' { + #Reset module scope secure string with fake OpenAI key + $Script:OpenAIKey = $null + } + } } diff --git a/__tests__/Test-AzureOpenAIKey.tests.ps1 b/__tests__/Test-AzureOpenAIKey.tests.ps1 new file mode 100644 index 0000000..c1175a6 --- /dev/null +++ b/__tests__/Test-AzureOpenAIKey.tests.ps1 @@ -0,0 +1,42 @@ +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe "AzureOpenAIKey" -Tag AzureOpenAIKey { + BeforeAll { + $script:savedKey = $env:AzureOpenAIKey + $env:AzureOpenAIKey = 'a7duejdnekhdl' + + Mock Invoke-RestMethodWithProgress -ModuleName PowerShellAI -ParameterFilter { + $Params.Method -eq 'Post' -and $Params.Uri -eq (Get-OpenAIChatCompletionUri) + } -MockWith { + [PSCustomObject]@{ + choices = @( + [PSCustomObject]@{ + message = [PSCustomObject]@{ + content = 'Mocked Get-GPT4Completion call' + } + } + ) + } + } + } + + BeforeEach { + Stop-Chat + Clear-ChatMessages + Get-ChatSessionPath | Get-ChildItem -ErrorAction SilentlyContinue | Remove-Item -Force + } + + AfterAll { + $env:AzureOpenAIKey = $savedKey + } + + It 'Test Test-AzureOpenAIKey function exists' { + $actual = Get-Command Test-AzureOpenAIKey -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } + + It 'Test Test-AzureOpenAIKey returns true if AzureOpenAIKey is set' { + $actual = Test-AzureOpenAIKey + $actual | Should -BeTrue + } +} \ No newline at end of file diff --git a/__tests__/TestFunctionExists.tests.ps1 b/__tests__/TestFunctionExists.tests.ps1 new file mode 100644 index 0000000..1f3899b --- /dev/null +++ b/__tests__/TestFunctionExists.tests.ps1 @@ -0,0 +1,13 @@ +# first pass +# for functions not directly tested for various reasons +# this is a simple test to ensure the function exists +# in cased they are deleted or renamed + +Import-Module "$PSScriptRoot\..\PowerShellAI.psd1" -Force + +Describe "FunctionExist" -Tag 'FunctionExist' { + It "Should not be null the function exists" { + $actual = Get-Command Invoke-AIErrorHelper -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } +} diff --git a/changelog.md b/changelog.md index 418895a..4488a7b 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,263 @@ +# v0.9.7 +- Thank you [Darren Robinson](https://github.com/darrenjrobinson) + - New Get-AOAIDalleImage cmdlet supports Azure Open AI + +# v0.9.6 + +- Updated default model to use gpt-3.5-turbo-instruct + +# v0.9.5 +- Thank you [Darren Robinson](https://github.com/darrenjrobinson) + - Update Copilot to support Azure Open AI + +# v0.9.4 + +- [Shaun Lawrie](https://twitter.com/shaun_lawrie) - Fallback to gray text for the codeblock if the console host doesn't expose window dimensions. This fixes [#200](https://github.com/dfinke/PowerShellAI/issues/200) and potentially [#199](https://github.com/dfinke/PowerShellAI/issues/199) as well. + +# v0.9.2 + +- Support latest OpenAI API models gpt-4-1106-preview and gpt-3.5-turbo-1106 +- Add `Get-ChatCompletion` to the list to support Azure Open AI +- Add function `ConvertTo-JsonL` + +# v0.9.1 + +- Remove `Get-ChatCompletion` from `psd1`. It lives in `PowerShellAI.Functions` + +# v0.9.0 + +- Added requirement to the `PowerShellAI.Functions` module + 👉 https://github.com/dfinke/PowerShellAI.Functions + +# v0.8.2 +- Thank you [Darren Robinson](https://github.com/darrenjrobinson) + - max_tokens increased to what Azure OpenAI supports https://github.com/dfinke/PowerShellAI/issues/173 + +- Added `$name` to `New-ChatMessageTemplate`. Required when a message has the role of function + +# v0.8.1 + +- Thank you [Shaun Lawrie](https://github.com/ShaunLawrie) + - Fixed progress bars for users with proxies configured https://github.com/dfinke/PowerShellAI/pull/171 +- Added a new role `function` for messages + +# v0.8.0 + +- Fix "[] is too short - 'messages'" https://github.com/dfinke/PowerShellAI/issues/163 + +# v0.7.9 +- Thank you [merlinfrombelgium](https://github.com/merlinfrombelgium) + - Introduce re-prompting after explain is chosen in copilot function +- Updated to latest models for interacting with GPT + +# v0.7.8 +- Added example: `Read-ExcelAndMultiplyUnits.ps1 ` + +Uses the `ImportExcel` `Get-ExcelFileSchema` to read the schema of the Excel file, then creates GPT chat messages for the user and system, the prompt `read the excel file +multiply the units by 20%`. Finally, it outputs the PowerShell code that reads the Excel file, multiplies the units by 20%, and formats the output as a table. + +# v0.7.7 + +Thank you [Shaun Lawrie](https://github.com/ShaunLawrie) +- Fix GPT fails when run from a PSDrive + - Reported by Jeff Hicks - https://github.com/dfinke/PowerShellAI/issues/148 + +# v0.7.6 + +- Implement mechanism to turn on/off persistence of chat message +- Add `Get-CompletionFromMessages` so you can get messages can be passes standalone to get the completions + +# v0.7.5 + +- Fixed spaces in property names [#141](https://github.com/dfinke/PowerShellAI/issues/141) + +# v0.7.4 + +- Thank you [Shaun Lawrie](https://github.com/ShaunLawrie)! + - Fix function builder not running with latest PowerShellAI + - Fix write-progress breaking notebooks + +# v0.7.3 + +- Thank you [Shaun Lawrie](https://github.com/ShaunLawrie)! + - Added `Write-Progress`, tests and mocks + +# v0.7.2 + +- Added `Write-Information` as an indicator after callint GPT + +# v0.7.1 + +- `New-Chat` refactored + - Now immediately posts to GPT if you specify a prompt and displays the response + - No prompt, still closes existing session and creates a new one +# v0.6.3 + +- Improved the `system prompt` for `copilot` `gh?` function +- Make `New-NBCell` public + +# v0.6.2 + +- Added `Get-OpenAIEmbedding` to get a vector representation of a given input +- Added `-temperature` param to `chat` +- Added "send to vs code" option to menu +- Added two new functions: + - `git?`: Translate natural language to Git commands + - `gh?`: Translate natural language to GitHub CLI commands + + +**Full Changelog**: https://github.com/dfinke/PowerShellAI/compare/v0.6.1...v0.6.2 +# v0.6.1 + +- Upgraded `copilot` and `explain` to use `Write-CodeBlock` fir syntax highlighting +- Added call to `Invoke-Formatter` to prettify the code + +# v0.6.0 + +- Fixed `Invalid JSON` - https://github.com/dfinke/PowerShellAI/issues/119 + +# v0.5.8 + +Thank you @ShaydeNofziger for the contribution! +- Update .gitignore +- Update help comment + +Also, I: +- Added `kql` to validate set for `NBCopilot` + +# v0.5.7 + +Thanks Shaun, great work! + +- `AI Function Builder` by [Shaun Lawrie](https://github.com/ShaunLawrie) + - AIFunctionBuilder takes a prompt and generates a PowerShell function which is validated for syntax and logical issues so you don't have to do the boring work. + - https://github.com/dfinke/PowerShellAI/tree/master/CommunityContributions/06-AIFunctionBuilder#readme +- `Notebook Copilot` by [Doug Finke](https://github.com/dfinke) + - This PowerShell function allows you to use ChatGPT directly from your Interactive Notebook. + - https://github.com/dfinke/PowerShellAI/tree/master/CommunityContributions/07-NotebookCopilot#readme +- `Devcontainer` created for easier use in Codespaces [Doug Finke](https://github.com/dfinke) + + +# v0.5.6 +## What's Changed +- Enables chat conversations with either the public OpenAI or a private Azure OpenAI Service. + - [Documentation](https://github.com/dfinke/PowerShellAI/wiki/AzureOpenAI) + - [Video](https://youtu.be/1Z1QYQZ1Z1Q) + +_Community Contributions:_ +- Thank you [Svyatoslav Pidgorny](https://github.com/SP3269) + - Copilot prompt change, adding clipboard + + ``` + PS D:\> copilot 'cmds to find k8 pods' + ╔═════════════════════════╗ + ║Q: cmds to find k8 pods ║ + ║═════════════════════════║ + ║1: kubectl get pods ║ + ╚═════════════════════════╝ + Run the code? You can also choose additional actions + [Y] Yes [E] Explain [C] Copy [N] No [?] Help (default is "N"): + ``` + +## New Contributors +* @SP3269 made their first contribution in https://github.com/dfinke/PowerShellAI/pull/105 + + +# v0.5.5 + +- Added support for GPT-4, conversation-in and message-out + - Saves the conversation to a file in each invocation + - Supports changing chat options like the model like `gpt-4` or `gpt-3.5-turbo` and more + - List sessions that have been saved, plus you can get their content + +**Note**: This defaults to using `gpt-4`. You can set the model to chat with: + +```powershell +Get-ChatSessionOptions +Set-ChatSessionOption -model gpt-3.5-turbo +Get-ChatSessionOptions +``` + +Getting started example: + +```powershell +New-Chat 'respond only in json' +chat 'what are the capitals of Spain, France, and the USA?' +``` + +```json +{ + "Spain": "Madrid", + "France": "Paris", + "USA": "Washington, D.C." +} +``` + + +# v0.5.4 + +- Copilot can now `explain` the code it generates. +- Thank you [Kris Borowinski](https://github.com/kborowinski) for fixing the Pester tests. + +# v0.5.3 + +- Thank you to [Matt Cargile](https://github.com/mattcargile) for suggestions on improving the prompt for `Invoke-AIExplain` and reviewing the updates. + - Prompt now includes + - 'You are running powershell on' + - $PSVersionTable.Platform +- Added $max_tokens to `Invoke-AIExplain` +- Added `$IdEnd`. You can ask for the explanation of a range of history items `Invoke-AIExplain -Id 20 -IdEnd 23` + +# v0.5.2 + +- Added `Invoke-AIExplain` - The function utilizes the OpenAI GPT-3 API to offer explanations for the most recently run command, and more. +- Added alias `explain` to `Invoke-AIExplain` + +# v0.5.1 + +- Added proof of concept to work with the new Chat REST API + - There is more to it. Requires refactoring and tests + - Proving to be very useful + + ```powershell + new-chat 'you are a powershell bot' + + chat 'even numbers btwn 1 and 10' + chat 'odd numbers' + ``` + +# v0.5.0 + +- Thank you [Kris Borowinski](https://github.com/kborowinski) for re-working Get-OpenAIKey to Get-LocalOpenAIKey and creating/updating tests + +# v0.4.9 + +- For `Get-OpenAIUsage` + - Default start and end date + - Added switch $OnlyLineItems + +# v0.4.8 +Thank you to the community for your contributions! + +- [Mikey Bronowski](https://github.com/MikeyBronowski) + - Update README with fixes and clarifications +- Usage by @stefanstranger in https://github.com/dfinke/PowerShellAI/pull/69 + +- [Stefan Stranger](https://github.com/stefanstranger) + - Added functions to get OpenAI Dashboard information and more + - Demos in the [Polyglot Interactive Notebook](CommunityContributions/05-Settings/Settings.ipynb) + # v0.4.7 -- Thank you [Kris Borowinski](https://github.com/kborowinski) + +Thank you to the community for your contributions! +- [Kris Borowinski](https://github.com/kborowinski) - On PS 6 and higher use Invoke-RestMethod with secure Token +- [Shaun Lawrie](https://twitter.com/shaun_lawrie) + - Add error insights by [Shaun Lawrie](https://twitter.com/shaun_lawrie) +- [James Brundage](https://twitter.com/JamesBru) + - PowerShellAI enhancement for short cut key by @StartAutomating +- [Adam Bacon](https://twitter.com/psdevuk) + - Add functions and prompts for use with ChatGPT # v0.4.6 - Thank you to [Pieter Jan Geutjens](https://github.com/pjgeutjens) diff --git a/media/AIErrorInsights.png b/media/AIErrorInsights.png new file mode 100644 index 0000000..1d948cc Binary files /dev/null and b/media/AIErrorInsights.png differ diff --git a/media/NBCopilot.png b/media/NBCopilot.png new file mode 100644 index 0000000..553565f Binary files /dev/null and b/media/NBCopilot.png differ diff --git a/media/invoke-functionbuilder.mp4 b/media/invoke-functionbuilder.mp4 new file mode 100644 index 0000000..4fae613 Binary files /dev/null and b/media/invoke-functionbuilder.mp4 differ diff --git a/media/write-codeblock.mp4 b/media/write-codeblock.mp4 new file mode 100644 index 0000000..a783318 Binary files /dev/null and b/media/write-codeblock.mp4 differ