diff --git a/.github/workflows/copilot-build-backend.yml b/.github/workflows/copilot-build-backend.yml index 0eed8fb3f..e82da790e 100644 --- a/.github/workflows/copilot-build-backend.yml +++ b/.github/workflows/copilot-build-backend.yml @@ -3,8 +3,8 @@ name: copilot-build-backend on: pull_request: branches: ["main"] - merge_group: - branches: ["main"] + paths: + - "webapi/**" workflow_call: outputs: artifact: diff --git a/.github/workflows/copilot-build-frontend.yml b/.github/workflows/copilot-build-frontend.yml index c74e7e4cf..7c8fb46ce 100644 --- a/.github/workflows/copilot-build-frontend.yml +++ b/.github/workflows/copilot-build-frontend.yml @@ -4,6 +4,8 @@ on: workflow_dispatch: pull_request: branches: ["main"] + paths: + - "webapp/**" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} diff --git a/scripts/deploy/deploy-azure.sh b/scripts/deploy/deploy-azure.sh old mode 100644 new mode 100755 diff --git a/scripts/deploy/deploy-webapp.ps1 b/scripts/deploy/deploy-webapp.ps1 index e17dfc0f3..a47cbe27f 100644 --- a/scripts/deploy/deploy-webapp.ps1 +++ b/scripts/deploy/deploy-webapp.ps1 @@ -63,7 +63,7 @@ Write-Host "webapiScope: $webapiScope" # Set ASCII as default encoding for Out-File $PSDefaultParameterValues['Out-File:Encoding'] = 'ascii' -$envFilePath = "$PSScriptRoot/../webapp/.env" +$envFilePath = "$PSScriptRoot/../../webapp/.env" Write-Host "Writing environment variables to '$envFilePath'..." "REACT_APP_BACKEND_URI=https://$webapiUrl/" | Out-File -FilePath $envFilePath "REACT_APP_AUTH_TYPE=AzureAd" | Out-File -FilePath $envFilePath -Append @@ -72,16 +72,16 @@ Write-Host "Writing environment variables to '$envFilePath'..." "REACT_APP_AAD_API_SCOPE=api://$webapiClientId/$webapiScope" | Out-File -FilePath $envFilePath -Append Write-Host "Generating SWA config..." -$swaConfig = $(Get-Content "$PSScriptRoot/../webapp/template.swa-cli.config.json" -Raw) +$swaConfig = $(Get-Content "$PSScriptRoot/../../webapp/template.swa-cli.config.json" -Raw) $swaConfig = $swaConfig.Replace("{{appDevserverUrl}}", "https://$webappUrl") $swaConfig = $swaConfig.Replace("{{appName}}", "$webappName") $swaConfig = $swaConfig.Replace("{{resourceGroup}}", "$ResourceGroupName") $swaConfig = $swaConfig.Replace("{{subscription-id}}", "$Subscription") -$swaConfig | Out-File -FilePath "$PSScriptRoot/../webapp/swa-cli.config.json" -Write-Host $(Get-Content "$PSScriptRoot/../webapp/swa-cli.config.json" -Raw) +$swaConfig | Out-File -FilePath "$PSScriptRoot/../../webapp/swa-cli.config.json" +Write-Host $(Get-Content "$PSScriptRoot/../../webapp/swa-cli.config.json" -Raw) -Push-Location -Path "$PSScriptRoot/../webapp" +Push-Location -Path "$PSScriptRoot/../../webapp" Write-Host "Installing yarn dependencies..." yarn install if ($LASTEXITCODE -ne 0) { diff --git a/scripts/deploy/deploy-webapp.sh b/scripts/deploy/deploy-webapp.sh old mode 100644 new mode 100755 index 783d2bbf4..fcbb87730 --- a/scripts/deploy/deploy-webapp.sh +++ b/scripts/deploy/deploy-webapp.sh @@ -94,7 +94,7 @@ echo "WEB_API_CLIENT_ID: $WEB_API_CLIENT_ID" eval WEB_API_SCOPE=$(az webapp config appsettings list --name $WEB_API_NAME --resource-group $RESOURCE_GROUP | jq '.[] | select(.name=="Authentication:AzureAd:Scopes").value') echo "WEB_API_SCOPE: $WEB_API_SCOPE" -ENV_FILE_PATH="$SCRIPT_ROOT/../webapp/.env" +ENV_FILE_PATH="$SCRIPT_ROOT/../../webapp/.env" echo "Writing environment variables to '$ENV_FILE_PATH'..." echo "REACT_APP_BACKEND_URI=https://$WEB_API_URL/" > $ENV_FILE_PATH echo "REACT_APP_AUTH_TYPE=AzureAd" >> $ENV_FILE_PATH @@ -103,8 +103,8 @@ echo "REACT_APP_AAD_CLIENT_ID=$APPLICATION_ID" >> $ENV_FILE_PATH echo "REACT_APP_AAD_API_SCOPE=api://$WEB_API_CLIENT_ID/$WEB_API_SCOPE" >> $ENV_FILE_PATH echo "Writing swa-cli.config.json..." -SWA_CONFIG_FILE_PATH="$SCRIPT_ROOT/../webapp/swa-cli.config.json" -SWA_CONFIG_TEMPLATE_FILE_PATH="$SCRIPT_ROOT/../webapp/template.swa-cli.config.json" +SWA_CONFIG_FILE_PATH="$SCRIPT_ROOT/../../webapp/swa-cli.config.json" +SWA_CONFIG_TEMPLATE_FILE_PATH="$SCRIPT_ROOT/../../webapp/template.swa-cli.config.json" swaConfig=`cat $SWA_CONFIG_TEMPLATE_FILE_PATH` swaConfig=$(echo $swaConfig | sed "s/{{appDevserverUrl}}/https:\/\/${WEB_APP_URL}/") swaConfig=$(echo $swaConfig | sed "s/{{appName}}/$WEB_API_NAME/") @@ -112,7 +112,7 @@ swaConfig=$(echo $swaConfig | sed "s/{{resourceGroup}}/$RESOURCE_GROUP/") swaConfig=$(echo $swaConfig | sed "s/{{subscription-id}}/$SUBSCRIPTION/") echo $swaConfig > $SWA_CONFIG_FILE_PATH -pushd "$SCRIPT_ROOT/../webapp" +pushd "$SCRIPT_ROOT/../../webapp" echo "Installing yarn dependencies..." yarn install diff --git a/scripts/deploy/main.bicep b/scripts/deploy/main.bicep index 1b7b7e09a..bba6c650e 100644 --- a/scripts/deploy/main.bicep +++ b/scripts/deploy/main.bicep @@ -244,27 +244,27 @@ resource appServiceWebConfig 'Microsoft.Web/sites/config@2022-09-01' = { value: deployCosmosDB ? cosmosAccount.listConnectionStrings().connectionStrings[0].connectionString : '' } { - name: 'MemoriesStore:Type' + name: 'MemoryStore:Type' value: memoryStore } { - name: 'MemoriesStore:Qdrant:Host' + name: 'MemoryStore:Qdrant:Host' value: memoryStore == 'Qdrant' ? 'https://${appServiceQdrant.properties.defaultHostName}' : '' } { - name: 'MemoriesStore:Qdrant:Port' + name: 'MemoryStore:Qdrant:Port' value: '443' } { - name: 'MemoriesStore:AzureCognitiveSearch:UseVectorSearch' + name: 'MemoryStore:AzureCognitiveSearch:UseVectorSearch' value: 'true' } { - name: 'MemoriesStore:AzureCognitiveSearch:Endpoint' + name: 'MemoryStore:AzureCognitiveSearch:Endpoint' value: memoryStore == 'AzureCognitiveSearch' ? 'https://${azureCognitiveSearch.name}.search.windows.net' : '' } { - name: 'MemoriesStore:AzureCognitiveSearch:Key' + name: 'MemoryStore:AzureCognitiveSearch:Key' value: memoryStore == 'AzureCognitiveSearch' ? azureCognitiveSearch.listAdminKeys().primaryKey : '' } { diff --git a/scripts/deploy/main.json b/scripts/deploy/main.json index 97b9f129a..1f02a82e8 100644 --- a/scripts/deploy/main.json +++ b/scripts/deploy/main.json @@ -362,27 +362,27 @@ "value": "[if(parameters('deployCosmosDB'), listConnectionStrings(resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('cosmos-{0}', variables('uniqueName')))), '2023-04-15').connectionStrings[0].connectionString, '')]" }, { - "name": "MemoriesStore:Type", + "name": "MemoryStore:Type", "value": "[parameters('memoryStore')]" }, { - "name": "MemoriesStore:Qdrant:Host", + "name": "MemoryStore:Qdrant:Host", "value": "[if(equals(parameters('memoryStore'), 'Qdrant'), format('https://{0}', reference(resourceId('Microsoft.Web/sites', format('app-{0}-qdrant', variables('uniqueName'))), '2022-09-01').defaultHostName), '')]" }, { - "name": "MemoriesStore:Qdrant:Port", + "name": "MemoryStore:Qdrant:Port", "value": "443" }, { - "name": "MemoriesStore:AzureCognitiveSearch:UseVectorSearch", + "name": "MemoryStore:AzureCognitiveSearch:UseVectorSearch", "value": "true" }, { - "name": "MemoriesStore:AzureCognitiveSearch:Endpoint", + "name": "MemoryStore:AzureCognitiveSearch:Endpoint", "value": "[if(equals(parameters('memoryStore'), 'AzureCognitiveSearch'), format('https://{0}.search.windows.net', format('acs-{0}', variables('uniqueName'))), '')]" }, { - "name": "MemoriesStore:AzureCognitiveSearch:Key", + "name": "MemoryStore:AzureCognitiveSearch:Key", "value": "[if(equals(parameters('memoryStore'), 'AzureCognitiveSearch'), listAdminKeys(resourceId('Microsoft.Search/searchServices', format('acs-{0}', variables('uniqueName'))), '2022-09-01').primaryKey, '')]" }, { diff --git a/scripts/deploy/package-webapi.ps1 b/scripts/deploy/package-webapi.ps1 old mode 100644 new mode 100755 diff --git a/webapi/Controllers/ServiceOptionsController.cs b/webapi/Controllers/ServiceOptionsController.cs index 3e67a86e6..e4f71e2e9 100644 --- a/webapi/Controllers/ServiceOptionsController.cs +++ b/webapi/Controllers/ServiceOptionsController.cs @@ -18,14 +18,14 @@ public class ServiceOptionsController : ControllerBase { private readonly ILogger _logger; - private readonly MemoriesStoreOptions _memoriesStoreOptions; + private readonly MemoryStoreOptions _memoryStoreOptions; public ServiceOptionsController( ILogger logger, - IOptions memoriesStoreOptions) + IOptions memoryStoreOptions) { this._logger = logger; - this._memoriesStoreOptions = memoriesStoreOptions.Value; + this._memoryStoreOptions = memoryStoreOptions.Value; } // TODO: [Issue #95] Include all service options in a single response. @@ -40,10 +40,10 @@ public IActionResult GetServiceOptions() return this.Ok( new ServiceOptionsResponse() { - MemoriesStore = new MemoriesStoreOptionResponse() + MemoryStore = new MemoryStoreOptionResponse() { - Types = Enum.GetNames(typeof(MemoriesStoreOptions.MemoriesStoreType)), - SelectedType = this._memoriesStoreOptions.Type.ToString() + Types = Enum.GetNames(typeof(MemoryStoreOptions.MemoryStoreType)), + SelectedType = this._memoryStoreOptions.Type.ToString() } } ); diff --git a/webapi/CopilotChatWebApi.csproj b/webapi/CopilotChatWebApi.csproj index e966e2c47..3386af3be 100644 --- a/webapi/CopilotChatWebApi.csproj +++ b/webapi/CopilotChatWebApi.csproj @@ -13,6 +13,7 @@ + diff --git a/webapi/Extensions/SemanticKernelExtensions.cs b/webapi/Extensions/SemanticKernelExtensions.cs index 82fce1721..034968784 100644 --- a/webapi/Extensions/SemanticKernelExtensions.cs +++ b/webapi/Extensions/SemanticKernelExtensions.cs @@ -22,7 +22,7 @@ using Microsoft.SemanticKernel.Orchestration; using Microsoft.SemanticKernel.Skills.Core; using Microsoft.SemanticKernel.TemplateEngine; -using static CopilotChat.WebApi.Options.MemoriesStoreOptions; +using static CopilotChat.WebApi.Options.MemoryStoreOptions; namespace CopilotChat.WebApi.Extensions; @@ -155,18 +155,18 @@ private static Task RegisterSkillsAsync(IServiceProvider sp, IKernel kernel) /// private static void AddSemanticTextMemory(this IServiceCollection services) { - MemoriesStoreOptions config = services.BuildServiceProvider().GetRequiredService>().Value; + MemoryStoreOptions config = services.BuildServiceProvider().GetRequiredService>().Value; switch (config.Type) { - case MemoriesStoreType.Volatile: + case MemoryStoreType.Volatile: services.AddSingleton(); break; - case MemoriesStoreType.Qdrant: + case MemoryStoreType.Qdrant: if (config.Qdrant == null) { - throw new InvalidOperationException("MemoriesStore type is Qdrant and Qdrant configuration is null."); + throw new InvalidOperationException("MemoryStore type is Qdrant and Qdrant configuration is null."); } services.AddSingleton(sp => @@ -189,10 +189,10 @@ private static void AddSemanticTextMemory(this IServiceCollection services) }); break; - case MemoriesStoreType.AzureCognitiveSearch: + case MemoryStoreType.AzureCognitiveSearch: if (config.AzureCognitiveSearch == null) { - throw new InvalidOperationException("MemoriesStore type is AzureCognitiveSearch and AzureCognitiveSearch configuration is null."); + throw new InvalidOperationException("MemoryStore type is AzureCognitiveSearch and AzureCognitiveSearch configuration is null."); } services.AddSingleton(sp => @@ -201,10 +201,10 @@ private static void AddSemanticTextMemory(this IServiceCollection services) }); break; - case MemoriesStoreOptions.MemoriesStoreType.Chroma: + case MemoryStoreOptions.MemoryStoreType.Chroma: if (config.Chroma == null) { - throw new InvalidOperationException("MemoriesStore type is Chroma and Chroma configuration is null."); + throw new InvalidOperationException("MemoryStore type is Chroma and Chroma configuration is null."); } services.AddSingleton(sp => @@ -222,7 +222,7 @@ private static void AddSemanticTextMemory(this IServiceCollection services) break; default: - throw new InvalidOperationException($"Invalid 'MemoriesStore' type '{config.Type}'."); + throw new InvalidOperationException($"Invalid 'MemoryStore' type '{config.Type}'."); } services.AddScoped(sp => new SemanticTextMemory( diff --git a/webapi/Extensions/ServiceExtensions.cs b/webapi/Extensions/ServiceExtensions.cs index 3f9fcb897..2c3a94e46 100644 --- a/webapi/Extensions/ServiceExtensions.cs +++ b/webapi/Extensions/ServiceExtensions.cs @@ -48,8 +48,8 @@ public static IServiceCollection AddOptions(this IServiceCollection services, Co .PostConfigure(TrimStringProperties); // Memory store configuration - services.AddOptions() - .Bind(configuration.GetSection(MemoriesStoreOptions.PropertyName)) + services.AddOptions() + .Bind(configuration.GetSection(MemoryStoreOptions.PropertyName)) .ValidateDataAnnotations() .ValidateOnStart() .PostConfigure(TrimStringProperties); diff --git a/webapi/Models/Response/ProposedPlan.cs b/webapi/Models/Response/ProposedPlan.cs index c8e580936..a2070e68b 100644 --- a/webapi/Models/Response/ProposedPlan.cs +++ b/webapi/Models/Response/ProposedPlan.cs @@ -10,6 +10,7 @@ public enum PlanType { Action, // single-step Sequential, // multi-step + Stepwise, // MRKL style planning } // State of Plan diff --git a/webapi/Models/Response/ServiceOptionsResponse.cs b/webapi/Models/Response/ServiceOptionsResponse.cs index e6a124409..d5eda9cba 100644 --- a/webapi/Models/Response/ServiceOptionsResponse.cs +++ b/webapi/Models/Response/ServiceOptionsResponse.cs @@ -9,25 +9,25 @@ namespace CopilotChat.WebApi.Models.Response; public class ServiceOptionsResponse { /// - /// The memories store that is configured. + /// Configured memory store. /// - [JsonPropertyName("memoriesStore")] - public MemoriesStoreOptionResponse MemoriesStore { get; set; } = new MemoriesStoreOptionResponse(); + [JsonPropertyName("memoryStore")] + public MemoryStoreOptionResponse MemoryStore { get; set; } = new MemoryStoreOptionResponse(); } /// -/// Response to memoriesStoreType request. +/// Response to memoryStoreType request. /// -public class MemoriesStoreOptionResponse +public class MemoryStoreOptionResponse { /// - /// All the available memories store types. + /// All the available memory store types. /// [JsonPropertyName("types")] public IEnumerable Types { get; set; } = Enumerable.Empty(); /// - /// The selected memories store type. + /// The selected memory store type. /// [JsonPropertyName("selectedType")] public string SelectedType { get; set; } = string.Empty; diff --git a/webapi/Options/MemoriesStoreOptions.cs b/webapi/Options/MemoriesStoreOptions.cs deleted file mode 100644 index 7486b4cba..000000000 --- a/webapi/Options/MemoriesStoreOptions.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace CopilotChat.WebApi.Options; - -/// -/// Configuration settings for the memories store. -/// -public class MemoriesStoreOptions -{ - public const string PropertyName = "MemoriesStore"; - - /// - /// The type of memories store to use. - /// - public enum MemoriesStoreType - { - /// - /// Non-persistent memories store. - /// - Volatile, - - /// - /// Qdrant based persistent memories store. - /// - Qdrant, - - /// - /// Azure Cognitive Search persistent memories store. - /// - AzureCognitiveSearch, - - /// - /// Chroma DB persistent memories store. - /// - Chroma - } - - /// - /// Gets or sets the type of memories store to use. - /// - public MemoriesStoreType Type { get; set; } = MemoriesStoreType.Volatile; - - /// - /// Gets or sets the configuration for the Qdrant memories store. - /// - [RequiredOnPropertyValue(nameof(Type), MemoriesStoreType.Qdrant)] - public QdrantOptions? Qdrant { get; set; } - - /// - /// Gets or sets the configuration for the Chroma memories store. - /// - [RequiredOnPropertyValue(nameof(Type), MemoriesStoreType.Chroma)] - public VectorMemoryWebOptions? Chroma { get; set; } - - /// - /// Gets or sets the configuration for the Azure Cognitive Search memories store. - /// - [RequiredOnPropertyValue(nameof(Type), MemoriesStoreType.AzureCognitiveSearch)] - public AzureCognitiveSearchOptions? AzureCognitiveSearch { get; set; } -} diff --git a/webapi/Options/MemoryStoreOptions.cs b/webapi/Options/MemoryStoreOptions.cs new file mode 100644 index 000000000..a55da230a --- /dev/null +++ b/webapi/Options/MemoryStoreOptions.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace CopilotChat.WebApi.Options; + +/// +/// Configuration settings for the memory store. +/// +public class MemoryStoreOptions +{ + public const string PropertyName = "MemoryStore"; + + /// + /// The type of memory store to use. + /// + public enum MemoryStoreType + { + /// + /// Non-persistent memory store. + /// + Volatile, + + /// + /// Qdrant based persistent memory store. + /// + Qdrant, + + /// + /// Azure Cognitive Search persistent memory store. + /// + AzureCognitiveSearch, + + /// + /// Chroma DB persistent memory store. + /// + Chroma + } + + /// + /// Gets or sets the type of memory store to use. + /// + public MemoryStoreType Type { get; set; } = MemoryStoreType.Volatile; + + /// + /// Gets or sets the configuration for the Qdrant memory store. + /// + [RequiredOnPropertyValue(nameof(Type), MemoryStoreType.Qdrant)] + public QdrantOptions? Qdrant { get; set; } + + /// + /// Gets or sets the configuration for the Chroma memory store. + /// + [RequiredOnPropertyValue(nameof(Type), MemoryStoreType.Chroma)] + public VectorMemoryWebOptions? Chroma { get; set; } + + /// + /// Gets or sets the configuration for the Azure Cognitive Search memory store. + /// + [RequiredOnPropertyValue(nameof(Type), MemoryStoreType.AzureCognitiveSearch)] + public AzureCognitiveSearchOptions? AzureCognitiveSearch { get; set; } +} diff --git a/webapi/Options/PlannerOptions.cs b/webapi/Options/PlannerOptions.cs index 0e24cdbc6..7d9c79d3f 100644 --- a/webapi/Options/PlannerOptions.cs +++ b/webapi/Options/PlannerOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.ComponentModel.DataAnnotations; using CopilotChat.WebApi.Models.Response; +using Microsoft.SemanticKernel.Planning.Stepwise; namespace CopilotChat.WebApi.Options; @@ -32,7 +33,7 @@ public class MissingFunctionErrorOptions public const string PropertyName = "Planner"; /// - /// Define if the planner must be Sequential or not. + /// The type of planner to used to create plan. /// [Required] public PlanType Type { get; set; } = PlanType.Action; @@ -52,4 +53,10 @@ public class MissingFunctionErrorOptions /// Whether to retry plan creation if LLM returned response that doesn't contain valid plan (e.g., invalid XML or JSON, contains missing function, etc.). /// public bool AllowRetriesOnInvalidPlan { get; set; } = true; + + /// + /// The configuration for the stepwise planner. + /// + [RequiredOnPropertyValue(nameof(Type), PlanType.Stepwise)] + public StepwisePlannerConfig StepwisePlannerConfig { get; set; } = new StepwisePlannerConfig(); } diff --git a/webapi/Skills/ChatSkills/CopilotChatPlanner.cs b/webapi/Skills/ChatSkills/CopilotChatPlanner.cs index 17d8abf00..4841e0d55 100644 --- a/webapi/Skills/ChatSkills/CopilotChatPlanner.cs +++ b/webapi/Skills/ChatSkills/CopilotChatPlanner.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -9,6 +10,7 @@ using CopilotChat.WebApi.Options; using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Orchestration; using Microsoft.SemanticKernel.Planning; using Microsoft.SemanticKernel.Planning.Sequential; using Microsoft.SemanticKernel.SkillDefinition; @@ -39,7 +41,7 @@ public class CopilotChatPlanner /// Flag to indicate that a variable is unknown and needs to be filled in by the user. /// This is used to flag any inputs that had dependencies from removed steps. /// - private const string UNKNOWN_VARIABLE_FLAG = "$???"; + private const string UnknownVariableFlag = "$???"; /// /// Regex to match variable names from plan parameters. @@ -47,7 +49,13 @@ public class CopilotChatPlanner /// Matches: $variableName, $variable_name, $variable-name, $some_variable_Name, $variableName123, $variableName_123, $variableName-123 /// Does not match: $123variableName, $100 $200 /// - private const string VARIABLE_REGEX = @"\$([A-Za-z]+[_-]*[\w]+)"; + private const string VariableRegex = @"\$([A-Za-z]+[_-]*[\w]+)"; + + /// + /// Supplemental text to add to the plan goal if PlannerOptions.Type is set to Stepwise. + /// Helps the planner know when to bail out to request additional user input. + /// + private const string StepwisePlannerSupplement = "If you need more information to fulfill this request, return with a request for additional user input."; /// /// Initializes a new instance of the class. @@ -74,8 +82,12 @@ public async Task CreatePlanAsync(string goal, ILogger logger) return new Plan(goal); } - Plan plan = this._plannerOptions?.Type == PlanType.Sequential - ? await new SequentialPlanner( + Plan plan; + + switch (this._plannerOptions?.Type) + { + case PlanType.Sequential: + plan = await new SequentialPlanner( this.Kernel, new SequentialPlannerConfig { @@ -83,12 +95,52 @@ public async Task CreatePlanAsync(string goal, ILogger logger) // Allow plan to be created with missing functions AllowMissingFunctions = this._plannerOptions?.MissingFunctionError.AllowRetries ?? false } - ).CreatePlanAsync(goal) - : await new ActionPlanner(this.Kernel).CreatePlanAsync(goal); + ).CreatePlanAsync(goal); + break; + default: + plan = await new ActionPlanner(this.Kernel).CreatePlanAsync(goal); + break; + } return this._plannerOptions!.MissingFunctionError.AllowRetries ? this.SanitizePlan(plan, plannerFunctionsView, logger) : plan; } + /// + /// Run the stepwise planner. + /// + /// The goal containing user intent and ask context. + /// The context to run the plan in. + public async Task RunStepwisePlannerAsync(string goal, SKContext context) + { + var config = new Microsoft.SemanticKernel.Planning.Stepwise.StepwisePlannerConfig() + { + MaxTokens = this._plannerOptions?.StepwisePlannerConfig.MaxTokens ?? 2048, + MaxIterations = this._plannerOptions?.StepwisePlannerConfig.MaxIterations ?? 15, + MinIterationTimeMs = this._plannerOptions?.StepwisePlannerConfig.MinIterationTimeMs ?? 1500 + }; + + Stopwatch sw = new(); + sw.Start(); + + try + { + var plan = new StepwisePlanner( + this.Kernel, + config + ).CreatePlan(string.Join("\n", goal, StepwisePlannerSupplement)); + var result = await plan.InvokeAsync(context); + + sw.Stop(); + result.Variables.Set("timeTaken", sw.Elapsed.ToString()); + return result; + } + catch (Exception e) + { + context.Logger.LogError(e, "Error running stepwise planner"); + throw; + } + } + #region Private /// @@ -112,7 +164,7 @@ private Plan SanitizePlan(Plan plan, FunctionsView availableFunctions, ILogger l availableOutputs.AddRange(step.Outputs); // Regex to match variable names - Regex variableRegEx = new(VARIABLE_REGEX, RegexOptions.Singleline); + Regex variableRegEx = new(VariableRegex, RegexOptions.Singleline); // Check for any inputs that may have dependencies from removed steps foreach (var input in step.Parameters) @@ -133,7 +185,7 @@ private Plan SanitizePlan(Plan plan, FunctionsView availableFunctions, ILogger l && inputVariableMatch.Groups[1].Captures.Count == 1 && !unavailableOutputs.Any(output => string.Equals(output, inputVariableValue, StringComparison.OrdinalIgnoreCase)) ? "$PLAN.RESULT" // TODO: [Issue #2256] Extract constants from Plan class, requires change on kernel team - : UNKNOWN_VARIABLE_FLAG; + : UnknownVariableFlag; step.Parameters.Set(input.Key, Regex.Replace(input.Value, variableRegEx.ToString(), overrideValue)); } } diff --git a/webapi/Skills/ChatSkills/ExternalInformationSkill.cs b/webapi/Skills/ChatSkills/ExternalInformationSkill.cs index d08bfd3b0..bfa23b211 100644 --- a/webapi/Skills/ChatSkills/ExternalInformationSkill.cs +++ b/webapi/Skills/ChatSkills/ExternalInformationSkill.cs @@ -85,6 +85,15 @@ public async Task AcquireExternalInformationAsync( return string.Empty; } + var contextString = string.Join("\n", context.Variables.Where(v => v.Key != "userIntent").Select(v => $"{v.Key}: {v.Value}")); + var goal = $"Given the following context, accomplish the user intent.\nContext:\n{contextString}\nUser Intent:{userIntent}"; + if (this._planner.PlannerOptions?.Type == PlanType.Stepwise) + { + var newPlanContext = context.Clone(); + newPlanContext = await this._planner.RunStepwisePlannerAsync(goal, context); + return $"{PromptPreamble}\n{newPlanContext.Variables.Input.Trim()}\n{PromptPostamble}\n"; + } + // Check if plan exists in ask's context variables. var planExists = context.Variables.TryGetValue("proposedPlan", out string? proposedPlanJson); var deserializedPlan = planExists && !string.IsNullOrWhiteSpace(proposedPlanJson) ? JsonSerializer.Deserialize(proposedPlanJson) : null; @@ -127,7 +136,6 @@ public async Task AcquireExternalInformationAsync( else { // Create a plan and set it in context for approval. - var contextString = string.Join("\n", context.Variables.Where(v => v.Key != "userIntent").Select(v => $"{v.Key}: {v.Value}")); Plan? plan = null; // Use default planner options if planner options are null. var plannerOptions = this._planner.PlannerOptions ?? new PlannerOptions(); @@ -139,7 +147,7 @@ public async Task AcquireExternalInformationAsync( { // TODO: [Issue #2256] Remove InvalidPlan retry logic once Core team stabilizes planner try { - plan = await this._planner.CreatePlanAsync($"Given the following context, accomplish the user intent.\nContext:\n{contextString}\nUser Intent:{userIntent}", context.Logger); + plan = await this._planner.CreatePlanAsync(goal, context.Logger); } catch (Exception e) when (this.IsRetriableError(e)) { diff --git a/webapi/appsettings.json b/webapi/appsettings.json index 439877dde..b280e838a 100644 --- a/webapi/appsettings.json +++ b/webapi/appsettings.json @@ -49,15 +49,18 @@ // // Planner can determine which skill functions, if any, need to be used to fulfill a user's request. // https://learn.microsoft.com/en-us/semantic-kernel/concepts-sk/planner - // - Set Planner:Type to "Action" to use the single-step ActionPlanner (default) + // - Set Planner:Type to "Action" to use the single-step ActionPlanner // - Set Planner:Type to "Sequential" to enable the multi-step SequentialPlanner // Note: SequentialPlanner works best with `gpt-4`. See the "Enabling Sequential Planner" section in webapi/README.md for configuration instructions. + // - Set Planner:Type to "Stepwise" to enable MRKL style planning // - Set Planner:RelevancyThreshold to a decimal between 0 and 1.0. // "Planner": { "Type": "Sequential", - // Set RelevancyThreshold to a value >= 0.50 if using the SequentialPlanner with gpt-3.5-turbo. Ignored when Planner:Type is "Action" - "RelevancyThreshold": "0.80", + // The minimum relevancy score for a function to be considered. + // Set RelevancyThreshold to a value between 0 and 1 if using the SequentialPlanner or Stepwise planner with gpt-3.5-turbo. + // Ignored when Planner:Type is "Action" + "RelevancyThreshold": "0.25", // Whether to allow missing functions in the plan on creation then sanitize output. Functions are considered missing if they're not available in the planner's kernel's context. // If set to true, the plan will be created with missing functions as no-op steps that are filtered from the final proposed plan. // If this is set to false, the plan creation will fail if any functions are missing. @@ -66,7 +69,12 @@ "MaxRetriesAllowed": "3" // Max retries allowed on MissingFunctionsError. If set to 0, no retries will be attempted. }, // Whether to retry plan creation if LLM returned response with invalid plan. - "AllowRetriesOnInvalidPlan": "true" + "AllowRetriesOnInvalidPlan": "true", + "StepwisePlannerConfig": { + "MaxTokens": "2048", + "MaxIterations": "15", + "MinIterationTimeMs": "1500" + } }, // // Optional Azure Speech service configuration for providing Azure Speech access tokens. @@ -116,12 +124,12 @@ // Memory stores are used for storing new memories and retrieving semantically similar memories. // - Supported Types are "volatile", "qdrant", "azurecognitivesearch", or "chroma". // - When using Qdrant or Azure Cognitive Search, see ./README.md for deployment instructions. - // - Set "MemoriesStore:AzureCognitiveSearch:Key" using dotnet's user secrets (see above) - // (i.e. dotnet user-secrets set "MemoriesStore:AzureCognitiveSearch:Key" "MY_AZCOGSRCH_KEY") - // - Set "MemoriesStore:Qdrant:Key" using dotnet's user secrets (see above) if you are using a Qdrant Cloud instance. - // (i.e. dotnet user-secrets set "MemoriesStore:Qdrant:Key" "MY_QDRANTCLOUD_KEY") + // - Set "MemoryStore:AzureCognitiveSearch:Key" using dotnet's user secrets (see above) + // (i.e. dotnet user-secrets set "MemoryStore:AzureCognitiveSearch:Key" "MY_AZCOGSRCH_KEY") + // - Set "MemoryStore:Qdrant:Key" using dotnet's user secrets (see above) if you are using a Qdrant Cloud instance. + // (i.e. dotnet user-secrets set "MemoryStore:Qdrant:Key" "MY_QDRANTCLOUD_KEY") // - "MemoriesStore": { + "MemoryStore": { "Type": "volatile", "Qdrant": { "Host": "http://localhost", diff --git a/webapp/src/Constants.ts b/webapp/src/Constants.ts index 8a3def465..d741c1220 100644 --- a/webapp/src/Constants.ts +++ b/webapp/src/Constants.ts @@ -4,6 +4,7 @@ export const Constants = { app: { name: 'Copilot', updateCheckIntervalSeconds: 60 * 5, + CONNECTION_ALERT_ID: 'connection-alert', }, msal: { method: 'redirect', // 'redirect' | 'popup' @@ -49,4 +50,5 @@ export const Constants = { MANIFEST_PATH: '/.well-known/ai-plugin.json', }, KEYSTROKE_DEBOUNCE_TIME_MS: 250, + STEPWISE_RESULT_NOT_FOUND_REGEX: /(Result not found, review _stepsTaken to see what happened\.)\s+(\[{.*}])/g, }; diff --git a/webapp/src/checkEnv.ts b/webapp/src/checkEnv.ts index 22b31082f..3f46e5f62 100644 --- a/webapp/src/checkEnv.ts +++ b/webapp/src/checkEnv.ts @@ -1,9 +1,12 @@ import { AuthType } from './libs/auth/AuthHelper'; +/** + * Checks if all required environment variables are defined + * @returns {string[]} An array of missing environment variables + */ export const getMissingEnvVariables = () => { // Should be aligned with variables defined in .env.example const envVariables = ['REACT_APP_BACKEND_URI']; - const missingVariables = []; for (const variable of envVariables) { diff --git a/webapp/src/components/chat/prompt-dialog/PromptDialog.tsx b/webapp/src/components/chat/prompt-dialog/PromptDialog.tsx index 454c0fe07..50459ca0b 100644 --- a/webapp/src/components/chat/prompt-dialog/PromptDialog.tsx +++ b/webapp/src/components/chat/prompt-dialog/PromptDialog.tsx @@ -19,10 +19,13 @@ import { } from '@fluentui/react-components'; import { Info16Regular } from '@fluentui/react-icons'; import React from 'react'; +import { Constants } from '../../../Constants'; import { BotResponsePrompt, PromptSectionsNameMap } from '../../../libs/models/BotResponsePrompt'; import { IChatMessage } from '../../../libs/models/ChatMessage'; import { useDialogClasses } from '../../../styles'; import { TokenUsageGraph } from '../../token-usage/TokenUsageGraph'; +import { formatParagraphTextContent } from '../../utils/TextUtils'; +import { StepwiseThoughtProcess } from './stepwise-planner/StepwiseThoughtProcess'; const useClasses = makeStyles({ prompt: { @@ -50,18 +53,20 @@ export const PromptDialog: React.FC = ({ message }) => { } catch (e) { prompt = message.prompt ?? ''; } - let promptDetails; if (typeof prompt === 'string') { promptDetails = prompt.split('\n').map((paragraph, idx) =>

{paragraph}

); } else { promptDetails = Object.entries(prompt).map(([key, value]) => { + const isStepwiseThoughtProcess = Constants.STEPWISE_RESULT_NOT_FOUND_REGEX.test(value as string); return value ? (
{PromptSectionsNameMap[key]} - {(value as string).split('\n').map((paragraph, idx) => ( -

{paragraph}

- ))} + {isStepwiseThoughtProcess ? ( + + ) : ( + formatParagraphTextContent(value as string) + )}
) : null; }); diff --git a/webapp/src/components/chat/prompt-dialog/stepwise-planner/StepwiseStepView.tsx b/webapp/src/components/chat/prompt-dialog/stepwise-planner/StepwiseStepView.tsx new file mode 100644 index 000000000..160526939 --- /dev/null +++ b/webapp/src/components/chat/prompt-dialog/stepwise-planner/StepwiseStepView.tsx @@ -0,0 +1,94 @@ +import { + AccordionHeader, + AccordionItem, + AccordionPanel, + Body1, + makeStyles, + shorthands, + tokens, +} from '@fluentui/react-components'; +import { StepwiseStep } from '../../../../libs/models/StepwiseStep'; +import { formatParagraphTextContent } from '../../../utils/TextUtils'; + +const useClasses = makeStyles({ + root: { + display: 'flex', + ...shorthands.gap(tokens.spacingHorizontalM), + }, + accordionItem: { + width: '99%', + }, + header: { + width: '100%', + /* Styles for the button within the header */ + '& button': { + alignItems: 'flex-start', + minHeight: '-webkit-fill-available', + paddingLeft: tokens.spacingHorizontalNone, + }, + }, +}); + +interface IStepwiseStepViewProps { + step: StepwiseStep; + index: number; +} + +export const StepwiseStepView: React.FC = ({ step, index }) => { + const classes = useClasses(); + + let header = `[OBSERVATION] ${step.observation}`; + let details: string | undefined; + + if (step.thought) { + const thoughtRegEx = /\[(THOUGHT|QUESTION|ACTION)](\s*(.*))*/g; + let thought = step.thought.match(thoughtRegEx)?.[0] ?? `[THOUGHT] ${step.thought}`; + + // Only show the first sentence of the thought in the header. + // Show the rest as details. + const firstSentenceIndex = thought.indexOf('. '); + if (firstSentenceIndex > 0) { + details = thought.substring(firstSentenceIndex + 2); + thought = thought.substring(0, firstSentenceIndex + 1); + } + + header = thought; + } + + if (step.action) { + header = `[ACTION] ${step.action}`; + + // Format the action variables and observation. + const variables = step.action_variables + ? 'Action variables: \n' + + Object.entries(step.action_variables) + .map(([key, value]) => `\r${key}: ${value}`) + .join('\n') + : ''; + + // Remove the [ACTION] tag from the thought and remove any code block formatting. + details = step.thought.replace('[ACTION]', '').replaceAll('```', '') + '\n'; + + // Parse any unicode quotation characters in the observation. + const observation = step.observation?.replaceAll(/\\{0,2}u0022/g, '"'); + details = details.concat(variables, `\nObservation: \n\r${observation}`); + } + + return ( +
+ {index + 1}. + + {details ? ( + <> + + {header} + + {formatParagraphTextContent(details)} + + ) : ( + {header} + )} + +
+ ); +}; diff --git a/webapp/src/components/chat/prompt-dialog/stepwise-planner/StepwiseThoughtProcess.tsx b/webapp/src/components/chat/prompt-dialog/stepwise-planner/StepwiseThoughtProcess.tsx new file mode 100644 index 000000000..709f20d64 --- /dev/null +++ b/webapp/src/components/chat/prompt-dialog/stepwise-planner/StepwiseThoughtProcess.tsx @@ -0,0 +1,42 @@ +import { Accordion, Body1, makeStyles, mergeClasses, shorthands, tokens } from '@fluentui/react-components'; +import { Constants } from '../../../../Constants'; +import { StepwiseStep } from '../../../../libs/models/StepwiseStep'; +import { StepwiseStepView } from './StepwiseStepView'; + +const useClasses = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + ...shorthands.gap(tokens.spacingHorizontalSNudge), + }, + header: { + paddingTop: tokens.spacingVerticalS, + }, +}); + +interface IStepwiseThoughtProcessProps { + stepwiseResult: string; +} + +export const StepwiseThoughtProcess: React.FC = ({ stepwiseResult }) => { + const classes = useClasses(); + + const matches = stepwiseResult.matchAll(Constants.STEPWISE_RESULT_NOT_FOUND_REGEX); + const matchGroups = Array.from(matches); + if (matchGroups.length > 0) { + const steps = JSON.parse(matchGroups[0][2]) as StepwiseStep[]; + return ( +
+ {matchGroups[0][1]} + [THOUGHT PROCESS] + + {steps.map((step, index) => { + return ; + })} + +
+ ); + } + + return; +}; diff --git a/webapp/src/components/chat/tabs/DocumentsTab.tsx b/webapp/src/components/chat/tabs/DocumentsTab.tsx index b4e89c315..fd0afefd3 100644 --- a/webapp/src/components/chat/tabs/DocumentsTab.tsx +++ b/webapp/src/components/chat/tabs/DocumentsTab.tsx @@ -181,14 +181,14 @@ export const DocumentsTab: React.FC = () => { {/* Hardcode vector database as we don't support switching vector store dynamically now. */}
- - {serviceOptions.memoriesStore.types.map((storeType) => { + + {serviceOptions.memoryStore.types.map((storeType) => { return ( ); })} diff --git a/webapp/src/components/token-usage/TokenUsageGraph.tsx b/webapp/src/components/token-usage/TokenUsageGraph.tsx index 697ab01be..4ba0529cb 100644 --- a/webapp/src/components/token-usage/TokenUsageGraph.tsx +++ b/webapp/src/components/token-usage/TokenUsageGraph.tsx @@ -63,7 +63,8 @@ const contrastColors = [ export const TokenUsageGraph: React.FC = ({ promptView, tokenUsage }) => { const classes = useClasses(); const { conversations, selectedId } = useAppSelector((state: RootState) => state.conversations); - const loadingResponse = conversations[selectedId].botResponseStatus && Object.entries(tokenUsage).length === 0; + const loadingResponse = + selectedId !== '' && conversations[selectedId].botResponseStatus && Object.entries(tokenUsage).length === 0; const responseGenerationView: TokenUsageView = {}; const memoryGenerationView: TokenUsageView = {}; diff --git a/webapp/src/components/utils/TextUtils.ts b/webapp/src/components/utils/TextUtils.tsx similarity index 76% rename from webapp/src/components/utils/TextUtils.ts rename to webapp/src/components/utils/TextUtils.tsx index 7a588a754..ec986de25 100644 --- a/webapp/src/components/utils/TextUtils.ts +++ b/webapp/src/components/utils/TextUtils.tsx @@ -1,3 +1,5 @@ +import { Body1, tokens } from '@fluentui/react-components'; + /* * Function to detect and convert URLs within a string into clickable links. * It wraps each link matched with anchor tags and applies safe href attributes. @@ -66,3 +68,29 @@ export function formatChatTextContent(messageContent: string) { .replace(/^!sk:.*$/gm, (match: string) => createCommandLink(match)); return contentAsString; } + +/* + * Formats text containing `\n` or `\r` into paragraphs. + */ +export function formatParagraphTextContent(messageContent: string) { + messageContent = messageContent.replaceAll('\r\n', '\n\r'); + return ( + + {messageContent.split('\n').map((paragraph, idx) => ( +

+ {paragraph} +

+ ))} +
+ ); +} diff --git a/webapp/src/components/views/BackendProbe.tsx b/webapp/src/components/views/BackendProbe.tsx index a4eec1239..266963b86 100644 --- a/webapp/src/components/views/BackendProbe.tsx +++ b/webapp/src/components/views/BackendProbe.tsx @@ -20,7 +20,9 @@ const BackendProbe: FC = ({ uri, onBackendFound }) => { } }; - void fetchAsync(); + fetchAsync().catch(() => { + // Ignore - this page is just a probe, so we don't need to show any errors if backend is not found + }); }, 3000); return () => { diff --git a/webapp/src/libs/hooks/useChat.ts b/webapp/src/libs/hooks/useChat.ts index 23559a785..d75167237 100644 --- a/webapp/src/libs/hooks/useChat.ts +++ b/webapp/src/libs/hooks/useChat.ts @@ -73,21 +73,22 @@ export const useChat = () => { const createChat = async () => { const chatTitle = `Copilot @ ${new Date().toLocaleString()}`; - const accessToken = await AuthHelper.getSKaaSAccessToken(instance, inProgress); try { - await chatService.createChatAsync(chatTitle, accessToken).then((result: ICreateChatSessionResponse) => { - const newChat: ChatState = { - id: result.chatSession.id, - title: result.chatSession.title, - systemDescription: result.chatSession.systemDescription, - memoryBalance: result.chatSession.memoryBalance, - messages: [result.initialBotMessage], - users: [loggedInUser], - botProfilePicture: getBotProfilePicture(Object.keys(conversations).length), - input: '', - botResponseStatus: undefined, - userDataLoaded: false, - }; + await chatService + .createChatAsync(chatTitle, await AuthHelper.getSKaaSAccessToken(instance, inProgress)) + .then((result: ICreateChatSessionResponse) => { + const newChat: ChatState = { + id: result.chatSession.id, + title: result.chatSession.title, + systemDescription: result.chatSession.systemDescription, + memoryBalance: result.chatSession.memoryBalance, + messages: [result.initialBotMessage], + users: [loggedInUser], + botProfilePicture: getBotProfilePicture(Object.keys(conversations).length), + input: '', + botResponseStatus: undefined, + userDataLoaded: false, + }; dispatch(addConversation(newChat)); return newChat.id; @@ -139,8 +140,8 @@ export const useChat = () => { }; const loadChats = async () => { - const accessToken = await AuthHelper.getSKaaSAccessToken(instance, inProgress); try { + const accessToken = await AuthHelper.getSKaaSAccessToken(instance, inProgress); const chatSessions = await chatService.getAllChatsAsync(userId, accessToken); if (chatSessions.length > 0) { @@ -196,10 +197,9 @@ export const useChat = () => { }; const uploadBot = async (bot: Bot) => { - const accessToken = await AuthHelper.getSKaaSAccessToken(instance, inProgress); - botService - .uploadAsync(bot, accessToken) - .then(async (chatSession: IChatSession) => { + try { + const accessToken = await AuthHelper.getSKaaSAccessToken(instance, inProgress); + await botService.uploadAsync(bot, accessToken).then(async (chatSession: IChatSession) => { const chatMessages = await chatService.getChatMessagesAsync(chatSession.id, 0, 100, accessToken); const newChat = { @@ -212,11 +212,11 @@ export const useChat = () => { }; dispatch(addConversation(newChat)); - }) - .catch((e: any) => { - const errorMessage = `Unable to upload the bot. Details: ${getErrorDetails(e)}`; - dispatch(addAlert({ message: errorMessage, type: AlertType.Error })); }); + } catch (e: any) { + const errorMessage = `Unable to upload the bot. Details: ${getErrorDetails(e)}`; + dispatch(addAlert({ message: errorMessage, type: AlertType.Error })); + } }; const getBotProfilePicture = (index: number): string => { @@ -275,8 +275,8 @@ export const useChat = () => { }; const joinChat = async (chatId: string) => { - const accessToken = await AuthHelper.getSKaaSAccessToken(instance, inProgress); try { + const accessToken = await AuthHelper.getSKaaSAccessToken(instance, inProgress); await chatService.joinChatAsync(userId, chatId, accessToken).then(async (result: IChatSession) => { // Get chat messages const chatMessages = await chatService.getChatMessagesAsync(result.id, 0, 100, accessToken); @@ -308,9 +308,14 @@ export const useChat = () => { }; const editChat = async (chatId: string, title: string, syetemDescription: string, memoryBalance: number) => { - const accessToken = await AuthHelper.getSKaaSAccessToken(instance, inProgress); try { - await chatService.editChatAsync(chatId, title, syetemDescription, memoryBalance, accessToken); + await chatService.editChatAsync( + chatId, + title, + syetemDescription, + memoryBalance, + await AuthHelper.getSKaaSAccessToken(instance, inProgress), + ); } catch (e: any) { const errorMessage = `Error editing chat ${chatId}. Details: ${getErrorDetails(e)}`; dispatch(addAlert({ message: errorMessage, type: AlertType.Error })); @@ -318,9 +323,8 @@ export const useChat = () => { }; const getServiceOptions = async () => { - const accessToken = await AuthHelper.getSKaaSAccessToken(instance, inProgress); try { - return await chatService.getServiceOptionsAsync(accessToken); + return await chatService.getServiceOptionsAsync(await AuthHelper.getSKaaSAccessToken(instance, inProgress)); } catch (e: any) { const errorMessage = `Error getting service options. Details: ${getErrorDetails(e)}`; dispatch(addAlert({ message: errorMessage, type: AlertType.Error })); diff --git a/webapp/src/libs/models/BotResponsePrompt.ts b/webapp/src/libs/models/BotResponsePrompt.ts index 4714685d4..b266b61e8 100644 --- a/webapp/src/libs/models/BotResponsePrompt.ts +++ b/webapp/src/libs/models/BotResponsePrompt.ts @@ -27,7 +27,7 @@ export const PromptSectionsNameMap: Record = { audience: 'Audience', userIntent: 'User Intent', chatMemories: 'Chat Memories', - externalInformation: 'External Information', + externalInformation: 'Planner Results', chatHistory: 'Chat History', systemChatContinuation: 'System Chat Continuation', }; diff --git a/webapp/src/libs/models/ServiceOptions.ts b/webapp/src/libs/models/ServiceOptions.ts index 79172ac5f..f275ba5fb 100644 --- a/webapp/src/libs/models/ServiceOptions.ts +++ b/webapp/src/libs/models/ServiceOptions.ts @@ -1,10 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. -export interface MemoriesStore { +export interface MemoryStore { types: string[]; selectedType: string; } export interface ServiceOptions { - memoriesStore: MemoriesStore; + memoryStore: MemoryStore; } diff --git a/webapp/src/libs/models/StepwiseStep.ts b/webapp/src/libs/models/StepwiseStep.ts new file mode 100644 index 000000000..08a7c9410 --- /dev/null +++ b/webapp/src/libs/models/StepwiseStep.ts @@ -0,0 +1,19 @@ +export interface StepwiseStep { + // The step number + thought: string; + + // The action of the step + action?: string; + + // The variables for the action + action_variables?: Record; + + // The output of the action + observation?: string; + + // The output of the system + final_answer?: string; + + // The raw response from the action + original_response: string; +} diff --git a/webapp/src/libs/models/TokenUsage.ts b/webapp/src/libs/models/TokenUsage.ts index 01e5c33d1..5d2c1d90d 100644 --- a/webapp/src/libs/models/TokenUsage.ts +++ b/webapp/src/libs/models/TokenUsage.ts @@ -20,6 +20,6 @@ export const TokenUsageFunctionNameMap: Record = { userIntentExtraction: 'User Intent Extraction', metaPromptTemplate: 'Meta Prompt Template', responseCompletion: 'Response Completion', - workingMemoryExtraction: 'Working Memory Extraction', - longTermMemoryExtraction: 'Long Term Memory Extraction', + workingMemoryExtraction: 'Working Memory Generation', + longTermMemoryExtraction: 'Long Term Memory Generation', }; diff --git a/webapp/src/redux/features/app/AppState.ts b/webapp/src/redux/features/app/AppState.ts index 9111b1a6c..f74f18477 100644 --- a/webapp/src/redux/features/app/AppState.ts +++ b/webapp/src/redux/features/app/AppState.ts @@ -14,6 +14,7 @@ export interface ActiveUserInfo { export interface Alert { message: string; type: AlertType; + id?: string; } interface Feature { @@ -137,5 +138,5 @@ export const initialState: AppState = { tokenUsage: {}, features: Features, settings: Settings, - serviceOptions: { memoriesStore: { types: [], selectedType: '' } }, + serviceOptions: { memoryStore: { types: [], selectedType: '' } }, }; diff --git a/webapp/src/redux/features/app/appSlice.ts b/webapp/src/redux/features/app/appSlice.ts index ff95e23b3..1d4ef00f2 100644 --- a/webapp/src/redux/features/app/appSlice.ts +++ b/webapp/src/redux/features/app/appSlice.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { Constants } from '../../../Constants'; import { ServiceOptions } from '../../../libs/models/ServiceOptions'; import { TokenUsage } from '../../../libs/models/TokenUsage'; import { ActiveUserInfo, Alert, AppState, FeatureKeys, initialState } from './AppState'; @@ -13,10 +14,14 @@ export const appSlice = createSlice({ state.alerts = action.payload; }, addAlert: (state: AppState, action: PayloadAction) => { - if (state.alerts.length === 3) { - state.alerts.shift(); + if ( + action.payload.id == Constants.app.CONNECTION_ALERT_ID || + isServerConnectionError(action.payload.message) + ) { + updateConnectionStatus(state, action.payload); + } else { + addNewAlert(state.alerts, action.payload); } - state.alerts.push(action.payload); }, removeAlert: (state: AppState, action: PayloadAction) => { state.alerts.splice(action.payload, 1); @@ -89,3 +94,38 @@ const getTotalTokenUsage = (previousSum?: number, current?: number) => { return previousSum + current; }; + +const isServerConnectionError = (message: string) => { + return ( + message.includes(`Cannot send data if the connection is not in the 'Connected' State.`) || + message.includes(`Server timeout elapsed without receiving a message from the server.`) + ); +}; + +const addNewAlert = (alerts: Alert[], newAlert: Alert) => { + if (alerts.length === 3) { + alerts.shift(); + } + + alerts.push(newAlert); +}; + +const updateConnectionStatus = (state: AppState, statusUpdate: Alert) => { + if (isServerConnectionError(statusUpdate.message)) { + statusUpdate.message = + // Constant message so alert UI doesn't feel glitchy on every connection error from SignalR + 'Cannot send data due to lost connection or server timeout. Try refreshing this page to restart the connection.'; + } + + // There should only ever be one connection alert at a time, + // so we tag the alert with a unique ID so we can remove if needed + statusUpdate.id ??= Constants.app.CONNECTION_ALERT_ID; + + // Remove the existing connection alert if it exists + const connectionAlertIndex = state.alerts.findIndex((alert) => alert.id === Constants.app.CONNECTION_ALERT_ID); + if (connectionAlertIndex !== -1) { + state.alerts.splice(connectionAlertIndex, 1); + } + + addNewAlert(state.alerts, statusUpdate); +}; diff --git a/webapp/src/redux/features/message-relay/signalRMiddleware.ts b/webapp/src/redux/features/message-relay/signalRMiddleware.ts index 8ffc237ef..6beaac3fd 100644 --- a/webapp/src/redux/features/message-relay/signalRMiddleware.ts +++ b/webapp/src/redux/features/message-relay/signalRMiddleware.ts @@ -2,6 +2,7 @@ import * as signalR from '@microsoft/signalr'; import { AnyAction, Dispatch } from '@reduxjs/toolkit'; +import { Constants } from '../../../Constants'; import { AlertType } from '../../../libs/models/AlertType'; import { IChatUser } from '../../../libs/models/ChatUser'; import { PlanState } from '../../../libs/models/Plan'; @@ -65,7 +66,13 @@ const registerCommonSignalConnectionEvents = (store: Store) => { hubConnection.onclose((error) => { if (hubConnection.state === signalR.HubConnectionState.Disconnected) { const errorMessage = 'Connection closed due to error. Try refreshing this page to restart the connection'; - store.dispatch(addAlert({ message: String(errorMessage), type: AlertType.Error })); + store.dispatch( + addAlert({ + message: String(errorMessage), + type: AlertType.Error, + id: Constants.app.CONNECTION_ALERT_ID, + }), + ); console.log(errorMessage, error); } }); @@ -73,15 +80,21 @@ const registerCommonSignalConnectionEvents = (store: Store) => { hubConnection.onreconnecting((error) => { if (hubConnection.state === signalR.HubConnectionState.Reconnecting) { const errorMessage = 'Connection lost due to error. Reconnecting...'; - store.dispatch(addAlert({ message: String(errorMessage), type: AlertType.Info })); + store.dispatch( + addAlert({ + message: String(errorMessage), + type: AlertType.Info, + id: Constants.app.CONNECTION_ALERT_ID, + }), + ); console.log(errorMessage, error); } }); hubConnection.onreconnected((connectionId = '') => { if (hubConnection.state === signalR.HubConnectionState.Connected) { - const message = 'Connection reestablished.'; - store.dispatch(addAlert({ message, type: AlertType.Success })); + const message = 'Connection reestablished. Please refresh the page to ensure you have the latest data.'; + store.dispatch(addAlert({ message, type: AlertType.Success, id: Constants.app.CONNECTION_ALERT_ID })); console.log(message + ` Connected with connectionId ${connectionId}`); } });