Skip to content

Commit

Permalink
feat(@fluid-example/ai-collab): Integrate User Avatar into Sample App (
Browse files Browse the repository at this point in the history
…microsoft#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]>
  • Loading branch information
chentong7 and alexvy86 authored Jan 6, 2025
1 parent b750ced commit 915b76b
Show file tree
Hide file tree
Showing 14 changed files with 326 additions and 8 deletions.
12 changes: 10 additions & 2 deletions examples/apps/ai-collab/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,23 @@ module.exports = {
"import/no-internal-modules": [
"error",
{
allow: importInternalModulesAllowed.concat([
allow: [
"@fluidframework/*/beta",
"@fluidframework/*/alpha",

// NextJS requires reaching to its internal modules
"next/**",

// Path aliases
"@/actions/**",
"@/types/**",
"@/infra/**",
"@/components/**",
]),
"@/app/**",

// Experimental package APIs and exports are unknown, so allow any imports from them.
"@fluidframework/ai-collab/alpha",
],
},
],
// This is an example/test app; all its dependencies are dev dependencies so as not to pollute the lockfile
Expand Down
2 changes: 1 addition & 1 deletion examples/apps/ai-collab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ You can run this example using the following steps:
- For an even faster build, you can add the package name to the build command, like this:
`pnpm run build:fast --nolint @fluid-example/ai-collab`
1. Start a Tinylicious server by running `pnpm start:server` from this directory.
1. In a separate terminal also from this directory, run `pnpm next:dev` and open http://localhost:3000/ in a
1. In a separate terminal also from this directory, run `pnpm start` and open http://localhost:3000/ in a
web browser to see the app running.

### Using SharePoint embedded instead of tinylicious
Expand Down
13 changes: 13 additions & 0 deletions examples/apps/ai-collab/next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

// We deliberately configure NextJS to not use React Strict Mode, so we don't get double-rendering of React components
// during development. Otherwise containers get loaded twice, and the presence functionality works incorrectly, detecting
// every browser tab that *loaded* a container (but not the one that originally created it) as 2 presence participants.
const nextConfig = {
reactStrictMode: false,
};

export default nextConfig;
1 change: 1 addition & 0 deletions examples/apps/ai-collab/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@fluidframework/devtools": "workspace:~",
"@fluidframework/eslint-config-fluid": "^5.6.0",
"@fluidframework/odsp-client": "workspace:~",
"@fluidframework/presence": "workspace:~",
"@fluidframework/tinylicious-client": "workspace:~",
"@fluidframework/tree": "workspace:~",
"@iconify/react": "^5.0.2",
Expand Down
11 changes: 11 additions & 0 deletions examples/apps/ai-collab/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

"use client";

import { acquirePresenceViaDataObject } from "@fluidframework/presence/alpha";
import {
Box,
Button,
Expand All @@ -18,7 +19,10 @@ import {
import type { IFluidContainer, TreeView } from "fluid-framework";
import React, { useEffect, useState } from "react";

import { PresenceManager } from "./presence";

import { TaskGroup } from "@/components/TaskGroup";
import { UserPresenceGroup } from "@/components/UserPresenceGroup";
import {
CONTAINER_SCHEMA,
INITIAL_APP_STATE,
Expand Down Expand Up @@ -47,6 +51,7 @@ export async function createAndInitializeContainer(): Promise<
export default function TasksListPage(): JSX.Element {
const [selectedTaskGroup, setSelectedTaskGroup] = useState<SharedTreeTaskGroup>();
const [treeView, setTreeView] = useState<TreeView<typeof SharedTreeAppState>>();
const [presenceManagerContext, setPresenceManagerContext] = useState<PresenceManager>();

const { container, isFluidInitialized, data } = useFluidContainerNextJs(
containerIdFromUrl(),
Expand All @@ -57,6 +62,9 @@ export default function TasksListPage(): JSX.Element {
(fluidContainer) => {
const _treeView = fluidContainer.initialObjects.appState.viewWith(TREE_CONFIGURATION);
setTreeView(_treeView);

const presence = acquirePresenceViaDataObject(fluidContainer.initialObjects.presence);
setPresenceManagerContext(new PresenceManager(presence));
return { sharedTree: _treeView };
},
);
Expand All @@ -79,6 +87,9 @@ export default function TasksListPage(): JSX.Element {
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
maxWidth={false}
>
{presenceManagerContext && (
<UserPresenceGroup presenceManager={presenceManagerContext} />
)}
<Typography variant="h2" sx={{ my: 3 }}>
My Work Items
</Typography>
Expand Down
118 changes: 118 additions & 0 deletions examples/apps/ai-collab/src/app/presence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

import {
IPresence,
Latest,
type ISessionClient,
type PresenceStates,
type PresenceStatesSchema,
} from "@fluidframework/presence/alpha";

import { getProfilePhoto } from "@/infra/authHelper";

export interface User {
photo: string;
}

const statesSchema = {
onlineUsers: Latest({ photo: "" } satisfies User),
} satisfies PresenceStatesSchema;

export type UserPresence = PresenceStates<typeof statesSchema>;

// Takes a presence object and returns the user presence object that contains the shared object states
export function buildUserPresence(presence: IPresence): UserPresence {
const states = presence.getStates(`name:user-avatar-states`, statesSchema);
return states;
}

export class PresenceManager {
// A PresenceState object to manage the presence of users within the app
private readonly usersState: UserPresence;
// A map of SessionClient to UserInfo, where users can share their info with other users
private readonly userInfoMap: Map<ISessionClient, User> = new Map();
// A callback method to get updates when remote UserInfo changes
private userInfoCallback: (userInfoMap: Map<ISessionClient, User>) => void = () => {};

constructor(private readonly presence: IPresence) {
// Address for the presence state, this is used to organize the presence states and avoid conflicts
const appSelectionWorkspaceAddress = "aiCollab:workspace";

// Initialize presence state for the app selection workspace
this.usersState = presence.getStates(
appSelectionWorkspaceAddress, // Workspace address
statesSchema, // Workspace schema
);

// Listen for updates to the userInfo property in the presence state
this.usersState.props.onlineUsers.events.on("updated", (update) => {
// The remote client that updated the userInfo property
const remoteSessionClient = update.client;
// The new value of the userInfo property
const remoteUserInfo = update.value;

// Update the userInfoMap with the new value
this.userInfoMap.set(remoteSessionClient, remoteUserInfo);
// Notify the app about the updated userInfoMap
this.userInfoCallback(this.userInfoMap);
});

// Set the local user's info
this.setMyUserInfo().catch((error) => {
console.error(`Error: ${error} when setting local user info`);
});
}

// Set the local user's info and set it on the Presence State to share with other clients
private async setMyUserInfo(): Promise<void> {
const clientId = process.env.NEXT_PUBLIC_SPE_CLIENT_ID;
const tenantId = process.env.NEXT_PUBLIC_SPE_ENTRA_TENANT_ID;

// spe client
if (tenantId !== undefined && clientId !== undefined) {
const photoUrl = await getProfilePhoto();
this.usersState.props.onlineUsers.local = { photo: photoUrl };
}

this.userInfoMap.set(this.presence.getMyself(), this.usersState.props.onlineUsers.local);
this.userInfoCallback(this.userInfoMap);
}

// Returns the presence object
getPresence(): IPresence {
return this.presence;
}

// Allows the app to listen for updates to the userInfoMap
setUserInfoUpdateListener(callback: (userInfoMap: Map<ISessionClient, User>) => void): void {
this.userInfoCallback = callback;
}

// Returns the UserInfo of given session clients
getUserInfo(sessionList: ISessionClient[]): User[] {
const userInfoList: User[] = [];

for (const sessionClient of sessionList) {
// If local user or remote user is connected, then only add it to the list
try {
const userInfo = this.usersState.props.onlineUsers.clientValue(sessionClient).value;
// If the user is local user, then add it to the beginning of the list
if (sessionClient.sessionId === this.presence.getMyself().sessionId) {
userInfoList.push(userInfo);
} else {
// If the user is remote user, then add it to the end of the list
userInfoList.unshift(userInfo);
}
} catch (error) {
console.error(
`Error: ${error} when getting user info for session client: ${sessionClient.sessionId}`,
);
}
}

return userInfoList;
}
}
2 changes: 1 addition & 1 deletion examples/apps/ai-collab/src/app/spe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

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

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

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

Expand Down
1 change: 0 additions & 1 deletion examples/apps/ai-collab/src/components/TaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import { Tree, type TreeView } from "fluid-framework";
import { useSnackbar } from "notistack";
import React, { useState, type ReactNode, type SetStateAction } from "react";

// eslint-disable-next-line import/no-internal-modules
import { getOpenAiClient } from "@/infra/openAiClient";
import {
SharedTreeTask,
Expand Down
1 change: 0 additions & 1 deletion examples/apps/ai-collab/src/components/TaskGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import React, { useEffect, useState } from "react";

import { TaskCard } from "./TaskCard";

// eslint-disable-next-line import/no-internal-modules
import { getOpenAiClient } from "@/infra/openAiClient";
import {
aiCollabLlmTreeNodeValidator,
Expand Down
112 changes: 112 additions & 0 deletions examples/apps/ai-collab/src/components/UserPresenceGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

"use client";

import { Avatar, Badge, styled } from "@mui/material";
import React, { useEffect, useState } from "react";

import type { PresenceManager } from "@/app/presence";

interface UserPresenceProps {
presenceManager: PresenceManager;
}

const UserPresenceGroup: React.FC<UserPresenceProps> = ({ presenceManager }): JSX.Element => {
const [invalidations, setInvalidations] = useState(0);

useEffect(() => {
// Listen to the attendeeJoined event and update the presence group when a new attendee joins
const unsubJoin = presenceManager.getPresence().events.on("attendeeJoined", () => {
setInvalidations(invalidations + Math.random());
});
// Listen to the attendeeDisconnected event and update the presence group when an attendee leaves
const unsubDisconnect = presenceManager
.getPresence()
.events.on("attendeeDisconnected", () => {
setInvalidations(invalidations + Math.random());
});
// Listen to the userInfoUpdate event and update the presence group when the user info is updated
presenceManager.setUserInfoUpdateListener(() => {
setInvalidations(invalidations + Math.random());
});

return () => {
unsubJoin();
unsubDisconnect();
presenceManager.setUserInfoUpdateListener(() => {});
};
});

// Get the list of connected attendees
const connectedAttendees = [...presenceManager.getPresence().getAttendees()].filter(
(attendee) => attendee.getConnectionStatus() === "Connected",
);

// Get the user info for the connected attendees
const userInfoList = presenceManager.getUserInfo(connectedAttendees);

const StyledBadge = styled(Badge)(({ theme }) => ({
"& .MuiBadge-badge": {
backgroundColor: "#44b700",
color: "#44b700",
boxShadow: `0 0 0 2px ${theme.palette.background.paper}`,
"&::after": {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
borderRadius: "50%",
animation: "ripple 1.2s infinite ease-in-out",
border: "1px solid currentColor",
content: '""',
},
},
"@keyframes ripple": {
"0%": {
transform: "scale(.8)",
opacity: 1,
},
"100%": {
transform: "scale(2.4)",
opacity: 0,
},
},
}));

return (
<div>
{userInfoList.length === 0 ? (
<Avatar alt="User Photo" sx={{ width: 56, height: 56 }} />
) : (
<>
{userInfoList.slice(0, 4).map((userInfo, index) => (
<StyledBadge
key={index}
overlap="circular"
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
variant="dot"
>
<Avatar alt="User Photo" src={userInfo.photo} sx={{ width: 56, height: 56 }} />
</StyledBadge>
))}
{userInfoList.length > 4 && (
<Badge
overlap="circular"
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
badgeContent={`+${userInfoList.length - 4}`}
color="primary"
>
<Avatar alt="More Users" sx={{ width: 56, height: 56 }} />
</Badge>
)}
</>
)}
</div>
);
};

export { UserPresenceGroup };
Loading

0 comments on commit 915b76b

Please sign in to comment.