diff --git a/samples/apps/copilot-chat-app/webapi/CopilotChat/Models/ProposedPlan.cs b/samples/apps/copilot-chat-app/webapi/CopilotChat/Models/ProposedPlan.cs
index 40e97eadff..f84990b88c 100644
--- a/samples/apps/copilot-chat-app/webapi/CopilotChat/Models/ProposedPlan.cs
+++ b/samples/apps/copilot-chat-app/webapi/CopilotChat/Models/ProposedPlan.cs
@@ -5,6 +5,21 @@ using Microsoft.SemanticKernel.Planning;
namespace SemanticKernel.Service.CopilotChat.Models;
+// Type of Plan
+public enum PlanType
+{
+ Action, // single-step
+ Sequential, // multi-step
+}
+
+// State of Plan
+public enum PlanState
+{
+ NoOp, // Plan has not received any user input
+ Approved,
+ Rejected,
+}
+
///
/// Information about a single proposed plan.
///
@@ -16,12 +31,26 @@ public class ProposedPlan
[JsonPropertyName("proposedPlan")]
public Plan Plan { get; set; }
+ ///
+ /// Indicates whether plan is Action (single-step) or Sequential (multi-step).
+ ///
+ [JsonPropertyName("type")]
+ public PlanType Type { get; set; }
+
+ ///
+ /// State of plan
+ ///
+ [JsonPropertyName("state")]
+ public PlanState State { get; set; }
+
///
/// Create a new proposed plan.
///
/// Proposed plan object
- public ProposedPlan(Plan plan)
+ public ProposedPlan(Plan plan, PlanType type, PlanState state)
{
this.Plan = plan;
+ this.Type = type;
+ this.State = state;
}
}
diff --git a/samples/apps/copilot-chat-app/webapi/CopilotChat/Options/PlannerOptions.cs b/samples/apps/copilot-chat-app/webapi/CopilotChat/Options/PlannerOptions.cs
index c6a7858427..c2827035ad 100644
--- a/samples/apps/copilot-chat-app/webapi/CopilotChat/Options/PlannerOptions.cs
+++ b/samples/apps/copilot-chat-app/webapi/CopilotChat/Options/PlannerOptions.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel.DataAnnotations;
+using SemanticKernel.Service.CopilotChat.Models;
namespace SemanticKernel.Service.CopilotChat.Options;
@@ -14,5 +15,5 @@ public class PlannerOptions
/// Define if the planner must be Sequential or not.
///
[Required]
- public string Type { get; set; } = string.Empty;
+ public PlanType Type { get; set; } = PlanType.Action;
}
diff --git a/samples/apps/copilot-chat-app/webapi/CopilotChat/Skills/ChatSkills/ChatSkill.cs b/samples/apps/copilot-chat-app/webapi/CopilotChat/Skills/ChatSkills/ChatSkill.cs
index a73699d6bf..ff1738250f 100644
--- a/samples/apps/copilot-chat-app/webapi/CopilotChat/Skills/ChatSkills/ChatSkill.cs
+++ b/samples/apps/copilot-chat-app/webapi/CopilotChat/Skills/ChatSkills/ChatSkill.cs
@@ -238,6 +238,16 @@ public class ChatSkill
chatContext.Variables.Set("knowledgeCutoff", this._promptOptions.KnowledgeCutoffDate);
chatContext.Variables.Set("audience", chatContext["userName"]);
+ // Check if plan exists in ask's context variables.
+ // If plan was returned at this point, that means it was approved or cancelled.
+ // Update the response previously saved in chat history with state
+ if (context.Variables.TryGetValue("proposedPlan", out string? planJson)
+ && !string.IsNullOrWhiteSpace(planJson)
+ && context.Variables.TryGetValue("responseMessageId", out string? messageId))
+ {
+ await this.UpdateResponseAsync(planJson, messageId);
+ }
+
var response = chatContext.Variables.ContainsKey("userCancelledPlan")
? "I am sorry the plan did not meet your goals."
: await this.GetChatResponseAsync(chatContext);
@@ -258,7 +268,8 @@ public class ChatSkill
// Save this response to memory such that subsequent chat responses can use it
try
{
- var botMessage = await this.SaveNewResponseAsync(response, prompt, chatId);
+ ChatMessage botMessage = await this.SaveNewResponseAsync(response, prompt, chatId);
+ context.Variables.Set("messageId", botMessage.Id);
context.Variables.Set("messageType", botMessage.Type.ToString());
}
catch (Exception ex) when (!ex.IsCriticalException())
@@ -310,8 +321,7 @@ public class ChatSkill
// If plan is suggested, send back to user for approval before running
if (this._externalInformationSkill.ProposedPlan != null)
{
- return JsonSerializer.Serialize(
- new ProposedPlan(this._externalInformationSkill.ProposedPlan));
+ return JsonSerializer.Serialize(this._externalInformationSkill.ProposedPlan);
}
// 4. Query relevant semantic memories
@@ -380,6 +390,7 @@ public class ChatSkill
///
private async Task GetUserIntentAsync(SKContext context)
{
+ // TODO: Regenerate user intent if plan was modified
if (!context.Variables.TryGetValue("planUserIntent", out string? userIntent))
{
var contextVariables = new ContextVariables();
@@ -495,14 +506,10 @@ public class ChatSkill
///
/// Helper function create the correct context variables to acquire external information.
///
- private Task AcquireExternalInformationAsync(SKContext context, string userIntent, int tokenLimit)
+ private async Task AcquireExternalInformationAsync(SKContext context, string userIntent, int tokenLimit)
{
var contextVariables = context.Variables.Clone();
contextVariables.Set("tokenLimit", tokenLimit.ToString(new NumberFormatInfo()));
- if (context.Variables.TryGetValue("proposedPlan", out string? proposedPlan))
- {
- contextVariables.Set("proposedPlan", proposedPlan);
- }
var planContext = new SKContext(
contextVariables,
@@ -512,7 +519,7 @@ public class ChatSkill
context.CancellationToken
);
- var plan = this._externalInformationSkill.AcquireExternalInformationAsync(userIntent, planContext);
+ var plan = await this._externalInformationSkill.AcquireExternalInformationAsync(userIntent, planContext);
// Propagate the error
if (planContext.ErrorOccurred)
@@ -570,6 +577,20 @@ public class ChatSkill
return chatMessage;
}
+ ///
+ /// Updates previously saved response in the chat history.
+ ///
+ /// Updated response from the chat.
+ /// The chat message ID
+ private async Task UpdateResponseAsync(string updatedResponse, string messageId)
+ {
+ // Make sure the chat exists.
+ var chatMessage = await this._chatMessageRepository.FindByIdAsync(messageId);
+ chatMessage.Content = updatedResponse;
+
+ await this._chatMessageRepository.UpsertAsync(chatMessage);
+ }
+
///
/// Create a completion settings object for chat response. Parameters are read from the PromptSettings class.
///
diff --git a/samples/apps/copilot-chat-app/webapi/CopilotChat/Skills/ChatSkills/CopilotChatPlanner.cs b/samples/apps/copilot-chat-app/webapi/CopilotChat/Skills/ChatSkills/CopilotChatPlanner.cs
index 9989f61d11..f41106acee 100644
--- a/samples/apps/copilot-chat-app/webapi/CopilotChat/Skills/ChatSkills/CopilotChatPlanner.cs
+++ b/samples/apps/copilot-chat-app/webapi/CopilotChat/Skills/ChatSkills/CopilotChatPlanner.cs
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Planning;
using Microsoft.SemanticKernel.SkillDefinition;
+using SemanticKernel.Service.CopilotChat.Models;
using SemanticKernel.Service.CopilotChat.Options;
namespace SemanticKernel.Service.CopilotChat.Skills.ChatSkills;
@@ -23,6 +24,11 @@ public class CopilotChatPlanner
///
private readonly PlannerOptions? _plannerOptions;
+ ///
+ /// Gets the pptions for the planner.
+ ///
+ public PlannerOptions? PlannerOptions => this._plannerOptions;
+
///
/// Initializes a new instance of the class.
///
@@ -47,7 +53,7 @@ public class CopilotChatPlanner
return Task.FromResult(new Plan(goal));
}
- if (this._plannerOptions?.Type == "Sequential")
+ if (this._plannerOptions?.Type == PlanType.Sequential)
{
return new SequentialPlanner(this.Kernel).CreatePlanAsync(goal);
}
diff --git a/samples/apps/copilot-chat-app/webapi/CopilotChat/Skills/ChatSkills/ExternalInformationSkill.cs b/samples/apps/copilot-chat-app/webapi/CopilotChat/Skills/ChatSkills/ExternalInformationSkill.cs
index 9691633d8c..ccad3351c3 100644
--- a/samples/apps/copilot-chat-app/webapi/CopilotChat/Skills/ChatSkills/ExternalInformationSkill.cs
+++ b/samples/apps/copilot-chat-app/webapi/CopilotChat/Skills/ChatSkills/ExternalInformationSkill.cs
@@ -13,6 +13,7 @@ using Microsoft.Extensions.Options;
using Microsoft.SemanticKernel.Orchestration;
using Microsoft.SemanticKernel.Planning;
using Microsoft.SemanticKernel.SkillDefinition;
+using SemanticKernel.Service.CopilotChat.Models;
using SemanticKernel.Service.CopilotChat.Options;
using SemanticKernel.Service.CopilotChat.Skills.OpenApiSkills.GitHubSkill.Model;
using SemanticKernel.Service.CopilotChat.Skills.OpenApiSkills.JiraSkill.Model;
@@ -37,7 +38,7 @@ public class ExternalInformationSkill
///
/// Proposed plan to return for approval.
///
- public Plan? ProposedPlan { get; private set; }
+ public ProposedPlan? ProposedPlan { get; private set; }
///
/// Preamble to add to the related information text.
@@ -77,11 +78,13 @@ public class ExternalInformationSkill
}
// Check if plan exists in ask's context variables.
- // If plan was returned at this point, that means it was approved and should be run
- var planApproved = context.Variables.TryGetValue("proposedPlan", out string? planJson);
+ var planExists = context.Variables.TryGetValue("proposedPlan", out string? proposedPlanJson);
+ var deserializedPlan = planExists && !string.IsNullOrWhiteSpace(proposedPlanJson) ? JsonSerializer.Deserialize(proposedPlanJson) : null;
- if (planApproved && !string.IsNullOrWhiteSpace(planJson))
+ // Run plan if it was approved
+ if (deserializedPlan != null && deserializedPlan.State == PlanState.Approved)
{
+ string planJson = JsonSerializer.Serialize(deserializedPlan.Plan);
// Reload the plan with the planner's kernel so
// it has full context to be executed
var newPlanContext = new SKContext(
@@ -130,7 +133,7 @@ public class ExternalInformationSkill
Plan sanitizedPlan = this.SanitizePlan(plan, context);
sanitizedPlan.State.Update(plan.State);
- this.ProposedPlan = sanitizedPlan;
+ this.ProposedPlan = new ProposedPlan(sanitizedPlan, this._planner.PlannerOptions!.Type, PlanState.NoOp);
}
}
diff --git a/samples/apps/copilot-chat-app/webapi/README.md b/samples/apps/copilot-chat-app/webapi/README.md
index 38dc4cb0b3..08a800fa74 100644
--- a/samples/apps/copilot-chat-app/webapi/README.md
+++ b/samples/apps/copilot-chat-app/webapi/README.md
@@ -72,11 +72,16 @@ To enable sequential planner,
1. In [./webapi/appsettings.json](appsettings.json), set `"Type": "Sequential"` under the `Planner` section.
1. Then, set your preferred Planner model (`gpt-4` or `gpt-3.5-turbo`) under the `AIService` configuration section.
1. If using `gpt-4`, no other changes are required.
- 1. If using `gpt-3.5-turbo`, change [CopilotChatPlanner.cs](CopilotChat/Skills/ChatSkills/CopilotChatPlanner.cs) to initialize SequentialPlanner with a RelevancyThreshold*. The `CreatePlanAsync` method should return the following line if `this._plannerOptions?.Type == "Sequential"` is true:
- ```
- return new SequentialPlanner(this.Kernel, new SequentialPlannerConfig { RelevancyThreshold = 0.75 }).CreatePlanAsync(goal);
- ```
- \* The `RelevancyThreshold` is a number from 0 to 1 that represents how similar a goal is to a function's name/description/inputs. You want to tune that value when using SequentialPlanner to help keep things scoped while not missing on on things that are relevant or including too many things that really aren't. `0.75` is an arbitrary threshold and we recommend developers play around with this number to see what best fits their scenarios.
+ 1. If using `gpt-3.5-turbo`: change [CopilotChatPlanner.cs](CopilotChat/Skills/ChatSkills/CopilotChatPlanner.cs) to initialize SequentialPlanner with a RelevancyThreshold*.
+ - Add `using` statement to top of file:
+ ```
+ using Microsoft.SemanticKernel.Planning.Sequential;
+ ```
+ - The `CreatePlanAsync` method should return the following line if `this._plannerOptions?.Type == "Sequential"` is true:
+ ```
+ return new SequentialPlanner(this.Kernel, new SequentialPlannerConfig { RelevancyThreshold = 0.75 }).CreatePlanAsync(goal);
+ ```
+ \* The `RelevancyThreshold` is a number from 0 to 1 that represents how similar a goal is to a function's name/description/inputs. You want to tune that value when using SequentialPlanner to help keep things scoped while not missing on on things that are relevant or including too many things that really aren't. `0.75` is an arbitrary threshold and we recommend developers play around with this number to see what best fits their scenarios.
1. Restart the `webapi` - Copilot Chat should be now running locally with SequentialPlanner.
# (Optional) Enabling the Qdrant Memory Store
diff --git a/samples/apps/copilot-chat-app/webapp/src/components/chat/ChatHistoryItem.tsx b/samples/apps/copilot-chat-app/webapp/src/components/chat/ChatHistoryItem.tsx
index 5365587886..139b954b96 100644
--- a/samples/apps/copilot-chat-app/webapp/src/components/chat/ChatHistoryItem.tsx
+++ b/samples/apps/copilot-chat-app/webapp/src/components/chat/ChatHistoryItem.tsx
@@ -3,12 +3,11 @@
import { useMsal } from '@azure/msal-react';
import { Persona, Text, makeStyles, mergeClasses, shorthands, tokens } from '@fluentui/react-components';
import React from 'react';
-import { AuthorRoles, ChatMessageState, ChatMessageType, IChatMessage } from '../../libs/models/ChatMessage';
+import { AuthorRoles, IChatMessage } from '../../libs/models/ChatMessage';
import { GetResponseOptions, useChat } from '../../libs/useChat';
-import { parsePlan } from '../../libs/utils/PlanUtils';
-import { useAppDispatch, useAppSelector } from '../../redux/app/hooks';
+import { isPlan } from '../../libs/utils/PlanUtils';
+import { useAppSelector } from '../../redux/app/hooks';
import { RootState } from '../../redux/app/store';
-import { updateMessageState } from '../../redux/features/conversations/conversationsSlice';
import { Breakpoints } from '../../styles';
import { convertToAnchorTags, timestampToDateString } from '../utils/TextUtils';
import { PlanViewer } from './plan-viewer/PlanViewer';
@@ -82,53 +81,10 @@ export const ChatHistoryItem: React.FC = ({ message, getRe
const chat = useChat();
const { conversations, selectedId } = useAppSelector((state: RootState) => state.conversations);
- const dispatch = useAppDispatch();
- const plan = parsePlan(message.content);
- const isPlan = plan !== null;
+ const renderPlan = isPlan(message.content);
- // Initializing Plan action handlers here so we don't have to drill down data the components won't use otherwise
- const onPlanApproval = async () => {
- dispatch(
- updateMessageState({
- newMessageState: ChatMessageState.PlanApproved,
- messageIndex: messageIndex,
- chatId: selectedId,
- }),
- );
-
- // Extract plan from bot response
- const proposedPlan = JSON.parse(message.content).proposedPlan;
-
- // Invoke plan
- await getResponse({
- messageType: ChatMessageType.Plan,
- value: 'Yes, proceed',
- chatId: selectedId,
- approvedPlanJson: JSON.stringify(proposedPlan),
- planUserIntent: plan?.userIntent,
- });
- };
-
- const onPlanCancel = async () => {
- dispatch(
- updateMessageState({
- newMessageState: ChatMessageState.PlanRejected,
- messageIndex: messageIndex,
- chatId: selectedId,
- }),
- );
-
- // Bail out of plan
- await getResponse({
- messageType: ChatMessageType.Plan,
- value: 'No, cancel',
- chatId: selectedId,
- userCancelledPlan: true,
- });
- };
-
- const content = !isPlan
+ const content = !renderPlan
? (message.content as string)
.trim()
.replace(/[\u00A0-\u9999<>&]/g, function (i: string) {
@@ -150,35 +106,23 @@ export const ChatHistoryItem: React.FC = ({ message, getRe
: { name: fullName, color: 'colorful' as 'colorful' };
return (
- <>
-
+
+
+ Your app state has changed since this plan was generated, making it unreliable for the planner.
+ Please request a fresh plan to avoid potential conflicts.
+
+