Skip to content

Commit 915b76b

Browse files
chentong7alexvy86
andauthored
feat(@fluid-example/ai-collab): Integrate User Avatar into Sample App (#22850)
## Description This PoC demonstrates the integration of Microsoft's Presence API into an existing AI-powered application. The key objectives include ramping up the current AI app, incorporating the Presence API library, and integrating this functionality to display user presence information. Specifically, this demo will show the avatar of a signed-in Microsoft account on top of the sample AI app's user interface, indicating the user’s online status or availability in real time. By completing this PoC, we aim to enhance the AI app's user experience with seamless integration of Microsoft's identity and presence services, allowing for personalized interactions based on user status. ## Sample https://github.com/user-attachments/assets/488d88f2-90ce-4c4a-9371-7a89ac3fc931 --------- Co-authored-by: Alex Villarreal <[email protected]>
1 parent b750ced commit 915b76b

File tree

14 files changed

+326
-8
lines changed

14 files changed

+326
-8
lines changed

examples/apps/ai-collab/.eslintrc.cjs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,23 @@ module.exports = {
1818
"import/no-internal-modules": [
1919
"error",
2020
{
21-
allow: importInternalModulesAllowed.concat([
21+
allow: [
22+
"@fluidframework/*/beta",
23+
"@fluidframework/*/alpha",
24+
2225
// NextJS requires reaching to its internal modules
2326
"next/**",
2427

2528
// Path aliases
2629
"@/actions/**",
2730
"@/types/**",
31+
"@/infra/**",
2832
"@/components/**",
29-
]),
33+
"@/app/**",
34+
35+
// Experimental package APIs and exports are unknown, so allow any imports from them.
36+
"@fluidframework/ai-collab/alpha",
37+
],
3038
},
3139
],
3240
// This is an example/test app; all its dependencies are dev dependencies so as not to pollute the lockfile

examples/apps/ai-collab/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ You can run this example using the following steps:
2828
- For an even faster build, you can add the package name to the build command, like this:
2929
`pnpm run build:fast --nolint @fluid-example/ai-collab`
3030
1. Start a Tinylicious server by running `pnpm start:server` from this directory.
31-
1. In a separate terminal also from this directory, run `pnpm next:dev` and open http://localhost:3000/ in a
31+
1. In a separate terminal also from this directory, run `pnpm start` and open http://localhost:3000/ in a
3232
web browser to see the app running.
3333

3434
### Using SharePoint embedded instead of tinylicious
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*!
2+
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3+
* Licensed under the MIT License.
4+
*/
5+
6+
// We deliberately configure NextJS to not use React Strict Mode, so we don't get double-rendering of React components
7+
// during development. Otherwise containers get loaded twice, and the presence functionality works incorrectly, detecting
8+
// every browser tab that *loaded* a container (but not the one that originally created it) as 2 presence participants.
9+
const nextConfig = {
10+
reactStrictMode: false,
11+
};
12+
13+
export default nextConfig;

examples/apps/ai-collab/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@fluidframework/devtools": "workspace:~",
4444
"@fluidframework/eslint-config-fluid": "^5.6.0",
4545
"@fluidframework/odsp-client": "workspace:~",
46+
"@fluidframework/presence": "workspace:~",
4647
"@fluidframework/tinylicious-client": "workspace:~",
4748
"@fluidframework/tree": "workspace:~",
4849
"@iconify/react": "^5.0.2",

examples/apps/ai-collab/src/app/page.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
"use client";
77

8+
import { acquirePresenceViaDataObject } from "@fluidframework/presence/alpha";
89
import {
910
Box,
1011
Button,
@@ -18,7 +19,10 @@ import {
1819
import type { IFluidContainer, TreeView } from "fluid-framework";
1920
import React, { useEffect, useState } from "react";
2021

22+
import { PresenceManager } from "./presence";
23+
2124
import { TaskGroup } from "@/components/TaskGroup";
25+
import { UserPresenceGroup } from "@/components/UserPresenceGroup";
2226
import {
2327
CONTAINER_SCHEMA,
2428
INITIAL_APP_STATE,
@@ -47,6 +51,7 @@ export async function createAndInitializeContainer(): Promise<
4751
export default function TasksListPage(): JSX.Element {
4852
const [selectedTaskGroup, setSelectedTaskGroup] = useState<SharedTreeTaskGroup>();
4953
const [treeView, setTreeView] = useState<TreeView<typeof SharedTreeAppState>>();
54+
const [presenceManagerContext, setPresenceManagerContext] = useState<PresenceManager>();
5055

5156
const { container, isFluidInitialized, data } = useFluidContainerNextJs(
5257
containerIdFromUrl(),
@@ -57,6 +62,9 @@ export default function TasksListPage(): JSX.Element {
5762
(fluidContainer) => {
5863
const _treeView = fluidContainer.initialObjects.appState.viewWith(TREE_CONFIGURATION);
5964
setTreeView(_treeView);
65+
66+
const presence = acquirePresenceViaDataObject(fluidContainer.initialObjects.presence);
67+
setPresenceManagerContext(new PresenceManager(presence));
6068
return { sharedTree: _treeView };
6169
},
6270
);
@@ -79,6 +87,9 @@ export default function TasksListPage(): JSX.Element {
7987
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
8088
maxWidth={false}
8189
>
90+
{presenceManagerContext && (
91+
<UserPresenceGroup presenceManager={presenceManagerContext} />
92+
)}
8293
<Typography variant="h2" sx={{ my: 3 }}>
8394
My Work Items
8495
</Typography>
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*!
2+
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3+
* Licensed under the MIT License.
4+
*/
5+
6+
import {
7+
IPresence,
8+
Latest,
9+
type ISessionClient,
10+
type PresenceStates,
11+
type PresenceStatesSchema,
12+
} from "@fluidframework/presence/alpha";
13+
14+
import { getProfilePhoto } from "@/infra/authHelper";
15+
16+
export interface User {
17+
photo: string;
18+
}
19+
20+
const statesSchema = {
21+
onlineUsers: Latest({ photo: "" } satisfies User),
22+
} satisfies PresenceStatesSchema;
23+
24+
export type UserPresence = PresenceStates<typeof statesSchema>;
25+
26+
// Takes a presence object and returns the user presence object that contains the shared object states
27+
export function buildUserPresence(presence: IPresence): UserPresence {
28+
const states = presence.getStates(`name:user-avatar-states`, statesSchema);
29+
return states;
30+
}
31+
32+
export class PresenceManager {
33+
// A PresenceState object to manage the presence of users within the app
34+
private readonly usersState: UserPresence;
35+
// A map of SessionClient to UserInfo, where users can share their info with other users
36+
private readonly userInfoMap: Map<ISessionClient, User> = new Map();
37+
// A callback method to get updates when remote UserInfo changes
38+
private userInfoCallback: (userInfoMap: Map<ISessionClient, User>) => void = () => {};
39+
40+
constructor(private readonly presence: IPresence) {
41+
// Address for the presence state, this is used to organize the presence states and avoid conflicts
42+
const appSelectionWorkspaceAddress = "aiCollab:workspace";
43+
44+
// Initialize presence state for the app selection workspace
45+
this.usersState = presence.getStates(
46+
appSelectionWorkspaceAddress, // Workspace address
47+
statesSchema, // Workspace schema
48+
);
49+
50+
// Listen for updates to the userInfo property in the presence state
51+
this.usersState.props.onlineUsers.events.on("updated", (update) => {
52+
// The remote client that updated the userInfo property
53+
const remoteSessionClient = update.client;
54+
// The new value of the userInfo property
55+
const remoteUserInfo = update.value;
56+
57+
// Update the userInfoMap with the new value
58+
this.userInfoMap.set(remoteSessionClient, remoteUserInfo);
59+
// Notify the app about the updated userInfoMap
60+
this.userInfoCallback(this.userInfoMap);
61+
});
62+
63+
// Set the local user's info
64+
this.setMyUserInfo().catch((error) => {
65+
console.error(`Error: ${error} when setting local user info`);
66+
});
67+
}
68+
69+
// Set the local user's info and set it on the Presence State to share with other clients
70+
private async setMyUserInfo(): Promise<void> {
71+
const clientId = process.env.NEXT_PUBLIC_SPE_CLIENT_ID;
72+
const tenantId = process.env.NEXT_PUBLIC_SPE_ENTRA_TENANT_ID;
73+
74+
// spe client
75+
if (tenantId !== undefined && clientId !== undefined) {
76+
const photoUrl = await getProfilePhoto();
77+
this.usersState.props.onlineUsers.local = { photo: photoUrl };
78+
}
79+
80+
this.userInfoMap.set(this.presence.getMyself(), this.usersState.props.onlineUsers.local);
81+
this.userInfoCallback(this.userInfoMap);
82+
}
83+
84+
// Returns the presence object
85+
getPresence(): IPresence {
86+
return this.presence;
87+
}
88+
89+
// Allows the app to listen for updates to the userInfoMap
90+
setUserInfoUpdateListener(callback: (userInfoMap: Map<ISessionClient, User>) => void): void {
91+
this.userInfoCallback = callback;
92+
}
93+
94+
// Returns the UserInfo of given session clients
95+
getUserInfo(sessionList: ISessionClient[]): User[] {
96+
const userInfoList: User[] = [];
97+
98+
for (const sessionClient of sessionList) {
99+
// If local user or remote user is connected, then only add it to the list
100+
try {
101+
const userInfo = this.usersState.props.onlineUsers.clientValue(sessionClient).value;
102+
// If the user is local user, then add it to the beginning of the list
103+
if (sessionClient.sessionId === this.presence.getMyself().sessionId) {
104+
userInfoList.push(userInfo);
105+
} else {
106+
// If the user is remote user, then add it to the end of the list
107+
userInfoList.unshift(userInfo);
108+
}
109+
} catch (error) {
110+
console.error(
111+
`Error: ${error} when getting user info for session client: ${sessionClient.sessionId}`,
112+
);
113+
}
114+
}
115+
116+
return userInfoList;
117+
}
118+
}

examples/apps/ai-collab/src/app/spe.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import type { ContainerSchema, IFluidContainer } from "fluid-framework";
77

8-
import { start } from "@/infra/authHelper"; // eslint-disable-line import/no-internal-modules
8+
import { start } from "@/infra/authHelper";
99

1010
const { client, getShareLink, containerId: _containerId } = await start();
1111

examples/apps/ai-collab/src/components/TaskCard.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ import { Tree, type TreeView } from "fluid-framework";
3737
import { useSnackbar } from "notistack";
3838
import React, { useState, type ReactNode, type SetStateAction } from "react";
3939

40-
// eslint-disable-next-line import/no-internal-modules
4140
import { getOpenAiClient } from "@/infra/openAiClient";
4241
import {
4342
SharedTreeTask,

examples/apps/ai-collab/src/components/TaskGroup.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ import React, { useEffect, useState } from "react";
4242

4343
import { TaskCard } from "./TaskCard";
4444

45-
// eslint-disable-next-line import/no-internal-modules
4645
import { getOpenAiClient } from "@/infra/openAiClient";
4746
import {
4847
aiCollabLlmTreeNodeValidator,
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*!
2+
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3+
* Licensed under the MIT License.
4+
*/
5+
6+
"use client";
7+
8+
import { Avatar, Badge, styled } from "@mui/material";
9+
import React, { useEffect, useState } from "react";
10+
11+
import type { PresenceManager } from "@/app/presence";
12+
13+
interface UserPresenceProps {
14+
presenceManager: PresenceManager;
15+
}
16+
17+
const UserPresenceGroup: React.FC<UserPresenceProps> = ({ presenceManager }): JSX.Element => {
18+
const [invalidations, setInvalidations] = useState(0);
19+
20+
useEffect(() => {
21+
// Listen to the attendeeJoined event and update the presence group when a new attendee joins
22+
const unsubJoin = presenceManager.getPresence().events.on("attendeeJoined", () => {
23+
setInvalidations(invalidations + Math.random());
24+
});
25+
// Listen to the attendeeDisconnected event and update the presence group when an attendee leaves
26+
const unsubDisconnect = presenceManager
27+
.getPresence()
28+
.events.on("attendeeDisconnected", () => {
29+
setInvalidations(invalidations + Math.random());
30+
});
31+
// Listen to the userInfoUpdate event and update the presence group when the user info is updated
32+
presenceManager.setUserInfoUpdateListener(() => {
33+
setInvalidations(invalidations + Math.random());
34+
});
35+
36+
return () => {
37+
unsubJoin();
38+
unsubDisconnect();
39+
presenceManager.setUserInfoUpdateListener(() => {});
40+
};
41+
});
42+
43+
// Get the list of connected attendees
44+
const connectedAttendees = [...presenceManager.getPresence().getAttendees()].filter(
45+
(attendee) => attendee.getConnectionStatus() === "Connected",
46+
);
47+
48+
// Get the user info for the connected attendees
49+
const userInfoList = presenceManager.getUserInfo(connectedAttendees);
50+
51+
const StyledBadge = styled(Badge)(({ theme }) => ({
52+
"& .MuiBadge-badge": {
53+
backgroundColor: "#44b700",
54+
color: "#44b700",
55+
boxShadow: `0 0 0 2px ${theme.palette.background.paper}`,
56+
"&::after": {
57+
position: "absolute",
58+
top: 0,
59+
left: 0,
60+
width: "100%",
61+
height: "100%",
62+
borderRadius: "50%",
63+
animation: "ripple 1.2s infinite ease-in-out",
64+
border: "1px solid currentColor",
65+
content: '""',
66+
},
67+
},
68+
"@keyframes ripple": {
69+
"0%": {
70+
transform: "scale(.8)",
71+
opacity: 1,
72+
},
73+
"100%": {
74+
transform: "scale(2.4)",
75+
opacity: 0,
76+
},
77+
},
78+
}));
79+
80+
return (
81+
<div>
82+
{userInfoList.length === 0 ? (
83+
<Avatar alt="User Photo" sx={{ width: 56, height: 56 }} />
84+
) : (
85+
<>
86+
{userInfoList.slice(0, 4).map((userInfo, index) => (
87+
<StyledBadge
88+
key={index}
89+
overlap="circular"
90+
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
91+
variant="dot"
92+
>
93+
<Avatar alt="User Photo" src={userInfo.photo} sx={{ width: 56, height: 56 }} />
94+
</StyledBadge>
95+
))}
96+
{userInfoList.length > 4 && (
97+
<Badge
98+
overlap="circular"
99+
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
100+
badgeContent={`+${userInfoList.length - 4}`}
101+
color="primary"
102+
>
103+
<Avatar alt="More Users" sx={{ width: 56, height: 56 }} />
104+
</Badge>
105+
)}
106+
</>
107+
)}
108+
</div>
109+
);
110+
};
111+
112+
export { UserPresenceGroup };

0 commit comments

Comments
 (0)