Desktop Chatbot

November 10, 2017
botframework electron typescript

Together with IT department and developers from the General University Hospital in Prague (VFN in short) we have built an internal chatbot for employees to search through their organization. When finalizing this solution we found it difficult to host the chatbot on an internal SharePoint site and decided to extend the solution with a desktop application.

This article describes the process of creating a desktop hosting application, which can host any chatbot built using Microsoft’s Bot Framework.

Problem Statement

General University Hospital in Prague is one of the biggest healthcare facilities in the Czech Republic. It provides basic, specialized and very specialized medical, nursing, outpatient and diagnostic care for children and adults in all basic medical fields. It also provides comprehensive pharmaceutical care, including the technologically complicated preparation of cytostatics and sterile medicinal preparations.

The hospital is preparing to launch an internal chatbot – running on Azure and Microsoft Bot Framework – to improve their employee search experience. This bot is designed to run in a web client, but there are issues with hosting environment – the generic WebChat control doesn’t work properly on a SharePoint site with Internet Explorer 11 and Windows 7. Reduced functionality prevented wide deployment.

Solution

To deal with limitations of the environment we decided to build a desktop application wrapper for the Bot Framework WebChat control using Electron.

The goal is to allow users working on their PC or Mac to have quick access to the chatbot like any other desktop application.

Key Technologies:

Implementation

The app is based on customer’s requirements, but it was built with reusability on mind If you don’t need any specific customizations, you can just do git clone, npm install, set chatbot secret, tsc and npm start.

GitHub repository: https://github.com/msimecek/Desktop-Chatbot

Why Electron?

Electron’s popularity is growing even though there are objections raising against it. Developers usually criticise its memory footprint, performance (or rather the lack of it) and size of final packages.

With its shortcomings on mind, we chose Electron for this project because it provides with consistent rendering environment (V8 and Chromium) across different operating systems and system-wide web browser engines. The final application can be customized, installed globally and run on system start.

Start

I strongly recommend starting with the Electron Quick Start app. It includes basic boilerplate code along with useful comments and can serve as the starting point for any Electron project.

git clone https://github.com/electron/electron-quick-start
cd electron-quick-start
npm install && npm start

In this project we have used TypeScript, so we have changed the original code from the template to benefit from TypeScript features. Since June 2017, Electron bundles TypeScript typings with the NPM package, therefore it’s not necessary to install them separately in order to get IDE support.

Project Structure

We chose the following project structure:

Images folder contains screenshots for the README.md file.

At the core of TypeScript magic is the tsconfig.json file. Because of this file the TypeScript compiler knows that:

{
    "compilerOptions": {
        "module": "commonjs",
        "target": "es6",
        "outDir": "src/js",
        "sourceMap": true,
        "types": [
            "node"
        ],
        "typeRoots": [
            "src/node_modules/@types"
        ],
        "baseUrl": ".",
        "paths": {
            "electron": ["src/node_modules/electron"],
            "mkdirp": ["src/node_modules/mkdirp"]
        }
    },
    "compileOnSave": true
}

Visual Studio Code support is defined in .vscode/tasks.json and .vscode/launch.json.

Build task transpiles TypeScript code to JavaScript. It can be triggered with a mouse from the IDE or by pressing Ctrl +Shift+B.

tasks.json

{
    "version": "2.0.0",
    "tasks": [
        {
            "identifier": "build-tsc",
            "type": "typescript",
            "tsconfig": "tsconfig.json",
            "problemMatcher": [
                "$tsc"
            ],
            "group": {
                "kind": "build",
                "isDefault": true
            }
        }
    ]
}

Launch configuration instructs VS Code to build the app first (by launching the build-tsc task) and then run the main.js script with Electron binary. It can be trigger with a mouse from the IDE or by pressing F5.

launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "Electron Main",
            "runtimeExecutable": "${workspaceFolder}/src/node_modules/.bin/electron",
            "program": "${workspaceFolder}/src/js/main.js",
            "cwd": "${workspaceFolder}/src",
            "protocol": "legacy",
            "preLaunchTask": "build-tsc"
        }
    ]
}

Main Screen

The app contains only one screen - the chatbot window - with a few tweaks. Everything important related to what the user can see happens in index.html and renderer.ts.

The Bot Framework WebChat control is embedded as described on GitHub.

Before rendering, there are a few CSS settings in place. Specifically:

import { BotConfig } from "./botConfig";

let style = document.createElement("style");
style.type = "text/css";

if (BotConfig.header.visible) {
    style.innerHTML = ".wc-header {background-color: " + (BotConfig.header.backgroundColor || "#3a96dd") + "; color: " + (BotConfig.header.textColor || "white") + ";}";
}
else {
    style.innerHTML = ".wc-header {display:none}";
}

if (!BotConfig.uploadButton)
    style.innerHTML += ".wc-upload {display: none} .wc-textbox {left: 10px; right: 0}";

document.getElementsByTagName('head')[0].appendChild(style);

And then we embed the BotChat component.

declare var BotChat:any;

BotChat.App({
    directLine: { secret: BotConfig.bot.directLineSecret },
    user: { id: BotConfig.bot.userId, name: BotConfig.bot.userName },
    bot: { id: BotConfig.bot.botId, name: BotConfig.bot.botName },
    resize: 'detect'
  }, document.getElementById("bot"));

To prevent TypeScript from complaining about unknown BotChat, I declare it before actual use.

To allow for easier customization, we decided to reference an external CSS stylesheet hosted on VFN’s web server. This reference would go to the index.html page’s header tag and is not part ot the GitHub repo.

Configuration

There are two types of configuration in this project:

  1. BotConfig, which is immutable after app compilation and is usually set by an administrator.
  2. Settings, which contains user-related information about the app - such as window position.

To start with the app quickly, you should modify only the botConfig.ts file and set the bot.directLineSecret value.

Overview of the configuration values:

Property Explanation
header.backgroundColor Background color of the WebChat header (if displayed).
header.textColor Foreground color of the WebChat header (if displayed).
header.visible Show/hide the WebChat header.
bot.directLineSecret Defines connection to your bot. Comes from the Bot Framework portal.
bot.userId ID of the user, so that you can identify them on backend.
bot.userName User’s name shown in the chat window.
bot.botId ID of the bot. WebChat overrides this.
bot.botName Bot’s name. WebChat overrides this.
uploadButton Show/hide the upload button in message window.
devMode Show/hide the Chromium developer tools panel.

Saving Window Position

A user can resize the chatbot window and place it anywhere on the screen. To persist the screen size and location between restarts, it is saved to a JSON file every time any of the dimensions changes (after the resize and move Electron events).

settings.ts

export function saveSettings(filename: string, settings)
{
    filename = ensureStoragePath() + "/" + filename;
    Fs.writeFileSync(filename, JSON.stringify(settings, null, 2));
}

main.ts

mainWindow.on("resize", () => { saveWindowState(); });
mainWindow.on("move", () => { saveWindowState(); });

function saveWindowState() {
  var bounds = mainWindow.getBounds();

  settings.windowState.width = bounds.width;
  settings.windowState.height = bounds.height;
  settings.windowState.left = bounds.x;
  settings.windowState.top = bounds.y;

  saveSettings(settingsFileName, settings);
}

Window position is loaded when the application starts and main window is created.

settings.ts

export function loadSettings(filename: string) 
{
    try {
        filename = ensureStoragePath() + "/" + filename;
        const stat = Fs.statSync(filename);
        if (stat.isFile()) {
            return JSON.parse(Fs.readFileSync(filename, "utf-8"));
        }
        return defaultSettings;
    } catch (e) {
        console.error(`Failed to read: ${filename}`, e);
        return defaultSettings;
    }

}

main.ts

function createWindow () {
  settings = loadSettings(settingsFileName);
  
  let initPosition: Electron.Rectangle = {
    width: settings.windowState.width || 0,
    height: settings.windowState.height || 0,
    x: settings.windowState.left || 0,
    y: settings.windowState.top || 0,
  }
  
  if (isWindowOffScreen(initPosition)) {
    let display = Electron.screen.getAllDisplays().find(display => display.id === settings.windowState.displayId);
    display = display || Electron.screen.getDisplayMatching(initPosition);
    initPosition.x = display.workArea.x;
    initPosition.y = display.workArea.y;
  };
  
  mainWindow = new BrowserWindow(
    {
      width: settings.windowState.width,
      height: settings.windowState.height, 
      x: initPosition.x, 
      y: initPosition.y,
      title: windowTitle,
      icon: path.join(__dirname, "..", "assets", "icon.ico"),
      show: false
    }
  )

Window Off Screen

It can happen that the window is set to appear off-screen (for instance in cases when the user has multiple monitors connected). That’s why we also check whether the saved location is inside the working area of current display.

const isWindowOffScreen = function(windowBounds: Electron.Rectangle): boolean {
  const nearestDisplay = Electron.screen.getDisplayMatching(windowBounds).workArea;
  return (
      windowBounds.x > (nearestDisplay.x + nearestDisplay.width) ||
      (windowBounds.x + windowBounds.width) < nearestDisplay.x ||
      windowBounds.y > (nearestDisplay.y + nearestDisplay.height) ||
      (windowBounds.y + windowBounds.height) < nearestDisplay.y
  );
}

Clicking a hyperlink with external URL opens a new Electron window and loads the site inside of it. Preferred behavior for this chatbot is to open a new web browser window instead. That’s why we handled the new-window event:

mainWindow.webContents.on("new-window", function(event, url) {
  event.preventDefault();
  Electron.shell.openExternal(url);
});

Generate EXE

You can generate the resulting application using the command line or with the provided installer.js script. Both methods use the electron-packager NPM package.

Command line:

electron-packager . --overwrite --icon="C:\chatbot-electron\icon.ico" --win32metadata.ProductName="Chatbot" --win32metadata.FileDescription="Chatbot" --app-copyright="Copyright (c) 2017 Martin Simecek" --asar

Script:

cd src/js
node installer.js

Both produce the Chatbot.exe along with other supporting files and the app code packaged to ASAR format. Everything is placed into the /out/Chatbot-win32-x64 folder.

Installer

The installer.js script also builds an installer (using the electron-winstaller package). VFN had specific needs though, so they created the installer project in Visual Studio 2017 and included all files from the /out/Chatbot-win32-x64 folder.

Using this approach, they were able to:

Close Behavior

We decided to alter the app’s close behavior to:

First step is to introduce a new global property isQuitting:

let isQuitting: boolean = false;

And then handle the close Electron event:

mainWindow.on("close", function(event: Electron.Event) {
  if (!isQuitting) {
    event.preventDefault();
    mainWindow.hide();
  }
});

Finally, exit only from the tray menu:

const contextMenu = Electron.Menu.buildFromTemplate([
...
  { label: Strings(locale).quit, click: function() {
    isQuitting = true;
    app.quit();
    } }
]);

app.quit() fires the close event, which in this case proceeds and terminates the app.

Conclusion

At the end of this project, VFN is able to package and distribute their chatbot as a desktop application with the following benefits:

Regarding footprint, these are the results:

Package size on disk: 129 MB

08-result-size

Executable size: 80 MB

09-result-exe-size

RAM consumption: 60 MB

10-result-size-ram

Go check the repo here: https://github.com/msimecek/Desktop-Chatbot

comments powered by Disqus