コピペで動く!LangChainで作ったプログラムをデスクトップアプリにする方法【TypeScript編】

Featured image of the post

はじめに

TypeScript版のLangChainで作ったプログラムをデスクトップアプリ化する方法を解説する😊

そもそも何のためにデスクトップアプリ化するのか

LangChainで作った試作プログラムを非エンジニアの人に見てもらいたい😊

💡
デスクトップアプリ化すれば環境構築不要で誰でも使える!!

【補足】以前にexe化も試した

以前にコマンドライン上で動作するexeを作ったことがある😊

しかし画面がなくて寂しかったので、今回は画面ありのデスクトップアプリを作る!

💡
以前の記事を読んでなくても大丈夫です◎

作るもの
  • 独自データ(名探偵コナンの映画「⿊鉄の⿂影」の内容)について答えてくれるプログラムをデスクトップアプリ化する

完成イメージ

Image in a image block

💡
知り合いに見せる用なのでとりあえず動くものを目標にする!

結論

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!

Image in a image block

APIキーの準備

空の.envファイルを作って、OpenAIのAPIキーを記載しておく。

Image in a image block

.env

OPENAI_API_KEY="あなたのAPIキー"

TypeScriptを使う準備

以下のコマンドでtsconfig.jsonを生成しておく。

Image in a image block

tsc --init

必要なファイルを作成

electronでデスクトップアプリ化するためにいくつかファイルが必要💭

  • index.html、chat.js(画面)
  • preload.js(画面とバックエンドの仲介)
  • main.js(バックエンド処理を書くところ)
  • package.json(エントリーポイントの設定)

💡
electronを知らなくてもこれから紹介するコードをコピペすれば動く

最終的なディレクトリ構成
Image in a image block

注意点

hnswlibで作ったindexフォルダ(独自データ)が必要!

indexフォルダを持っていない場合は以下をダウンロードすればOK。

🔗
名探偵コナンの映画「⿊鉄の⿂影」についてのデータ

index.html(画面)

✅こんな感じの画面を作る。

Image in a image block

画面は好みのデザインで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(画面とバックエンドの仲介)

画面とバックエンドを仲介する処理。

⚠️
画面上(ブラウザ側)でLangChainは使えない。

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を使った処理)

⚠️
その他にもelectron関連の処理も必要だがとりあえずコピペでOK。

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;
})

💡
【補足】実行時にブラウザのディベロッパーツールを起動したい場合は以下の部分のコメントアウトを外せばOK!
// 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が生成できた😊

Image in a image block

💡
これでプログラムが動く準備が完成!

テスト実行

✅デスクトップアプリ化する前に、プログラムの動作確認をする。

以下のコマンドでテスト実行できる。

npx electron ./

するとこんな画面が起動するはず!

Image in a image block

質問を入力し、送信してしばらく待つと…

Image in a image block

独自データ(indexフォルダのデータ)を使って回答してくれる!

Image in a image block

💡
これで動作確認完了!

デスクトップアプリ化

動作確認が済んだので、最後にデスクトップアプリ化する。

✅以下のコマンドでデスクトップアプリ化できる。

(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のような名前のフォルダが生成される😊

Image in a image block

✅ChatApp.app(WindowsならChatApp.exe)を実行すると、先ほどと同じプログラムが起動する!

Image in a image block

💡
無事にデスクトップアプリ化できた!

デスクトップアプリを配布する

ChatApp.app(WindowsならChatApp.exe)を配布すれば、Node.jsの環境がないPCでも使用可能😊

注意点

セキュリティは非考慮

知り合いに使ってもらうのを想定しているのでセキュリティは考慮していない。

⚠️
OpenAIのAPIキーが見られる恐れがある