Enabling Plan Edits in Copilot (#1386)

### Motivation and Context
This PR enables user to edit a plan before approving or rejecting it:
- User can edit any of the input value fields
- User can delete step(s) (If SequentialPlanner is enabled). Deletion is
disabled when there is only one step.

When plan is returned to the Kernel, the chat message will be updated in
the history repository to reflect the new plan state.

### Description
This PR also contains optimizations for methods handling user actions on
proposed plan and plan schema changes to enable the above.

Chat History will also now reflect any actions the user took on the
plan.

Input Edit Mode:
<img width="665" alt="image"
src="https://github.com/microsoft/semantic-kernel/assets/125500434/31b7a5b5-9ca0-432a-bde1-3141e0e8e76f">

Editing Action Plan (inputs only):

![UserPlanEdits](https://github.com/microsoft/semantic-kernel/assets/125500434/66b8c022-dcaf-469f-9426-928fafac4742)

Editing SequentialPlanner (inputs and deleting step):

![SequentialPlannerEditsExample](https://github.com/microsoft/semantic-kernel/assets/125500434/0ae757b5-7767-46b0-977c-2f69dae133ba)

Added 'NoOp' Plan state, where proposedPlan becomes unactionable if app
state changes before user approves or rejects the plan. Potential
reasons for unstable state: required plug-in no longer enabled, access
permissions changed, etc.

![image](https://github.com/microsoft/semantic-kernel/assets/125500434/2af07534-4560-4e11-a8fc-4804d85a4717)


> Note: there's some hallucinations with SequentialPlanner, but planner
default is Action. SequentialPlanner stabilizations will continue in
another PR
This commit is contained in:
Teresa Hoang
2023-06-12 19:03:25 +00:00
committed by GitHub
parent df07fc6f28
commit f175d03580
16 changed files with 485 additions and 255 deletions
@@ -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,
}
/// <summary>
/// Information about a single proposed plan.
/// </summary>
@@ -16,12 +31,26 @@ public class ProposedPlan
[JsonPropertyName("proposedPlan")]
public Plan Plan { get; set; }
/// <summary>
/// Indicates whether plan is Action (single-step) or Sequential (multi-step).
/// </summary>
[JsonPropertyName("type")]
public PlanType Type { get; set; }
/// <summary>
/// State of plan
/// </summary>
[JsonPropertyName("state")]
public PlanState State { get; set; }
/// <summary>
/// Create a new proposed plan.
/// </summary>
/// <param name="plan">Proposed plan object</param>
public ProposedPlan(Plan plan)
public ProposedPlan(Plan plan, PlanType type, PlanState state)
{
this.Plan = plan;
this.Type = type;
this.State = state;
}
}
@@ -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.
/// </summary>
[Required]
public string Type { get; set; } = string.Empty;
public PlanType Type { get; set; } = PlanType.Action;
}
@@ -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<ProposedPlan>(
new ProposedPlan(this._externalInformationSkill.ProposedPlan));
return JsonSerializer.Serialize<ProposedPlan>(this._externalInformationSkill.ProposedPlan);
}
// 4. Query relevant semantic memories
@@ -380,6 +390,7 @@ public class ChatSkill
/// </summary>
private async Task<string> 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
/// <summary>
/// Helper function create the correct context variables to acquire external information.
/// </summary>
private Task<string> AcquireExternalInformationAsync(SKContext context, string userIntent, int tokenLimit)
private async Task<string> 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;
}
/// <summary>
/// Updates previously saved response in the chat history.
/// </summary>
/// <param name="updatedResponse">Updated response from the chat.</param>
/// <param name="messageId">The chat message ID</param>
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);
}
/// <summary>
/// Create a completion settings object for chat response. Parameters are read from the PromptSettings class.
/// </summary>
@@ -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
/// </summary>
private readonly PlannerOptions? _plannerOptions;
/// <summary>
/// Gets the pptions for the planner.
/// </summary>
public PlannerOptions? PlannerOptions => this._plannerOptions;
/// <summary>
/// Initializes a new instance of the <see cref="CopilotChatPlanner"/> class.
/// </summary>
@@ -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);
}
@@ -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
/// <summary>
/// Proposed plan to return for approval.
/// </summary>
public Plan? ProposedPlan { get; private set; }
public ProposedPlan? ProposedPlan { get; private set; }
/// <summary>
/// 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<ProposedPlan>(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);
}
}
+10 -5
View File
@@ -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
@@ -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<ChatHistoryItemProps> = ({ 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<ChatHistoryItemProps> = ({ message, getRe
: { name: fullName, color: 'colorful' as 'colorful' };
return (
<>
<div
className={isMe ? mergeClasses(classes.root, classes.alignEnd) : classes.root}
data-testid={`chat-history-item-${messageIndex}`}
data-username={fullName}
>
{!isMe && <Persona className={classes.persona} avatar={avatar} presence={{ status: 'available' }} />}
<div className={isMe ? mergeClasses(classes.item, classes.me) : classes.item}>
<div className={classes.header}>
{!isMe && <Text weight="semibold">{fullName}</Text>}
<Text className={classes.time}>{timestampToDateString(message.timestamp, true)}</Text>
{isBot && <PromptDetails message={message} />}
</div>
{!isPlan && (
<div
className={classes.content}
dangerouslySetInnerHTML={{ __html: convertToAnchorTags(content) }}
/>
)}
{isPlan && (
<PlanViewer
plan={plan}
planState={message.state ?? ChatMessageState.NoOp}
onSubmit={onPlanApproval}
onCancel={onPlanCancel}
/>
)}
<div className={isMe ? mergeClasses(classes.root, classes.alignEnd) : classes.root}>
{!isMe && <Persona className={classes.persona} avatar={avatar} presence={{ status: 'available' }} />}
<div className={isMe ? mergeClasses(classes.item, classes.me) : classes.item}>
<div className={classes.header}>
{!isMe && <Text weight="semibold">{fullName}</Text>}
<Text className={classes.time}>{timestampToDateString(message.timestamp, true)}</Text>
{isBot && <PromptDetails message={message} />}
</div>
{renderPlan ? (
<PlanViewer message={message} messageIndex={messageIndex} getResponse={getResponse} />
) : (
<div
className={classes.content}
dangerouslySetInnerHTML={{ __html: convertToAnchorTags(content) }}
/>
)}
</div>
</>
</div>
);
};
@@ -25,7 +25,7 @@ const useClasses = makeStyles({
height: '100%',
},
scroll: {
...shorthands.margin('4px'),
...shorthands.margin(tokens.spacingVerticalXS),
...SharedStyles.scroll,
},
history: {
@@ -1,6 +1,27 @@
import { Badge, Body1, Card, CardHeader, makeStyles, shorthands, Text, tokens } from '@fluentui/react-components';
import { IPlanInput, IPlanStep } from '../../../libs/models/Plan';
import {
Badge,
Body1,
Button,
Card,
CardHeader,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
DialogTrigger,
makeStyles,
shorthands,
Text,
tokens,
} from '@fluentui/react-components';
import { Dismiss12Regular } from '@fluentui/react-icons';
import { useState } from 'react';
import { Constants } from '../../../Constants';
import { IPlanInput } from '../../../libs/models/Plan';
import { CopilotChatTokens } from '../../../styles';
import { PlanStepInput } from './PlanStepInput';
const useClasses = makeStyles({
card: {
@@ -11,9 +32,9 @@ const useClasses = makeStyles({
header: {
color: CopilotChatTokens.titleColor,
},
inputs: {
parameters: {
...shorthands.gap(tokens.spacingHorizontalS),
display: 'flex',
...shorthands.gap('8px'),
flexWrap: 'wrap',
},
bar: {
@@ -28,10 +49,11 @@ const useClasses = makeStyles({
flexColumn: {
display: 'flex',
flexDirection: 'column',
marginLeft: '8px',
marginTop: '4px',
marginBottom: '4px',
...shorthands.gap('8px'),
width: '100%',
marginLeft: tokens.spacingHorizontalS,
marginTop: tokens.spacingVerticalXS,
marginBottom: tokens.spacingVerticalXS,
...shorthands.gap(tokens.spacingHorizontalS),
},
singleLine: {
...shorthands.overflow('hidden'),
@@ -42,18 +64,34 @@ const useClasses = makeStyles({
width: '650px',
fontSize: '12px',
},
dialog: {
width: '398px',
'& button': {
marginTop: tokens.spacingVerticalL,
width: 'max-content',
},
},
});
interface PlanStepCardProps {
index: number;
step: IPlanStep;
// See Semantic Kernel's Plan object for field definitions (step === Plan).
// Plan.cs: https://github.com/microsoft/semantic-kernel/blob/df07fc6f28853a481dd6f47e60d39a52fc6c9967/dotnet/src/SemanticKernel/Planning/Plan.cs#
// Not explicitly defining the type here to avoid additional overhead of property maintenance.
step: any;
enableEdits: boolean;
enableStepDelete: boolean;
onDeleteStep: (index: number) => void;
}
export const PlanStepCard: React.FC<PlanStepCardProps> = ({ index, step }) => {
export const PlanStepCard: React.FC<PlanStepCardProps> = ({ step, enableEdits, enableStepDelete, onDeleteStep }) => {
const classes = useClasses();
const [openDialog, setOpenDialog] = useState(false);
const numbersAsStrings = ['Zero', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine'];
const stepNumber = numbersAsStrings[index];
// Omit reserved context variable names from displayed inputs
const inputs = step.parameters.filter(
(parameter: IPlanInput) =>
!Constants.sk.reservedWords.includes(parameter.Key.trim()) && parameter.Value.trim() !== '',
);
return (
<Card className={classes.card}>
@@ -63,23 +101,94 @@ export const PlanStepCard: React.FC<PlanStepCardProps> = ({ index, step }) => {
<CardHeader
header={
<Body1>
<b className={classes.header}>Step {stepNumber} •</b> {step.skill}.{step.function}
<b className={classes.header}>Step {step.index + 1} •</b> {step.skill_name}.{step.name}
<br />
</Body1>
}
action={
enableEdits && enableStepDelete ? (
<Dialog open={openDialog}>
<DialogTrigger disableButtonEnhancement>
<Button
appearance="transparent"
icon={<Dismiss12Regular />}
aria-label="Delete step"
onClick={() => {
setOpenDialog(true);
}}
/>
</DialogTrigger>
<DialogSurface className={classes.dialog}>
<DialogBody>
<DialogTitle>Are you sure you want to delete this step?</DialogTitle>
<DialogContent>
Deleting this step could disrupt the plan's initial logic and cause
errors in subsequent steps. Make sure the next steps don't depend on
this step's outputs.
</DialogContent>
<DialogActions>
<DialogTrigger disableButtonEnhancement>
<Button
appearance="secondary"
onClick={() => {
setOpenDialog(false);
}}
>
Cancel
</Button>
</DialogTrigger>
<Button
appearance="primary"
onClick={() => {
setOpenDialog(false);
onDeleteStep(step.index);
}}
>
Yes, Delete Step
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
) : undefined
}
/>
{step.description && (
<div className={classes.singleLine}>
<Text weight="semibold">About: </Text> <Text>{step.description}</Text>
</div>
)}
{step.stepInputs.length > 0 && (
<div className={classes.inputs}>
{inputs.length > 0 && (
<div className={classes.parameters}>
<Text weight="semibold">Inputs: </Text>
{step.stepInputs.map((input: IPlanInput) => {
{inputs.map((input: IPlanInput) => {
const onEditInput = (newValue: string) => {
const inputIndex = step.parameters.findIndex(
(element: IPlanInput) => element.Key === input.Key,
);
step.parameters[inputIndex] = {
Key: input.Key,
Value: newValue,
};
};
return (
<Badge color="informative" shape="rounded" appearance="tint" key={input.Key}>
{`${input.Key}: ${input.Value}`}
<PlanStepInput
input={input}
key={input.Key}
onEdit={onEditInput}
enableEdits={enableEdits}
/>
);
})}
</div>
)}
{step.outputs.length > 0 && (
<div className={classes.parameters}>
<Text weight="semibold">Outputs: </Text>
{step.outputs.map((output: string) => {
return (
<Badge color="informative" shape="rounded" appearance="tint" key={output}>
{output}
</Badge>
);
})}
@@ -0,0 +1,96 @@
import { Badge, Button, makeStyles, mergeClasses, shorthands, tokens } from '@fluentui/react-components';
import { Checkmark16Regular, Dismiss16Regular, Edit16Regular } from '@fluentui/react-icons';
import { useCallback, useState } from 'react';
import { IPlanInput } from '../../../libs/models/Plan';
const useClasses = makeStyles({
root: {
...shorthands.overflow('hidden'),
display: 'flex',
},
buttons: {
...shorthands.padding(tokens.spacingVerticalNone),
paddingLeft: tokens.spacingHorizontalXXS,
minWidth: '18px',
},
input: {
...shorthands.margin(tokens.spacingHorizontalXXS),
maxHeight: '10px',
minHeight: '10px',
fontSize: '12px',
},
interactable: {
zIndex: '50',
},
});
interface PlanStepInputProps {
input: IPlanInput;
onEdit: (newValue: string) => void;
enableEdits: boolean;
}
export const PlanStepInput: React.FC<PlanStepInputProps> = ({ input, onEdit, enableEdits }) => {
const classes = useClasses();
const [isEditingInput, setIsEditingInput] = useState(false);
const [inputValue, setInputValue] = useState(input.Value);
const onEditClick = useCallback(() => {
setIsEditingInput(true);
}, []);
const onSubmitEdit = useCallback(() => {
setIsEditingInput(false);
onEdit(inputValue);
}, [inputValue, onEdit]);
const onCancel = useCallback(() => {
setIsEditingInput(false);
setInputValue(input.Value);
}, [input.Value]);
return (
<Badge color="informative" shape="rounded" appearance="tint" className={classes.root}>
{`${input.Key}: `}
{!enableEdits && input.Value}
{enableEdits && (
<>
{isEditingInput ? (
<input
className={mergeClasses(classes.input, classes.interactable)}
style={{ width: input.Value.length * 6, minWidth: '75px' }}
placeholder={input.Value}
value={inputValue}
onChange={(event: any) => {
setInputValue(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
onSubmitEdit();
}
}}
/>
) : (
inputValue
)}
<Button
icon={isEditingInput ? <Checkmark16Regular /> : <Edit16Regular />}
appearance="transparent"
className={mergeClasses(classes.buttons, classes.interactable)}
onClick={isEditingInput ? onSubmitEdit : onEditClick}
/>
{isEditingInput && (
<Button
icon={<Dismiss16Regular />}
appearance="transparent"
className={mergeClasses(classes.buttons, classes.interactable)}
onClick={onCancel}
/>
)}
</>
)}
</Badge>
);
};
@@ -1,12 +1,18 @@
import { Button, Text, makeStyles, mergeClasses, shorthands } from '@fluentui/react-components';
import { CheckmarkCircle24Regular, DismissCircle24Regular } from '@fluentui/react-icons';
import { ChatMessageState } from '../../../libs/models/ChatMessage';
import { IPlan, IPlanStep } from '../../../libs/models/Plan';
import { Button, Text, makeStyles, mergeClasses, shorthands, tokens } from '@fluentui/react-components';
import { CheckmarkCircle24Regular, DismissCircle24Regular, Info24Regular } from '@fluentui/react-icons';
import { useState } from 'react';
import { ChatMessageType, IChatMessage } from '../../../libs/models/ChatMessage';
import { IPlanInput, PlanState, PlanType } from '../../../libs/models/Plan';
import { IAskVariables } from '../../../libs/semantic-kernel/model/Ask';
import { GetResponseOptions } from '../../../libs/useChat';
import { useAppDispatch, useAppSelector } from '../../../redux/app/hooks';
import { RootState } from '../../../redux/app/store';
import { updateMessageState } from '../../../redux/features/conversations/conversationsSlice';
import { PlanStepCard } from './PlanStepCard';
const useClasses = makeStyles({
container: {
...shorthands.gap('11px'),
...shorthands.gap(tokens.spacingVerticalM),
display: 'flex',
flexDirection: 'column',
alignItems: 'baseline',
@@ -14,12 +20,12 @@ const useClasses = makeStyles({
buttons: {
display: 'flex',
flexDirection: 'row',
marginTop: '12px',
marginBottom: '12px',
...shorthands.gap('16px'),
marginTop: tokens.spacingVerticalM,
marginBottom: tokens.spacingVerticalM,
...shorthands.gap(tokens.spacingHorizontalL),
},
status: {
...shorthands.gap('10px'),
...shorthands.gap(tokens.spacingHorizontalMNudge),
},
text: {
alignSelf: 'center',
@@ -27,49 +33,137 @@ const useClasses = makeStyles({
});
interface PlanViewerProps {
plan: IPlan;
planState: ChatMessageState;
onSubmit: () => Promise<void>;
onCancel: () => Promise<void>;
message: IChatMessage;
messageIndex: number;
getResponse: (options: GetResponseOptions) => Promise<void>;
}
export const PlanViewer: React.FC<PlanViewerProps> = ({ plan, planState, onSubmit, onCancel }) => {
export const PlanViewer: React.FC<PlanViewerProps> = ({ message, messageIndex, getResponse }) => {
const classes = useClasses();
var stepCount = 1;
const dispatch = useAppDispatch();
const { selectedId } = useAppSelector((state: RootState) => state.conversations);
// Track original plan from user message
const parsedContent = JSON.parse(message.content);
const originalPlan = parsedContent.proposedPlan;
var planState = message.state ?? parsedContent.state;
// If plan came from ActionPlanner, use parameters from top-level plan state
if (parsedContent.type === PlanType.Action) {
originalPlan.steps[0].parameters = originalPlan.state;
}
const userIntentPrefix = 'User Intent:User intent: ';
const userIntentIndex = originalPlan.description.indexOf(userIntentPrefix);
const description =
userIntentIndex !== -1
? originalPlan.description.substring(userIntentIndex + userIntentPrefix.length).trim()
: '';
const [plan, setPlan] = useState(originalPlan);
const onPlanAction = async (planState: PlanState.PlanApproved | PlanState.PlanRejected) => {
dispatch(
updateMessageState({
newMessageState: planState,
messageIndex: messageIndex,
chatId: selectedId,
}),
);
const contextVariables: IAskVariables[] = [
{
key: 'responseMessageId',
value: message.id ?? '',
},
{
key: 'proposedPlan',
value: JSON.stringify({
proposedPlan: plan,
type: parsedContent.type,
state: planState,
}),
},
];
contextVariables.push(
planState === PlanState.PlanApproved
? {
key: 'planUserIntent',
value: description,
}
: {
key: 'userCancelledPlan',
value: 'true',
},
);
// Invoke plan
await getResponse({
value: planState === PlanState.PlanApproved ? 'Yes, proceed' : 'No, cancel',
contextVariables: contextVariables,
messageType: ChatMessageType.Plan,
chatId: selectedId,
});
};
const onDeleteStep = (index: number) => {
setPlan({
...plan,
steps: plan.steps.filter((_step: IPlanInput, i: number) => i !== index),
});
};
return (
<div className={classes.container}>
<Text>Based on the request, Copilot Chat will run the following steps:</Text>
<Text weight="bold">{`Goal: ${plan.description}`}</Text>
{plan.steps.map((step: IPlanStep) => {
const stepIndex = stepCount++;
return <PlanStepCard key={`Plan step: ${stepIndex}`} index={stepIndex} step={step} />;
<Text weight="bold">{`Goal: ${description}`}</Text>
{plan.steps.map((step: any, index: number) => {
return (
<PlanStepCard
key={`Plan step: ${index}`}
step={{ ...step, index: index }}
enableEdits={planState === PlanState.PlanApprovalRequired}
enableStepDelete={plan.steps.length > 1}
onDeleteStep={onDeleteStep}
/>
);
})}
{planState === ChatMessageState.PlanApprovalRequired && (
{planState === PlanState.PlanApprovalRequired && (
<>
Would you like to proceed with the plan?
<div className={classes.buttons}>
<Button appearance="secondary" onClick={onCancel}>
<Button appearance="secondary" onClick={() => onPlanAction(PlanState.PlanRejected)}>
No, cancel plan
</Button>
<Button type="submit" appearance="primary" onClick={onSubmit}>
<Button type="submit" appearance="primary" onClick={() => onPlanAction(PlanState.PlanApproved)}>
Yes, proceed
</Button>
</div>
</>
)}
{planState === ChatMessageState.PlanApproved && (
{planState === PlanState.PlanApproved && (
<div className={mergeClasses(classes.buttons, classes.status)}>
<CheckmarkCircle24Regular />
<Text className={classes.text}> Plan Executed</Text>
</div>
)}
{planState === ChatMessageState.PlanRejected && (
{planState === PlanState.PlanRejected && (
<div className={mergeClasses(classes.buttons, classes.status)}>
<DismissCircle24Regular />
<Text className={classes.text}> Plan Cancelled</Text>
</div>
)}
{planState === PlanState.NoOp && (
<div className={mergeClasses(classes.buttons, classes.status)}>
<Info24Regular />
<Text className={classes.text}>
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.
</Text>
</div>
)}
</div>
);
};
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
import { PlanState } from './Plan';
/**
* Role of the author of a chat message. It's a copy of AuthorRoles in the API C# code.
*/
@@ -38,12 +40,5 @@ export interface IChatMessage {
prompt?: string;
authorRole: AuthorRoles;
debug?: string;
state?: ChatMessageState; // if plan needs approval
}
export enum ChatMessageState {
NoOp,
PlanApprovalRequired,
PlanApproved,
PlanRejected,
state?: PlanState;
}
@@ -4,15 +4,14 @@ export type IPlanInput = {
Value: string;
};
export type IPlanStep = {
skill: string;
function: string;
description: string;
stepInputs: IPlanInput[];
};
export enum PlanState {
NoOp,
PlanApproved,
PlanRejected,
PlanApprovalRequired,
}
export type IPlan = {
userIntent: string;
description: string;
steps: IPlanStep[];
};
export enum PlanType {
Action, // single-step
Sequential, // multi-step
}
@@ -13,11 +13,15 @@ import {
setSelectedConversation,
updateConversation,
} from '../redux/features/conversations/conversationsSlice';
import { AuthHeaderTags } from '../redux/features/plugins/PluginsState';
import { AuthHelper } from './auth/AuthHelper';
import { AlertType } from './models/AlertType';
import { Bot } from './models/Bot';
import { AuthorRoles, ChatMessageState, ChatMessageType, IChatMessage } from './models/ChatMessage';
import { AuthorRoles, ChatMessageType, IChatMessage } from './models/ChatMessage';
import { IChatSession } from './models/ChatSession';
import { IChatUser } from './models/ChatUser';
import { PlanState } from './models/Plan';
import { IAskVariables } from './semantic-kernel/model/Ask';
import { BotService } from './services/BotService';
import { ChatService } from './services/ChatService';
import * as PlanUtils from './utils/PlanUtils';
@@ -27,17 +31,12 @@ import botIcon2 from '../assets/bot-icons/bot-icon-2.png';
import botIcon3 from '../assets/bot-icons/bot-icon-3.png';
import botIcon4 from '../assets/bot-icons/bot-icon-4.png';
import botIcon5 from '../assets/bot-icons/bot-icon-5.png';
import { IChatUser } from './models/ChatUser';
import { AuthHeaderTags } from '../redux/features/plugins/PluginsState';
export interface GetResponseOptions {
messageType: ChatMessageType;
value: string;
chatId: string;
approvedPlanJson?: string;
planUserIntent?: string;
userCancelledPlan?: boolean;
contextVariables?: IAskVariables[];
}
export const useChat = () => {
@@ -103,14 +102,7 @@ export const useChat = () => {
}
};
const getResponse = async ({
messageType,
value,
chatId,
approvedPlanJson,
planUserIntent,
userCancelledPlan,
}: GetResponseOptions) => {
const getResponse = async ({ messageType, value, chatId, contextVariables }: GetResponseOptions) => {
const ask = {
input: value,
variables: [
@@ -133,24 +125,8 @@ export const useChat = () => {
],
};
if (approvedPlanJson) {
ask.variables.push(
{
key: 'proposedPlan',
value: approvedPlanJson,
},
{
key: 'planUserIntent',
value: planUserIntent!,
},
);
}
if (userCancelledPlan) {
ask.variables.push({
key: 'userCancelledPlan',
value: 'true',
});
if (contextVariables) {
ask.variables.push(...contextVariables);
}
try {
@@ -175,7 +151,8 @@ export const useChat = () => {
content: result.value,
prompt: result.variables.find((v) => v.key === 'prompt')?.value,
authorRole: AuthorRoles.Bot,
state: PlanUtils.isPlan(result.value) ? ChatMessageState.PlanApprovalRequired : ChatMessageState.NoOp,
id: result.variables.find((v) => v.key === 'messageId')?.value,
state: PlanUtils.isPlan(result.value) ? PlanState.PlanApprovalRequired : PlanState.NoOp,
};
dispatch(updateConversation({ message: messageResult, chatId: chatId }));
@@ -1,64 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
import { Constants } from '../../Constants';
import { IPlan, IPlanInput } from '../models/Plan';
export const isPlan = (object: string) => {
// backslash has to be escaped since it's a JSON string
// eslint-disable-next-line
const planPrefix = `proposedPlan\":`;
return object.indexOf(planPrefix) !== -1;
};
export const parsePlan = (response: string): IPlan | null => {
if (isPlan(response)) {
try {
const parsedResponse = JSON.parse(response);
const plan = parsedResponse.proposedPlan;
const userIntentPrefix = 'User intent: ';
const index = plan.description.indexOf(userIntentPrefix);
return {
userIntent: plan.description,
description: index !== -1 ? plan.description.substring(index + userIntentPrefix.length).trim() : '',
steps: extractPlanSteps(plan),
};
} catch (e: any) {
console.error('Error parsing plan: ' + response);
}
}
return null;
};
const extractPlanSteps = (plan: any) => {
// If plan came from Actio Planner, extract step parameters from top-level plan state
const planInputs = extractParameters(plan.state);
const planSteps = plan.steps;
return planSteps.map((step: any) => {
// If plan came from SequentialPlanner, extract step parameters from respective step object
const stepParameters = extractParameters(step.parameters);
return {
skill: step['skill_name'],
function: step['name'],
description: step['description'],
stepInputs: planSteps.length === 1 && planInputs.length > 0 ? planInputs : stepParameters,
};
});
};
const extractParameters = (parametersArray: IPlanInput[]) => {
const parameters: IPlanInput[] = [];
for (var param in parametersArray) {
if (
// Omit reserved context variable names
!Constants.sk.reservedWords.includes(parametersArray[param].Key.trim()) &&
parametersArray[param].Value.trim() !== ''
) {
parameters.push(parametersArray[param]);
}
}
return parameters;
};
@@ -1,9 +1,16 @@
// Copyright (c) Microsoft. All rights reserved.
import { createSlice, PayloadAction, Slice } from '@reduxjs/toolkit';
import { ChatMessageState, IChatMessage } from '../../../libs/models/ChatMessage';
import { IChatMessage } from '../../../libs/models/ChatMessage';
import { PlanState } from '../../../libs/models/Plan';
import { ChatState } from './ChatState';
import { ConversationInputChange, Conversations, ConversationsState, ConversationTitleChange, initialState } from './ConversationsState';
import {
ConversationInputChange,
Conversations,
ConversationsState,
ConversationTitleChange,
initialState,
} from './ConversationsState';
export const conversationsSlice: Slice<ConversationsState> = createSlice({
name: 'conversations',
@@ -44,7 +51,7 @@ export const conversationsSlice: Slice<ConversationsState> = createSlice({
},
updateMessageState: (
state: ConversationsState,
action: PayloadAction<{ newMessageState: ChatMessageState; messageIndex: number; chatId?: string }>,
action: PayloadAction<{ newMessageState: PlanState; messageIndex: number; chatId?: string }>,
) => {
const { newMessageState, messageIndex, chatId } = action.payload;
const id = chatId ?? state.selectedId;