Desktop Chatbot
botframework electron typescript
November 10, 2017
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:
- .vscode contains build and run configuration for Visual Studio Code. Thanks to these files, we can press F5 and have the app running directly from the IDE.
- app contains all TypeScript files.
- out is where the installer and final EXE goes.
- src contains everything that is eventually deployed to the client: compiled JS files, corresponding MAP files, node_modules, index.html, icons and package.json.
- finally in the root there is the .gitignore and tsconfig.json.
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:
- we want to target ECMAScript 6,
- we want the output to go to src/js,
- we want it to look to src/node_modules/electron and src/node_modules/mkdirp and provide code suggestions from there.
{
"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:
- setting visibility and colors of the chat window header,
- setting visibility of the upload button.
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:
- BotConfig, which is immutable after app compilation and is usually set by an administrator.
- 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).
export function saveSettings(filename: string, settings)
{
filename = ensureStoragePath() + "/" + filename;
Fs.writeFileSync(filename, JSON.stringify(settings, null, 2));
}
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.
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;
}
}
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
);
}
Hyperlinks
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:
- install the app as System, instead of user,
- add the app to the Startup list automatically,
- prevent duplicates in the Startup folder on app update.
Close Behavior
We decided to alter the app's close behavior to:
- minimize and stay in the taskbar when the Minimize button is clicked,
- minimize and hide from taskbar when the Close button is clicked,
- exit app when the Quit option is selected from the tray context menu.
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:
- Windows version independent,
- system web browser version independent,
- with content updateable independently.
Regarding footprint, these are the results:
Package size on disk: 129 MB
Executable size: 80 MB
RAM consumption: 60 MB
Go check the repo here: https://github.com/msimecek/Desktop-Chatbot
Feedback
Found something inaccurate or plain wrong? Was this content helpful to you? Let me know!
📧 codez@deedx.cz