Copilot Chat: Display usernames in Copilot Chat (#1899)

### Motivation and Context
<!-- Thank you for your contribution to the semantic-kernel repo!
Please help reviewers and future users, providing the following
information:
  1. Why is this change required?
  2. What problem does it solve?
  3. What scenario does it contribute to?
  4. If it fixes an open issue, please link to the issue here.
-->

This PR adds support for fetching user data for each user in a chat
session, allowing us to display usernames instead of GUIDs in user
lists.

### Description
<!-- Describe your changes, the overall approach, the underlying design.
These notes will help understanding how your code works. Thanks! -->

- User data is loaded per conversation for optimization but only fetched
per user.
- Fetched user data is stored and indexed globally, since users can be
part of multiple chats.
- User data is fetched only if the user exists in the same tenant as the
active user due to AAD access restrictions, otherwise, user display name
will be "External Guest".

> Note: right now, external user's names will show up in the chat
history since we save user names as part of the chat message, but this
will be removed in future work:
https://github.com/orgs/microsoft/projects/852?pane=issue&itemId=29734028


![image](https://github.com/microsoft/semantic-kernel/assets/125500434/2e6d7e78-85d1-4045-93b4-651c0e19ae4a)

### Contribution Checklist
<!-- Before submitting this PR, please make sure: -->
- [x] The code builds clean without any errors or warnings
- [x] The PR follows SK Contribution Guidelines
(https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
- [x] The code follows the .NET coding conventions
(https://learn.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
verified with `dotnet format`
- [x] All unit tests pass, and I have added new tests where possible
- [x] I didn't break anyone 😄

---------

Co-authored-by: Shawn Callegari <[email protected]>
This commit is contained in:
Teresa Hoang
2023-07-11 18:44:47 +00:00
committed by GitHub
co-authored by Shawn Callegari
parent c5abac0fd2
commit e195f4def9
16 changed files with 428 additions and 53 deletions
@@ -16,6 +16,8 @@ export const Constants = {
storeAuthStateInCookie: false,
},
semanticKernelScopes: ['openid', 'offline_access', 'profile'],
// MS Graph scopes required for loading user information
msGraphAppScopes: ['User.ReadBasic.All'],
},
bot: {
profile: {
@@ -39,6 +41,7 @@ export const Constants = {
},
// For a list of Microsoft Graph permissions, see https://learn.microsoft.com/en-us/graph/permissions-reference.
// Your application registration will need to be granted these permissions in Azure Active Directory.
msGraphScopes: ['Calendars.Read', 'Mail.Read', 'Mail.Send', 'Tasks.ReadWrite', 'User.Read'],
msGraphPluginScopes: ['Calendars.Read', 'Mail.Read', 'Mail.Send', 'Tasks.ReadWrite', 'User.Read'],
adoScopes: ['vso.work'],
BATCH_REQUEST_LIMIT: 20,
};
@@ -2,8 +2,6 @@
import { useMsal } from '@azure/msal-react';
import {
AvatarGroupItem,
AvatarGroupPopover,
Button,
Input,
InputOnChangeData,
@@ -34,7 +32,8 @@ import { addAlert, removeAlert } from '../../redux/features/app/appSlice';
import { editConversationTitle } from '../../redux/features/conversations/conversationsSlice';
import { ChatResourceList } from './ChatResourceList';
import { ChatRoom } from './ChatRoom';
import { ShareBotMenu } from './ShareBotMenu';
import { ParticipantsList } from './controls/ParticipantsList';
import { ShareBotMenu } from './controls/ShareBotMenu';
const useClasses = makeStyles({
root: {
@@ -62,6 +61,7 @@ const useClasses = makeStyles({
},
controls: {
display: 'flex',
alignItems: 'center',
},
popoverHeader: {
...shorthands.margin('0'),
@@ -98,14 +98,15 @@ const useClasses = makeStyles({
export const ChatWindow: React.FC = () => {
const classes = useClasses();
const dispatch = useAppDispatch();
const { conversations, selectedId } = useAppSelector((state: RootState) => state.conversations);
const chatName = conversations[selectedId].title;
const [title = '', setTitle] = useState<string | undefined>(selectedId);
const [isEditing, setIsEditing] = useState<boolean>(false);
const { instance, inProgress } = useMsal();
const chatService = new ChatService(process.env.REACT_APP_BACKEND_URI as string);
const { alerts } = useAppSelector((state: RootState) => state.app);
const chatService = new ChatService(process.env.REACT_APP_BACKEND_URI as string);
const { conversations, selectedId } = useAppSelector((state: RootState) => state.conversations);
const chatName = conversations[selectedId].title;
const [title = '', setTitle] = useState<string | undefined>(selectedId);
const [isEditing, setIsEditing] = useState<boolean>(false);
const onSave = async () => {
if (chatName !== title) {
@@ -194,7 +195,7 @@ export const ChatWindow: React.FC = () => {
<PopoverTrigger disableButtonEnhancement>
<Tooltip content={'Edit conversation name'} relationship="label">
<Button
data-testid='editChatTitleButton'
data-testid="editChatTitleButton"
icon={isEditing ? <Edit24Filled /> : <EditRegular />}
appearance="transparent"
onClick={onClose}
@@ -224,11 +225,7 @@ export const ChatWindow: React.FC = () => {
</TabList>
</div>
<div className={classes.controls}>
<AvatarGroupPopover>
{conversations[selectedId].users.map((user) => (
<AvatarGroupItem name={user.id} key={user.id} />
))}
</AvatarGroupPopover>
<ParticipantsList participants={conversations[selectedId].users} />
<ShareBotMenu chatId={selectedId} chatTitle={title} />
</div>
</div>
@@ -0,0 +1,100 @@
// Copyright (c) Microsoft. All rights reserved.
import { FC, useEffect, useState } from 'react';
import {
Avatar,
Persona,
Popover,
PopoverSurface,
PopoverTrigger,
Tooltip,
makeStyles,
shorthands,
tokens,
} from '@fluentui/react-components';
import { IChatUser } from '../../../libs/models/ChatUser';
import { useGraph } from '../../../libs/useGraph';
import { useAppDispatch, useAppSelector } from '../../../redux/app/hooks';
import { RootState } from '../../../redux/app/store';
import { setUsersLoaded } from '../../../redux/features/conversations/conversationsSlice';
import { Users } from '../../../redux/features/users/UsersState';
import { ScrollBarStyles } from '../../../styles';
interface ShareBotMenuProps {
participants: IChatUser[];
}
const useClasses = makeStyles({
root: {
display: 'flex',
...ScrollBarStyles,
...shorthands.padding(tokens.spacingVerticalNone, tokens.spacingVerticalXS),
...shorthands.border(tokens.spacingVerticalM),
...shorthands.borderRight(tokens.spacingHorizontalXS),
},
list: {
display: 'flex',
flexDirection: 'column',
height: 'min-content',
maxHeight: '220px',
width: '200px',
overflowWrap: 'anywhere',
...shorthands.gap(tokens.spacingVerticalS),
},
});
export const ParticipantsList: FC<ShareBotMenuProps> = ({ participants }) => {
const classes = useClasses();
const dispatch = useAppDispatch();
const { users } = useAppSelector((state: RootState) => state.users);
const { conversations, selectedId } = useAppSelector((state: RootState) => state.conversations);
const msGraph = useGraph();
const chatUsers = conversations[selectedId].users;
const [loadedParticipants, setLoadedParticipants] = useState(mapParticipantsNames(participants, users));
useEffect(() => {
if (!conversations[selectedId].userDataLoaded) {
void msGraph.loadUsers(chatUsers.map((user: IChatUser) => user.id)).then((loadedUsers?: Users) => {
if (loadedUsers) {
dispatch(setUsersLoaded(selectedId));
setLoadedParticipants(mapParticipantsNames(participants, loadedUsers));
}
});
}
// Limiting dependencies or else this effect is triggered on all user typing events or new chat messages
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId, chatUsers.length]);
return (
<Popover positioning={'below-end'} size="small">
<PopoverTrigger>
<Tooltip content="Chat participants" relationship="label">
<Avatar initials={`+${Math.min(participants.length, 100)}`} />
</Tooltip>
</PopoverTrigger>
<PopoverSurface className={classes.root}>
<div className={classes.list}>
{loadedParticipants.map((participant) => (
<Persona
textAlignment="center"
name={participant.name}
key={participant.id}
avatar={{ color: 'colorful' }}
/>
))}
</div>
</PopoverSurface>
</Popover>
);
};
const mapParticipantsNames = (participants: IChatUser[], usersData: Users) => {
return participants.map((participant) => ({
...participant,
// falsy lint check, users[userId] can be null if user data hasn't been loaded
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
name: usersData[participant.id.split('.')[0]]?.displayName ?? participant.id,
}));
};
@@ -4,9 +4,9 @@ import React, { FC, useCallback } from 'react';
import { Button, Menu, MenuItem, MenuList, MenuPopover, MenuTrigger, Tooltip } from '@fluentui/react-components';
import { ArrowDownloadRegular, PeopleTeamAddRegular, ShareRegular } from '@fluentui/react-icons';
import { useChat } from '../../libs/useChat';
import { useFile } from '../../libs/useFile';
import { InvitationCreateDialog } from './invitation-dialog/InvitationCreateDialog';
import { useChat } from '../../../libs/useChat';
import { useFile } from '../../../libs/useFile';
import { InvitationCreateDialog } from '../invitation-dialog/InvitationCreateDialog';
interface ShareBotMenuProps {
chatId: string;
@@ -39,10 +39,18 @@ export const ShareBotMenu: FC<ShareBotMenuProps> = ({ chatId, chatTitle }) => {
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem data-testid="downloadBotMenuItem" icon={<ArrowDownloadRegular />} onClick={onDownloadBotClick}>
<MenuItem
data-testid="downloadBotMenuItem"
icon={<ArrowDownloadRegular />}
onClick={onDownloadBotClick}
>
Download your Bot
</MenuItem>
<MenuItem data-testid="inviteOthersMenuItem" icon={<PeopleTeamAddRegular />} onClick={() => setIsGettingInvitationId(true)}>
<MenuItem
data-testid="inviteOthersMenuItem"
icon={<PeopleTeamAddRegular />}
onClick={() => setIsGettingInvitationId(true)}
>
Invite others to your Bot
</MenuItem>
</MenuList>
@@ -62,9 +62,15 @@ const ssoSilentRequest = async (msalInstance: IPublicClientApplication) => {
const loginAsync = async (instance: IPublicClientApplication) => {
if (Constants.msal.method === 'redirect') {
await instance.loginRedirect({ scopes: Constants.msal.semanticKernelScopes });
await instance.loginRedirect({
scopes: Constants.msal.semanticKernelScopes,
extraScopesToConsent: Constants.msal.msGraphAppScopes,
});
} else {
await instance.loginPopup({ scopes: Constants.msal.semanticKernelScopes });
await instance.loginPopup({
scopes: Constants.msal.semanticKernelScopes,
extraScopesToConsent: Constants.msal.msGraphAppScopes,
});
}
};
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
import { BaseService } from './BaseService';
export interface BatchRequest {
id: string;
method: 'GET' | 'POST' | 'PUT' | 'UPDATE' | 'DELETE';
url: string;
headers: any;
}
export interface BatchResponseBody {
responses: BatchResponse[];
}
export interface BatchResponse {
id: number;
status: number;
body?: any;
headers?: any;
}
export class GraphService extends BaseService {
constructor() {
super('https://graph.microsoft.com');
}
private version = 'v1.0';
private getCommandPath = (resourcePath: string) => {
return `/${this.version}/${resourcePath}`;
};
public makeBatchRequest = async (batchRequests: BatchRequest[], accessToken: string) => {
const result = await this.getResponseAsync<BatchResponseBody>(
{
commandPath: this.getCommandPath('$batch'),
method: 'POST',
body: { requests: batchRequests },
},
accessToken,
);
return result.responses;
};
}
@@ -83,6 +83,7 @@ export const useChat = () => {
botProfilePicture: getBotProfilePicture(Object.keys(conversations).length),
input: '',
isBotTyping: false,
userDataLoaded: false,
};
dispatch(addConversation(newChat));
@@ -153,6 +154,7 @@ export const useChat = () => {
botProfilePicture: getBotProfilePicture(Object.keys(loadedConversations).length),
input: '',
isBotTyping: false,
userDataLoaded: false,
};
}
@@ -207,7 +209,7 @@ export const useChat = () => {
});
};
const getBotProfilePicture = (index: number) => {
const getBotProfilePicture = (index: number): string => {
return botProfilePictures[index % botProfilePictures.length];
};
@@ -267,6 +269,7 @@ export const useChat = () => {
botProfilePicture: getBotProfilePicture(Object.keys(conversations).length),
input: '',
isBotTyping: false,
userDataLoaded: false,
};
dispatch(addConversation(newChat));
@@ -0,0 +1,160 @@
import { useMsal } from '@azure/msal-react';
import { Constants } from '../Constants';
import { useAppDispatch, useAppSelector } from '../redux/app/hooks';
import { RootState } from '../redux/app/store';
import { addAlert } from '../redux/features/app/appSlice';
import { UserData } from '../redux/features/users/UsersState';
import { setUsers } from '../redux/features/users/usersSlice';
import { TokenHelper } from './auth/TokenHelper';
import { AlertType } from './models/AlertType';
import { BatchRequest, BatchResponse, GraphService } from './services/GraphService';
export const useGraph = () => {
const { instance, inProgress } = useMsal();
const { users } = useAppSelector((state: RootState) => state.users);
const { activeUserInfo } = useAppSelector((state: RootState) => state.app);
const dispatch = useAppDispatch();
const graphService = new GraphService();
const loadUsers = async (userIds: string[]) => {
const MAX_RETRIES = 3;
let retries = 1;
// Initialize empty arrays to store results
const usersToLoad: string[] = [];
const loadedUsers: UserData[] = [];
const usersToRetry: string[] = [];
try {
// Copy current state of user data
const userData = { ...users };
// Filter user Ids list to optimize fetch
userIds.forEach((userId) => {
const ids = userId.split('.');
const objectId = ids[0]; // Unique GUID assigned to each user in their home tenant
const tenantId = ids[1]; // Home tenant id
// Active user can only access user data within their own tenant
// Mark chat users outside of tenant as External
if (activeUserInfo && tenantId !== activeUserInfo.id.split('.')[1]) {
userData[objectId] = {
id: objectId,
displayName: 'External User',
};
} else {
// Only fetch users that haven't already been loaded
if (!(objectId in users)) {
usersToLoad.push(objectId);
}
}
});
if (usersToLoad.length > 0) {
await makeBatchGetUsersRequest(usersToLoad, loadedUsers, usersToRetry);
// Retry any users that failed with transient (5xx) errors up to 3 times
while (usersToRetry.length > 0 && retries <= MAX_RETRIES) {
console.log(`Retrying batch request ${retries}/${MAX_RETRIES}`);
await makeBatchGetUsersRequest(usersToRetry, loadedUsers, usersToRetry);
retries++;
}
// Populate user data record to update state
loadedUsers.forEach((user) => {
userData[user.id] = user;
});
usersToRetry.forEach((userId) => {
userData[userId] = {
id: userId,
displayName: 'Unknown',
};
});
}
dispatch(setUsers(userData));
return userData;
} catch (e: unknown) {
dispatch(
addAlert({
type: AlertType.Error,
message: e as string,
}),
);
return;
}
};
// Helper function to fetch user data in batches of up to 20
const makeBatchGetUsersRequest = async (userIds: string[], loadedUsers: UserData[], usersToRetry: string[]) => {
const getUserScope = 'User.ReadBasic.All';
const token = await TokenHelper.getAccessTokenUsingMsal(inProgress, instance, [getUserScope]);
// Loop through the user ids in chunks of the maximum batch size
for (let i = 0; i < userIds.length; i += Constants.BATCH_REQUEST_LIMIT) {
// Slice the current chunk of user ids
const chunk = userIds.slice(i, i + Constants.BATCH_REQUEST_LIMIT);
// Create an array of batch requests for the chunk
const requests = chunk.map((id, index) => createGetUserRequest(id, i + index));
// Send the batch request using Graph Service
const responses: BatchResponse[] = await graphService.makeBatchRequest(requests, token);
// Loop through the batch responses and parse the user data
for (const response of responses) {
const userData = parseGetUserResponse(response, userIds, usersToRetry);
if (userData) {
// Push the user data to the results array
loadedUsers.push(userData);
}
}
}
};
// Helper function to create a GetUser request given a user id
const createGetUserRequest = (id: string, index: number): BatchRequest => {
return {
id: index.toString(),
method: 'GET',
url: `/users/${id}?$select=id,displayName,userPrincipalName`,
headers: {
'Content-Type': 'application/json',
},
};
};
// Helper function to extract the user data from a batch response
const parseGetUserResponse = (
response: BatchResponse,
userIds: string[],
usersToRetry: string[],
): UserData | null => {
if (response.status === 200 && response.body) {
const user = response.body as UserData;
return {
id: user.id,
displayName: user.displayName,
userPrincipalName: user.userPrincipalName,
};
} else if (response.status >= 500) {
// Transient error, try again
usersToRetry.push(userIds[response.id]);
} else {
// Failed to fetch, user data unavailable
return {
id: userIds[response.id],
displayName: 'Unknown',
};
}
return null;
};
return {
loadUsers,
};
};
@@ -4,6 +4,7 @@ import { AnyAction, combineReducers, createAction, Reducer } from '@reduxjs/tool
import appReducer from '../features/app/appSlice';
import conversationsReducer from '../features/conversations/conversationsSlice';
import pluginsReducer from '../features/plugins/pluginsSlice';
import usersReducer from '../features/users/usersSlice';
import { RootState } from './store';
// Define a top-level root state reset action
@@ -14,6 +15,7 @@ const rootReducer: Reducer<RootState> = combineReducers({
app: appReducer,
conversations: conversationsReducer,
plugins: pluginsReducer,
users: usersReducer,
});
// Define a special resetApp reducer that handles resetting the entire state
@@ -25,6 +27,7 @@ export const resetAppReducer = (state: RootState | undefined, action: AnyAction)
app: appReducer(undefined, action),
conversations: conversationsReducer(undefined, action),
plugins: pluginsReducer(undefined, action),
users: usersReducer(undefined, action),
};
}
@@ -9,6 +9,7 @@ import {
startSignalRConnection,
} from '../features/message-relay/signalRMiddleware';
import { PluginsState } from '../features/plugins/PluginsState';
import { UsersState } from '../features/users/UsersState';
import resetStateReducer, { resetApp } from './rootReducer';
export type StoreMiddlewareAPI = MiddlewareAPI<Dispatch, RootState>;
@@ -28,6 +29,7 @@ export interface RootState {
app: AppState;
conversations: ConversationsState;
plugins: PluginsState;
users: UsersState;
}
export const getSelectedChatID = (): string => {
@@ -12,4 +12,5 @@ export interface ChatState {
lastUpdatedTimestamp?: number;
input: string;
isBotTyping: boolean;
userDataLoaded: boolean;
}
@@ -45,6 +45,10 @@ export const conversationsSlice: Slice<ConversationsState> = createSlice({
) => {
const { user, chatId } = action.payload;
state.conversations[chatId].users.push(user);
state.conversations[chatId].userDataLoaded = false;
},
setUsersLoaded: (state: ConversationsState, action: PayloadAction<string>) => {
state.conversations[action.payload].userDataLoaded = true;
},
/*
* updateConversationFromUser() and updateConversationFromServer() both update the conversations state.
@@ -122,6 +126,7 @@ export const {
updateMessageState,
updateUserIsTyping,
updateUserIsTypingFromServer,
setUsersLoaded,
} = conversationsSlice.actions;
export default conversationsSlice.reducer;
@@ -1,3 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
import { Constants } from '../../../Constants';
import GithubIcon from '../../../assets/plugin-icons/github.png';
import JiraIcon from '../../../assets/plugin-icons/jira.png';
@@ -68,7 +70,7 @@ export const initialState: PluginsState = {
enabled: false,
authRequirements: {
Msal: true,
scopes: Constants.msGraphScopes,
scopes: Constants.msGraphPluginScopes,
},
headerTag: AuthHeaderTags.MsGraph,
icon: GraphIcon,
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
export interface UsersState {
users: Users;
}
export const initialState: UsersState = {
users: {},
};
export type Users = Record<string, UserData>;
export interface UserData {
id: string;
displayName?: string;
userPrincipalName?: string;
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { initialState, Users, UsersState } from './UsersState';
export const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
setUsers: (state: UsersState, action: PayloadAction<Users>) => {
state.users = action.payload;
},
},
});
export const { setUsers } = usersSlice.actions;
export default usersSlice.reducer;
@@ -1,22 +1,22 @@
import { BrandVariants, GriffelStyle, createLightTheme, tokens } from '@fluentui/react-components';
const semanticKernelBrandRamp: BrandVariants = {
10: "#060103",
20: "#261018",
30: "#431426",
40: "#591732",
50: "#701A3E",
60: "#861F4B",
70: "#982C57",
80: "#A53E63",
90: "#B15070",
100: "#BC627E",
110: "#C6748B",
120: "#CF869A",
130: "#D898A8",
140: "#E0AAB7",
150: "#E8BCC6",
160: "#EFCFD6"
10: '#060103',
20: '#261018',
30: '#431426',
40: '#591732',
50: '#701A3E',
60: '#861F4B',
70: '#982C57',
80: '#A53E63',
90: '#B15070',
100: '#BC627E',
110: '#C6748B',
120: '#CF869A',
130: '#D898A8',
140: '#E0AAB7',
150: '#E8BCC6',
160: '#EFCFD6',
};
export const semanticKernelLightTheme = createLightTheme(semanticKernelBrandRamp);
@@ -27,20 +27,24 @@ export const Breakpoints = {
},
};
export const SharedStyles: Record<string, GriffelStyle> = {
scroll: {
overflowY: 'scroll',
'&:hover': {
'&::-webkit-scrollbar-thumb': {
backgroundColor: tokens.colorScrollbarOverlay,
visibility: 'visible',
},
'&::-webkit-scrollbar-track': {
backgroundColor: tokens.colorNeutralBackground1,
WebkitBoxShadow: 'inset 0 0 5px rgba(0, 0, 0, 0.1)',
visibility: 'visible',
},
export const ScrollBarStyles: GriffelStyle = {
overflowY: 'scroll',
'&:hover': {
'&::-webkit-scrollbar-thumb': {
backgroundColor: tokens.colorScrollbarOverlay,
visibility: 'visible',
},
'&::-webkit-scrollbar-track': {
backgroundColor: tokens.colorNeutralBackground1,
WebkitBoxShadow: 'inset 0 0 5px rgba(0, 0, 0, 0.1)',
visibility: 'visible',
},
},
};
export const SharedStyles: Record<string, GriffelStyle> = {
scroll: {
height: '100%',
...ScrollBarStyles,
},
};