はじめに
TypeScript版のLangChainで作ったプログラムをデスクトップアプリ化する方法を解説する😊
そもそも何のためにデスクトップアプリ化するのか
LangChainで作った試作プログラムを非エンジニアの人に見てもらいたい😊
【補足】以前にexe化も試した
以前にコマンドライン上で動作するexeを作ったことがある😊
しかし画面がなくて寂しかったので、今回は画面ありのデスクトップアプリを作る!
作るもの
- 独自データ(名探偵コナンの映画「⿊鉄の⿂影」の内容)について答えてくれるプログラムをデスクトップアプリ化する
完成イメージ
結論
✅electronを使えばデスクトップアプリ化できる。
必要なもの
- Open AIのAPIキー
- Node.jsの開発環境
デスクトップアプリ化する
デスクトップアプリ化に使うツール
どうすればデスクトップアプリ化できるか調べるとNode.jsの「electron」を使う方法が出てきた💭
electronははじめて使うが、ドキュメントを見ながら作ることができた😊
使用するパッケージ
- electron
- electron-packager
- langchain
- openai
- hnswlib-node
- dotenv
- typescript
npmコマンドでインストールする。
npm install -D electron
npm install -D electron-packager
npm install langchain
npm install openai
npm install hnswlib-node
npm install dotenv
npm install -g typescript
【Windowsの場合】Visual Studioが必要
HNSWLibはC++のツールなのでC++をビルドするためにVisual Studioが必要。
(ダウンロードリンク)https://visualstudio.microsoft.com/thank-you-downloading-visual-studio/?sku=BuildTools
ダウンロード後、[C++によるデスクトップ開発]にチェックを付けてインストールすればOK!
APIキーの準備
空の.env
ファイルを作って、OpenAIのAPIキーを記載しておく。
.env
OPENAI_API_KEY="あなたのAPIキー"
TypeScriptを使う準備
以下のコマンドでtsconfig.json
を生成しておく。
tsc --init
必要なファイルを作成
electronでデスクトップアプリ化するためにいくつかファイルが必要💭
- index.html、chat.js(画面)
- preload.js(画面とバックエンドの仲介)
- main.js(バックエンド処理を書くところ)
- package.json(エントリーポイントの設定)
最終的なディレクトリ構成
index.html(画面)
✅こんな感じの画面を作る。
画面は好みのデザインでOK!
今回はChatGPTにいい感じの画面を作ってもらった😊
index.html(コピペでOK)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>チャットボット</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<style>
body {
background-color: #ffffff;
font-family: sans-serif;
font-size: 16px;
}
.container {
width: 100%;
margin: 20px auto;
}
.chat-box {
border: 1px solid #ccc;
padding: 20px;
}
.chat-message {
margin-bottom: 10px;
}
.chat-message.me {
background-color: #ccc;
color: #ffffff;
}
.chat-message.you {
background-color: #ffffff;
color: #333333;
}
.category-buttons {
margin-top: 10px;
}
.category-buttons button {
margin-right: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>チャットボット</h1>
<div class="chat-box">
<ul class="list-group" id="list-group">
<li class="list-group-item chat-message me">
<strong>あなた:</strong> こんにちは!
</li>
<li class="list-group-item chat-message you">
<strong>チャットボット:</strong> こんにちは!
</li>
</ul>
</div>
<form action="/chat" onsubmit="return false;">
<div class="form-group" id="form-group">
<div class="category-buttons" id="category-buttons">
<input type="text" class="form-control" name="question" id="question" placeholder="質問を入力してください">
<button type="button" class="btn btn-primary" id="exeAnswer">送信</button>
</div>
</div>
</form>
</div>
<script src="./chat.js"></script>
</body>
</html>
chat.js(画面操作)
✅index.htmlのUIを更新したり、送信ボタンクリック時の処理を書いたりするところ。
chat.js(コピペでOK)
// クロージャー
(() => {
document.getElementById("exeAnswer").addEventListener("click", chat);
// メッセージ送信ボタン押下時の処理
async function chat() {
// 質問取得
const question = getQuestion();
updateQ(question);
// 回答を生成
const answer = await getAnswer( question );
updateA(answer);
}
// -----------------------------------------
// UI更新
// -----------------------------------------
// 人間メッセージ追加
function addMessageHuman(message) {
const html = `
<li class="list-group-item chat-message me">
<strong>あなた:</strong> ${message}
</li>
`;
// メッセージ追加
document.getElementById("list-group").insertAdjacentHTML("beforeend", html);
}
// ボットメッセージ追加
function addMessageBot(message) {
const html = `
</li>
<li class="list-group-item chat-message you">
<strong>チャットボット:</strong> ${message}
</li>
`;
// メッセージ追加
document.getElementById("list-group").insertAdjacentHTML("beforeend", html);
}
// 質問を反映
function updateQ(question) {
addMessageHuman(question);
addMessageBot('考え中です...');
}
// 回答を反映
function updateA(answer) {
addMessageBot(answer);
}
// -----------------------------------------
// プロセス間通信
// -----------------------------------------
// チャット処理を呼び出す
async function sendByApi(
question // 質問内容
){
// メインプロセスに送信(preload.jsで用意したchatApi.api1()を使用する)
result = await window.chatApi.api1(question);
console.log(result);
return result;
}
// -----------------------------------------
// getter
// -----------------------------------------
// 質問を取得
function getQuestion() {
const question = document.getElementById("question").value;
return question;
}
// 回答を取得
async function getAnswer( question ) {
const answer = await sendByApi( question );
return answer;
};
})();
preload.ts(画面とバックエンドの仲介)
✅画面とバックエンドを仲介する処理。
preload.ts(コピペでOK)
※今回はTypeScriptで書いた。後でトランスパイルが必要。
// -----------------------------------------
// プロセス間通信
// -----------------------------------------
const {contextBridge ,ipcRenderer} = require('electron');
// レンダラープロセス内で実行される非同期関数api1を定義
const api1 = async (...args: any[]) => {
// メインプロセス(バックエンド)の処理channel_ichiriを呼び出す
const result = await ipcRenderer.invoke('channel_ichiri', ...args);
return result;
};
// contextBridgeを使って、レンダラープロセス内(ブラウザ側)で使用可能なAPIを設定する
contextBridge.exposeInMainWorld(
// 'chatApi'という名前でAPIを公開する
'chatApi', {
api1: async (...args: Array<any>) => api1('channel_ichiri', ...args),
})
main.ts(バックエンド処理)
✅送信ボタンがクリックされたときの回答処理(LangChainを使った処理)
main.ts(コピペでOK)
※今回はTypeScriptで書いた。後でトランスパイルが必要。
// モデル
import { ChatOpenAI } from "langchain/chat_models/openai";
// 埋め込み
import { OpenAIEmbeddings } from "langchain/embeddings/openai";
// ベクトル検索エンジン
import { HNSWLib } from "langchain/vectorstores/hnswlib";
// チェーン
import { ConversationalRetrievalQAChain } from "langchain/chains";
// メモリー
import { BufferMemory } from "langchain/memory";
// パス操作
const path = require('path');
// アプリケーション作成用のモジュールを読み込み
const { app, BrowserWindow, ipcMain } = require("electron");
// -----------------------------------------
// electronに必要な処理
// -----------------------------------------
// メインウィンドウ
let mainWindow;
const createWindow = () => {
// メインウィンドウを作成します
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
// プリロードスクリプトは、レンダラープロセスが読み込まれる前に実行され、
// レンダラーのグローバル(window や document など)と Node.js 環境の両方にアクセスできます。
preload: path.join(__dirname, "preload.js"),
},
});
// メインウィンドウに表示するURLを指定します
// (今回はmain.jsと同じディレクトリのindex.html)
mainWindow.loadFile("index.html");
// デベロッパーツールの起動
// mainWindow.webContents.openDevTools();
// メインウィンドウが閉じられたときの処理
mainWindow.on("closed", () => {
mainWindow = null;
});
};
// 初期化が完了した時の処理
app.whenReady().then(() => {
createWindow();
// アプリケーションがアクティブになった時の処理(Macだと、Dockがクリックされた時)
app.on("activate", () => {
// メインウィンドウが消えている場合は再度メインウィンドウを作成する
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
// 全てのウィンドウが閉じたときの処理
app.on("window-all-closed", () => {
// macOSのとき以外はアプリケーションを終了させます
if (process.platform !== "darwin") {
app.quit();
}
});
// -----------------------------------------
// 回答を生成
// -----------------------------------------
export const runLlm = async ( question: string ) => {
const path = require('path');
// APIキー読み込み
try {
require("dotenv").config({ path: path.join(__dirname, '.env') });
} catch (error) {
console.error('.envファイルの読み込みに失敗しました:', error);
}
// 作成済みのインデックスを読み込む
const vectorStore = await HNSWLib.load(
path.join(__dirname, 'index'), // indexフォルダ
new OpenAIEmbeddings()
);
// モデル
const model = new ChatOpenAI({});
// チェーン
const chain = ConversationalRetrievalQAChain.fromLLM(
model,
vectorStore.asRetriever(),
{
qaChainOptions: {type: "stuff"},
memory: new BufferMemory({
memoryKey: "chat_history",
inputKey: "question",
outputKey: "text",
returnMessages: true,
}),
// verbose: true,
}
);
// 質問する
const res = await chain.call({
question: question,
});
// 回答を返す
return res['text'];
};
// -----------------------------------------
// プロセス間通信
// -----------------------------------------
ipcMain.handle('channel_ichiri', async (event: Object, ...args: Array<any>) => {
// 【テスト】引数確認
console.log(event);
args.forEach( function(item, index) {
console.log("[" + index + "]=" + item);
});
if ( args.length !== 2 ) {
console.error( "channel_ichiriの引数が2個ではありません。" );
return;
}
// 回答生成
const question = args[1];
const res = runLlm(question);
// 回答を返す
return res;
})
// mainWindow.webContents.openDevTools();
package.json(エントリーポイントの設定)
✅package.jsonも修正が必要!
エントリーポイント(メインプロセスのJavaScriptファイル)を設定する。
{
"name": "xxxx",
"version": "x.x.x",
"description": "xxxx",
"main": "main.js", 👈ここを変える
"devDependencies": {
省略
},
"dependencies": {
省略
}
}
トランスパイル
TypeScriptをトランスパイルしてJavaScriptファイルを生成する。
以下のコマンドを実行する✅
tsc
main.jsとpreload.jsが生成できた😊
テスト実行
✅デスクトップアプリ化する前に、プログラムの動作確認をする。
以下のコマンドでテスト実行できる。
npx electron ./
するとこんな画面が起動するはず!
質問を入力し、送信してしばらく待つと…
独自データ(indexフォルダのデータ)を使って回答してくれる!
デスクトップアプリ化
動作確認が済んだので、最後にデスクトップアプリ化する。
✅以下のコマンドでデスクトップアプリ化できる。
(Windowsの場合)
npx electron-packager ./ ChatApp --platform=win32 --arch=x64 --overwrite
(Mac-M1の場合)
npx electron-packager ./ ChatApp --platform=darwin --arch=arm64 --overwrite
(Mac-Intelの場合)
npx electron-packager ./ ChatApp --platform=darwin --arch=x64 --overwrite
これでルートディレクトリにChatApp-darwin-arm64
のような名前のフォルダが生成される😊
✅ChatApp.app(WindowsならChatApp.exe)を実行すると、先ほどと同じプログラムが起動する!
デスクトップアプリを配布する
ChatApp.app(WindowsならChatApp.exe)を配布すれば、Node.jsの環境がないPCでも使用可能😊
注意点
セキュリティは非考慮
知り合いに使ってもらうのを想定しているのでセキュリティは考慮していない。