Public/Show-OAILocalPlayground.ps1

<#
.SYNOPSIS
Starts a local playground interface that lets you play with OAI
#>


function Show-OAILocalPlayground {
    param(
        $Port = 8080,
        $Threads = 2,
        [switch]$DisableLaunch
    )
    $script:port = $port

    if (-not $ENV:OpenAIKey) {
        Write-Warning 'No $ENV:OpenAIKey found.'
    }

    # When someone clones the PowerShellAIAssistant repo, but it isn't in any PSModulePath, the playground will fail.
    if (-not (Get-Module -list PowerShellAIAssistant)) {
        throw 'PowerShellAIAssistant not found in any module directories. Please add a PSModulePath to the correct location if using a custom one. e.g. $ENV:PSModulePath += "; $pwd"'
    }

    # This is almost like a proxy function since it is directly stolen from Pode.Web and then edited so that
    # ConvertTo-PodeWebPage works with advanced parameter blocks on pwsh 7.4+
    # Also has a slight improvement to use ConvertTo-Json for output results.
    # Likely remove this function as soon as Pode.Web >v0.8.3 is released.
    function Convertto-PodeWebPage {
        [CmdletBinding()]
        param(
            [Parameter(ValueFromPipeline = $true)]
            [string[]]
            $Commands,

            [Parameter()]
            [string]
            $Module,

            [switch]
            $GroupVerbs,

            [Parameter()]
            [Alias('NoAuth')]
            [switch]
            $NoAuthentication
        )

        # if a module was supplied, import it - then validate the commands
        if (![string]::IsNullOrWhiteSpace($Module)) {
            Import-PodeModule -Name $Module
            Export-PodeModule -Name $Module

            Write-Verbose "Getting exported commands from module"
            $ModuleCommands = (Get-Module -Name $Module | Sort-Object -Descending | Select-Object -First 1).ExportedCommands.Keys

            # if commands were supplied validate them - otherwise use all exported ones
            if (Test-PodeIsEmpty $Commands) {
                Write-Verbose "Using all commands in $($Module) for converting to Pages"
                $Commands = $ModuleCommands
            }
            else {
                Write-Verbose "Validating supplied commands against module's exported commands"
                foreach ($cmd in $Commands) {
                    if ($ModuleCommands -inotcontains $cmd) {
                        throw "Module $($Module) does not contain function $($cmd) to convert to a Page"
                    }
                }
            }
        }

        # if there are no commands, fail
        if (Test-PodeIsEmpty $Commands) {
            throw 'No commands supplied to convert to Pages'
        }

        $sysParams = [System.Management.Automation.PSCmdlet]::CommonParameters.GetEnumerator() | ForEach-Object { $_ }

        # create the pages for each of the commands
        foreach ($cmd in $Commands) {
            Write-Verbose "Building page for $($cmd)"
            $cmdInfo = (Get-Command -Name $cmd -ErrorAction Stop)

            $sets = $cmdInfo.ParameterSets
            if (($null -eq $sets) -or ($sets.Length -eq 0)) {
                continue
            }

            # for cmdlets this will be null
            $ast = $cmdInfo.ScriptBlock.Ast
            $paramDefs = $null
            if ($null -ne $ast) {
                $paramDefs = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.ParameterAst] }, $true) | Where-Object {
                    $_.Parent.Parent.Parent.Name -ieq $cmd
                }
            }

            $tabs = New-PodeWebTabs -Tabs @(foreach ($set in $sets) {
                    $elements = @(foreach ($param in $set.Parameters) {
                            if ($sysParams -icontains $param.Name) {
                                continue
                            }

                            $type = $param.ParameterType.Name

                            $default = $null
                            if ($null -ne $paramDefs) {
                                $default = ($paramDefs | Where-Object { $_.DefaultValue -and $_.Name.Extent.Text -ieq "`$$($param.Name)" }).DefaultValue.Value
                            }

                            if ($type -iin @('boolean', 'switchparameter')) {
                                New-PodeWebCheckbox -Name $param.Name -AsSwitch
                            }
                            else {
                                switch ($type) {
                                    'pscredential' {
                                        New-PodeWebCredential -Name $param.Name
                                    }

                                    default {
                                        $multiple = $param.ParameterType.Name.EndsWith('[]')

                                        if ($param.Attributes.TypeId.Name -icontains 'ValidateSetAttribute') {
                                            $values = ($param.Attributes | Where-Object { $_.TypeId.Name -ieq 'ValidateSetAttribute' }).ValidValues
                                            New-PodeWebSelect -Name $param.Name -Options $values -SelectedValue $default -Multiple:$multiple
                                        }
                                        elseif ($param.ParameterType.BaseType.Name -ieq 'enum') {
                                            $values = [enum]::GetValues($param.ParameterType)
                                            New-PodeWebSelect -Name $param.Name -Options $values -SelectedValue $default -Multiple:$multiple
                                        }
                                        else {
                                            New-PodeWebTextbox -Name $param.Name -Value $default
                                        }
                                    }
                                }
                            }
                        })

                    $elements += (New-PodeWebHidden -Name '_Function_Name_' -Value $cmd)

                    $name = $set.Name
                    if ([string]::IsNullOrWhiteSpace($name) -or ($set.Name -iin @('__AllParameterSets'))) {
                        $name = 'Default'
                    }

                    $formId = "form_param_$($cmd)_$($name)"

                    $form = New-PodeWebForm -Name Parameters -Id $formId -Content $elements -AsCard -NoAuthentication:$NoAuthentication -ScriptBlock {
                        $cmd = $WebEvent.Data['_Function_Name_']
                        $WebEvent.Data.Remove('_Function_Name_')

                        $_args = @{}
                        foreach ($key in $WebEvent.Data.Keys) {
                            if ($key -imatch '(?<name>.+)_(Username|Password)$') {
                                $name = $Matches['name']
                                $uKey = "$($name)_Username"
                                $pKey = "$($name)_Password"

                                if (![string]::IsNullOrWhiteSpace($WebEvent.Data[$uKey]) -and ![string]::IsNullOrWhiteSpace($WebEvent.Data[$pKey])) {
                                    $creds = (New-Object System.Management.Automation.PSCredential -ArgumentList $WebEvent.Data[$uKey], (ConvertTo-SecureString -AsPlainText $WebEvent.Data[$pKey] -Force))
                                    $_args[$name] = $creds
                                }
                            }
                            else {
                                if ($WebEvent.Data[$key] -iin @('true', 'false')) {
                                    $_args[$key] = ($WebEvent.Data[$key] -ieq 'true')
                                }
                                else {
                                    if ($WebEvent.Data[$key].Contains(',')) {
                                        $_args[$key] = ($WebEvent.Data[$key] -isplit ',' | ForEach-Object { $_.Trim() })
                                    }
                                    else {
                                        $_args[$key] = $WebEvent.Data[$key]
                                    }
                                }
                            }
                        }

                        try {
                    (. $cmd @_args) | ConvertTo-Json | Out-PodeWebTextbox -Multiline -Preformat
                        }
                        catch {
                            $_.Exception | ConvertTo-Json -Depth 0 | Out-PodeWebTextbox -Multiline -Preformat
                        }
                    }

                    New-PodeWebTab -Name $name -Layouts $form
                })

            $group = [string]::Empty
            if ($GroupVerbs) {
                $group = $cmdInfo.Verb
                if ([string]::IsNullOrWhiteSpace($group)) {
                    $group = '_'
                }
            }

            Add-PodeWebPage -Name $cmd -Icon Settings -Layouts $tabs -Group $group -NoAuthentication:$NoAuthentication
        }
    }

    # Avoid the ugly logic of (-not () -or -not ())
    if ((Get-Module -list Pode) -and (Get-Module -list Pode.Web)) {}
    else {
        # Get enthusiastic consent before installing extra modules on someone's system
        if ("y" -eq (Read-Host "The local playground requires Pode and Pode.Web. Type 'y' to install to local user from PSGallery")) {
            if (-not (Get-Module -list Pode)) { Install-Module Pode -Scope CurrentUser -Force }
            if (-not (Get-Module -list Pode.Web)) { Install-Module Pode.Web -Scope CurrentUser -Force }
        }
        else {
            throw "Pode and Pode.Web are not installed and consent was not given."
        }
    }

    # Pode.Web needs to be in the global scope for runspaces to access functions
    Import-Module Pode.Web -Scope Global

    Start-PodeServer -Browse:(-not $DisableLaunch) -StatusPageExceptions Show -Threads $Threads {
        $endpoint_param = @{
            Address  = "localhost"
            Port     = $script:Port
            Protocol = "Http"
            Name     = "Local Playground"
        }
        Add-PodeEndpoint @endpoint_param

        # Enable sessions so that user data can be stored server side easily
        Enable-PodeSessionMiddleware -Duration ([int]::MaxValue)

        Use-PodeWebTemplates -Title SampleApp -Theme Dark


        # You want the assistants and history to be accessible regardless of session
        New-PodeLockable "historyList_lock"
        Set-PodeState -Name "historyList" -Value @()
        New-PodeLockable "assistantList_lock"
        Set-PodeState -Name "assistantList" -Value @()

        Lock-PodeObject -Name "historyList_lock" -ScriptBlock {
            Set-PodeState -Name "historyList" -Value ([array](Get-PodeState -Name "historyList") + "Get-OAIAssistant")
        }
        $assistantList = Get-OAIAssistant
        Lock-PodeObject -Name "assistantList_lock" -ScriptBlock {
            Set-PodeState -Name "assistantList" -Value $assistantList
        }

        Add-PodeWebPage -Name CodeHistory -DisplayName "Code history" -ScriptBlock {
            New-PodeWebCard -Content @(
                New-PodeWebTextbox -Name "Cmdlets" -Multiline -ReadOnly -Value (Get-History).CommandLine
            )
        }

        # This page will create a conversation with an assistant, but does not use the boilerplate cmdlets
        # included in the PowerShellAIAssistant module like New-OAIThreadQuery because it doesn't save any
        # actual calls to remote resources and doesn't as easily allow for follow up messages
        Add-PodeWebPage -Name ChatGPT -ScriptBlock {
            # Provide an all-in-one interface to make it as easy as possible to stay on this page
            New-PodeWebCard -Content @(
                New-PodeWebButton -Name "Update assistant list" -ScriptBlock {
                    $assistantList = Get-OAIAssistant

                    Lock-PodeObject -Name "historyList_lock" -ScriptBlock {
                        Set-PodeState -Name "historyList" -Value ([array](Get-PodeState -Name "historyList") + "Get-OAIAssistant")
                    }
                    Lock-PodeObject -Name "assistantList_lock" -ScriptBlock {
                        Set-PodeState -Name "assistantList" -Value $assistantList
                    }
                    Move-PodeWebUrl -Url /pages/ChatGPT
                }
            ) -CssStyle @{"max-width" = "800px" }

            New-PodeWebCard -Content @(
                New-PodeWebForm -Name 'Example' -Content @(
                    New-PodeWebSelect -Name "Assistant" -Id "Assistant" -Options (@((Get-PodeState -Name "assistantList").id) + "New") -DisplayOptions (@((Get-PodeState -Name "assistantList").Name) + "New (Sample assistant)")
                    New-PodeWebSelect -Name "Thread" -Id "Thread" -Options (@($WebEvent.Session.Data.Threads.id) + "New...") -SelectedValue $WebEvent.Session.Data.Thread.id
                    New-PodeWebTextbox -Name 'Message'
                ) -ScriptBlock {
                    if ($WebEvent.Data['Assistant'] -eq "New") {
                        $assistant = New-OAIAssistant -Name 'Math Tutor' -Instructions 'You are a helpful math assistant. Please explain your answers.'
                        Lock-PodeObject -Name "assistantList_lock" -ScriptBlock {
                            $assistantList = Get-PodeState -Name "assistantList"
                            $assistantList = @($assistantList) + $assistant
                            Set-PodeState -Name "assistantList" -Value $assistantList
                        }
                        Update-PodeWebSelect -Id "Assistant" -SelectedValue $assistant.id -Options (@((Get-PodeState -Name "assistantList").id) + "New") -DisplayOptions (@((Get-PodeState -Name "assistantList").Name) + "New (Sample assistant)")
                        $assistantID = $assistant.id
                    }
                    else {
                        $assistantID = $WebEvent.Data['Assistant']
                    }

                    if ($WebEvent.Data['Thread'] -eq "New...") {
                        $WebEvent.Session.Data.Thread = New-OAIThread
                        $WebEvent.Session.Data.Threads = @($WebEvent.Session.Data.Threads) + $WebEvent.Session.Data.Thread | Sort-Object -Unique -Property created_at
                        @($WebEvent.Session.Data.Threads.id) + "New..." | Update-PodeWebSelect -Id "Thread" -SelectedValue $WebEvent.Session.Data.Thread
                    }
                    else {
                        $WebEvent.Session.Data.Thread = $WebEvent.Session.Data.Threads | Where-Object id -EQ $WebEvent.Data['Thread']
                    }

                    New-OAIMessage -ThreadId $WebEvent.Session.Data.Thread.id -Role user -Content $WebEvent.Data['Message'] | Out-Null

                    $run = New-OAIRun -ThreadId $WebEvent.Session.Data.Thread.id -AssistantId $assistantID
                    Wait-OAIOnRun -Run $run -Thread $WebEvent.Session.Data.Thread | Out-Null

                    $messages = Get-OAIMessage -ThreadId $WebEvent.Session.Data.Thread.id -Order asc
                    $messages.data | Select-Object Role, @{n = 'Message'; e = { $_.content.text.value } } | ForEach-Object {
                        "{0}: {1}" -f $_.Role, $_.Message
                        if ($_.Role -eq "Assistant") { "" }
                    } | Out-String | Out-PodeWebTextbox -Multiline -ReadOnly

                    Clear-PodeWebTextbox -Name Message
                }
            ) -CssStyle @{"max-width" = "800px" }
        }



        ConvertTo-PodeWebPage -Module PowerShellAIAssistant -Commands @(
            "Get-OAIAssistant"
            "New-OAIAssistant"
            # "New-OAIThread"
            # "New-OAIMessage"
            "New-OAIRun"
            # "Wait-OAIOnRun"
            "Get-OAIMessage"
            "Remove-OAIThread"
            "Remove-OAIAssistant"
        )
    }
}