Persisting Copilot chat sessions across page navigations and refreshes

I was struggling to understand how to persist Copilot chat sessions during navigations or refreshes of the site. In this post I discuss how I arrived at the solution.

Why, oh why is this not easier for clients and admins?

Recently I consulted with a client who was psyched for Copilot studio and creating their own generative AI bot for their website.  Who could blame them?  Even with all of the hype, there is a lot to the LLM technology behind the generative AI push right now.  As it turns out, what the client assumed would be hard was easy in Copilot Studio and what they thought would be easy was hard (for them).

Get to the point Hetz: the code.

There are a lot of really great source for information on the Microsoft Bot Framework out there.  These sources have been built up in the years of the framework's existence and evolution.  The problem with Copilot studio is that no one is telling clients that once you have trained your model, built out your topics, incorporated any number of low-code features into your awesome design - that if you want to customize the look and feel to match your site, you need to understand the bot framework.  Same is true if you want the bot to be tolerant of page navigations and refreshes. In other words, if you want to host the bot on your own site (that the bot was trained on) you need to use the DirectLine mechanism and the WebChat canvas from the bot framework.

To be fair, this documentation does exist and is helpful.  However, clients are looking for a 'settings' area in Copilot studio to do this work, rather than having to enlist their site developer to write / update JScript on the site.  So, let's start walking through the example code and pointing out where the updates were made to assist with this client's needs.

Here is the sample code from the Microsoft documentation:

<!doctype html>
<html lang="en">
  <head>
    <title>Contoso Sample Web Chat</title>
    <!--
      This styling is for the Web Chat demonstration purposes.
      It is recommended that style is moved to a separate file for organization in larger projects.
      Please visit https://github.com/microsoft/BotFramework-WebChat for details about Web Chat.
    -->
    <style>
      html,
      body {
        height: 100%;
      }

      body {
        margin: 0;
      }

      h1 {
        color: whitesmoke;
        font-family: Segoe UI;
        font-size: 16px;
        line-height: 20px;
        margin: 0;
        padding: 0 20px;
      }

      #banner {
        align-items: center;
        background-color: black;
        display: flex;
        height: 50px;
      }

      #webchat {
        height: calc(100% - 50px);
        overflow: hidden;
        position: fixed;
        top: 50px;
        width: 100%;
      }
    </style>
  </head>
  <body>
    <div>
      <div id="banner">
        <h1>Contoso Bot Name</h1>
      </div>
      <div id="webchat" role="main"></div>
    </div>
    <!--
      In this sample, the latest version of Web Chat is being used.
      In production environment, the version number should be pinned and version bump should be done frequently.
      Please visit https://github.com/microsoft/BotFramework-WebChat/tree/main/CHANGELOG.md for changelog.
    -->
    <script crossorigin="anonymous" src="https://cdn.botframework.com/botframework-webchat/latest/webchat.js"></script>

    <script>
      (async function () {
        // Specifies style options to customize the Web Chat canvas.
        // Please visit https://microsoft.github.io/BotFramework-WebChat for customization samples.
        const styleOptions = {
          // Hide upload button.
          hideUploadButton: true
        };

        // Specifies the token endpoint URL.
        // To get this value, visit Copilot Studio > Settings > Channels > Mobile app page.
        const tokenEndpointURL = new URL('<BOT TOKEN ENDPOINT>');

        // Specifies the language the copilot and Web Chat should display in:
        // - (Recommended) To match the page language, set it to document.documentElement.lang
        // - To use current user language, set it to navigator.language with a fallback language
        // - To use another language, set it to supported Unicode locale
        // Setting page language is highly recommended.
        // When page language is set, browsers will use native font for the respective language.

        const locale = document.documentElement.lang || 'en'; // Uses language specified in <html> element and fallback to English (United States).
        // const locale = navigator.language || 'ja-JP'; // Uses user preferred language and fallback to Japanese.
        // const locale = 'zh-HAnt'; // Always use Chinese (Traditional).

        const apiVersion = tokenEndpointURL.searchParams.get('api-version');

        const [directLineURL, token] = await Promise.all([
          fetch(new URL(`/powervirtualagents/regionalchannelsettings?api-version=${apiVersion}`, tokenEndpointURL))
            .then(response => {
              if (!response.ok) {
                throw new Error('Failed to retrieve regional channel settings.');
              }

              return response.json();
            })
            .then(({ channelUrlsById: { directline } }) => directline),
              fetch(tokenEndpointURL)
            .then(response => {
              if (!response.ok) {
                throw new Error('Failed to retrieve Direct Line token.');
              }

              return response.json();
            })
            .then(({ token }) => token)
        ]);

        // The "token" variable is the credentials for accessing the current conversation.
        // To maintain conversation across page navigation, save and reuse the token.
        // The token could have access to sensitive information about the user.
        // It must be treated like user password.

        const directLine = WebChat.createDirectLine({ domain: new URL('v3/directline', directLineURL), token });

        // Sends "startConversation" event when the connection is established.
        const subscription = directLine.connectionStatus$.subscribe({
          next(value) {
            if (value === 2) {
              directLine
                .postActivity({
                  localTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
                  locale,
                  name: 'startConversation',
                  type: 'event'
                })
                .subscribe();

              // Only send the event once, unsubscribe after the event is sent.
              subscription.unsubscribe();
            }
          }
        });

        WebChat.renderWebChat({ directLine, locale, styleOptions }, document.getElementById('webchat'));
      })();
    </script>
  </body>
</html>

Style updates to the chat canvas

See the const styleOptions object on lines 67 - 70?  Well, that is the object which allows you to set all kinds of stylistic features in the default WebChat canvas of the Copilot bot.  The complete list of properties can be found in the defaultStyleOption.ts file on GitHub: https://github.com/microsoft/BotFramework-WebChat/blob/master/packages/api/src/defaultStyleOptions.ts

Here is an example of what I did in this section to demonstrate to this client's web developer how to update the various properties of the default WebChat canvas.  It is important to note that the colors are specified in rgb format.

 const styleOptions = {
          // Hide upload button.
          hideUploadButton: true,
          botAvatarInitials: 'CP',          
          botAvatarBackgroundColor: 'rgb(0,25,49)',
          userAvatarBackgroundColor: 'rgba(255,215,0)',
          userAvatarInitials: 'You'
};

Persisting the conversation

Next, persisting the conversation is a little less straightforward.  Doing this requires some updates throughout the code and figuring out how to do it in this code was a challenge.  There are several examples out there which demonstrate various ways to achieve this conversation persistence across refreshes and navigations, however none of them use this version of the canvas completely.  Specifically, I am referring to using the subscribe promise on the directLine.connectionStatus$ object. Then we also need to ensure that if there is a conversation to be reloaded, it is and if there is not one, then a new conversation is initiated.

This functionality relies on several key pieces of data within the directLine object: conversationId, token, directLineUrl, and watermark.

token: A security token for verifying that you should have access to the conversationId. You'll notice that in the sample code, this token is retrieved on every page load, so in this sample you are not able to retrieve the original conversationId after a refresh, due to the token being renewed.

conversationId: A unique identifier for a specific conversation a user is having with your site's Copilot.

directLineUrl: The URL for interacting with the Copilot bot.

watermark: A point in the conversation to restore to.  Also required if you need to re-draw the chat bubbles in the canvas after a refresh / navigation.

First, watermark is set to a constant value:

const watermark = 1;

Next, we need to ensure that if there is a saved token, a new token and a new directlineUrl are not generated.  This can be done by placing a null check on your storage for the token being present:

if(!sessionStorage['token']) {
          var [directLineURL, token] = await Promise.all([
            fetch(new URL(`/powervirtualagents/regionalchannelsettings?api-version=${apiVersion}`, tokenEndpointURL))
              .then(response => {
                if (!response.ok) {
                  throw new Error('Failed to retrieve regional channel settings.');
                }

                return response.json();
              })
              .then(({ channelUrlsById: { directline } }) => directline),
            fetch(tokenEndpointURL)
              .then(response => {
                if (!response.ok) {
                  throw new Error('Failed to retrieve Direct Line token.');
                }

                return response.json();
              })
              .then(({ token }) => token)
          ]);

          // The "token" variable is the credentials for accessing the current conversation.
          // To maintain conversation across page navigation, save and reuse the token.        
          sessionStorage['token'] = token;
          sessionStorage['directLineURL'] = directLineURL;
}

The first line checks to see if there is a saved token in sessionStorage and if there is not one, then a new direcLineUrl and token are generated and save into sessionStorage at the end of the if statement.

Since these properties are all used to create an instance of the directLine object, they need to be passed to that object's constructor.  In our case, that means they need to be passed as parameters into the WebChat.createDirectLine() method.  So, we need to update the following code:

const directLine = WebChat.createDirectLine({ domain: new URL('v3/directline', directLineURL), token });

So that it supplies the persisted values for the properties listed above.  I am saving these in sessionStorage and we will see how that is done in just a bit.  We also need to ensure that if there is a conversationId for a previous conversation we use it and the previous token as well.  I am setting watermark to be a constant for this client, as they want to assume that the person using the bot will want to see the entire chat at all times.  Here is the updated method invocation:

// The token could have access to sensitive information about the user.
// It must be treated like user password.
conversationId = sessionStorage['conversationId'];  // If this is set, the there is an existing conversation to be retrieved, watermark is a const value of 1
var directLine;
if(conversationId) { 
     directLine = WebChat.createDirectLine({ domain: new URL('v3/directline', sessionStorage['directLineURL']), token: sessionStorage['token'], conversationId: conversationId, watermark: watermark});
}
else {
     directLine = WebChat.createDirectLine({ domain: new URL('v3/directline', directLineURL), token: token, watermark: watermark});
}

As you can see, if a conversationId was persisted in sessionStorage then it, along with the saved token and directLineUrl, are used to re-instantiate the directLine object.

Finally, saving the conversatioId.  Here you will see that I am saving it into sessionStorage immediately after the connectionStatus is '2' or 'Online'.

// Sends "startConversation" event when the connection is established.
const subscription = directLine.connectionStatus$.subscribe({
  next(value) {
    if (value === 2) {
      sessionStorage['conversationId'] = directLine.conversationId; // Store the conversation id to use across refreshes and page navigations
      directLine
        .postActivity({
         localTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
          locale,
          name: 'startConversation',
          type: 'event'
        })
        .subscribe();

      // Only send the event once, unsubscribe after the event is sent.
      subscription.unsubscribe();
    }
  }          
});

I hope this helps you solve this issue with your Copilot bots in less time than it took me to compile this from all of the different Bing searches I could think of.  Here is the final code in its entirety:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Bot Name</title>
    <!--
      This styling is for the Web Chat demonstration purposes.
      It is recommended that style is moved to a separate file for organization in larger projects.

      Please visit https://github.com/microsoft/BotFramework-WebChat for details about Web Chat.
    -->
    <style>
      html,
      body {
        height: 100%;
      }

      body {
        margin: 0;
      }

      h1 {
        color: whitesmoke;
        font-family: Segoe UI;
        font-size: 16px;
        line-height: 20px;
        margin: 0;
        padding: 0 20px;
      }

      #banner {
        align-items: center;
        background-color: #001931;
        display: flex;
        height: 50px;
      }

      #webchat {
        height: calc(100% - 50px);
        overflow: hidden;
        position: fixed;
        top: 50px;
        width: 100%;        
      }
    </style>
  </head>
  <body>
    <div>
      <div id="banner">
        <h1>Bot Title</h1>
      </div>
      <div id="webchat" role="main"></div>
    </div>

    <!--
      In this sample, the latest version of Web Chat is being used.
      In production environment, the version number should be pinned and version bump should be done frequently.

      Please visit https://github.com/microsoft/BotFramework-WebChat/tree/main/CHANGELOG.md for changelog.
    -->
    <script crossorigin="anonymous" src="https://cdn.botframework.com/botframework-webchat/latest/webchat.js"></script>

    <script>
      (async function () {
        // Specifies style options to customize the Web Chat canvas.
        // Please visit https://microsoft.github.io/BotFramework-WebChat for customization samples.
        const styleOptions = {
          // Hide upload button.
          hideUploadButton: true,
          botAvatarInitials: 'BF',          
          botAvatarBackgroundColor: 'rgb(0,25,49)',
          userAvatarBackgroundColor: 'rgba(255,215,0)',
          userAvatarInitials: 'You'
        };

        // Specifies the token endpoint URL.
        // To get this value, visit Copilot Studio > Settings > Channels > Mobile app page.
        const tokenEndpointURL = new URL('<BOT TOKEN ENDPOINT>');

        // Specifies the language the copilot and Web Chat should display in:
        // - (Recommended) To match the page language, set it to document.documentElement.lang
        // - To use current user language, set it to navigator.language with a fallback language
        // - To use another language, set it to supported Unicode locale

        // Setting page language is highly recommended.
        // When page language is set, browsers will use native font for the respective language.

        const locale = document.documentElement.lang || 'en'; // Uses language specified in <html> element and fallback to English (United States).
        // const locale = navigator.language || 'ja-JP'; // Uses user preferred language and fallback to Japanese.
        // const locale = 'zh-HAnt'; // Always use Chinese (Traditional).

        const apiVersion = tokenEndpointURL.searchParams.get('api-version');
        const watermark = 1;

        // If the token is empty, then we need to get the URL and token, otherwise there is an existing conversion and we need to 
        // use the existing token to retrieve the existing conversation
        if(!sessionStorage['token']) {
          var [directLineURL, token] = await Promise.all([
            fetch(new URL(`/powervirtualagents/regionalchannelsettings?api-version=${apiVersion}`, tokenEndpointURL))
              .then(response => {
                if (!response.ok) {
                  throw new Error('Failed to retrieve regional channel settings.');
                }

                return response.json();
              })
              .then(({ channelUrlsById: { directline } }) => directline),
            fetch(tokenEndpointURL)
              .then(response => {
                if (!response.ok) {
                  throw new Error('Failed to retrieve Direct Line token.');
                }

                return response.json();
              })
              .then(({ token }) => token)
          ]);

          // The "token" variable is the credentials for accessing the current conversation.
          // To maintain conversation across page navigation, save and reuse the token.        
          sessionStorage['token'] = token;
          sessionStorage['directLineURL'] = directLineURL;
        }        

        // The token could have access to sensitive information about the user.
        // It must be treated like user password.
        conversationId = sessionStorage['conversationId'];  // If this is set, the there is an existing conversation to be retrieved, watermark is a const value of 1
        var directLine;
        if(conversationId) { 
          directLine = WebChat.createDirectLine({ domain: new URL('v3/directline', sessionStorage['directLineURL']), token: sessionStorage['token'], conversationId: conversationId, watermark: watermark});
        }
        else {
          directLine = WebChat.createDirectLine({ domain: new URL('v3/directline', directLineURL), token: token, watermark: watermark});
        }

        // Sends "startConversation" event when the connection is established.
        const subscription = directLine.connectionStatus$.subscribe({
          next(value) {
            if (value === 2) {
              sessionStorage['conversationId'] = directLine.conversationId; // Store the conversation id to use across refreshes and page navigations
              directLine
                .postActivity({
                  localTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
                  locale,
                  name: 'startConversation',
                  type: 'event'
                })
                .subscribe();

              // Only send the event once, unsubscribe after the event is sent.
              subscription.unsubscribe();
            }
          }          
        });

        WebChat.renderWebChat({ directLine, locale, styleOptions }, document.getElementById('webchat')); 
      })();
    </script>
  </body>
</html>

 

 

 

 

 

Leave a comment

Please note that we won't show your email to others, or use it for sending unwanted emails. We will only use it to render your Gravatar image and to validate you as a real person.