チャットモンスターバトル、通称「チャトモン」「CDLE名古屋」と「CDLE生成モデル」がコラボしたリアルイベントとして企画されました。



ChatGPTのカスタマイズ機能…GPTsを利用したこのイベントは非常に盛り上がりました。しかし、GPTsが安定して動かないなど、継続してイベントを行うには不安な面もありました。

そんな中、東京AI祭でチャトモンをテーマに登壇する事が決まりました。それをきっかけに、チャトモンの(OpenAIのAPIを利用した)Webアプリ化の開発に着手することとなります。
経緯については、下記のブログを見ていただければと思います。



こうして完成したWebアプリですが、下記のブログ内のリンクから遊ぶ事が出来ます。



このアプリはフロントエンドと呼ばれるブラウザ上のプログラムのみで動かしていますが…

「これ、本当にフロントエンドのみで動いているの?」

…のように、驚かれる方もいらっしゃいました。

私の方も、ようやく落ち着いてきた事もありますので、実際にどのようなプログラムで動いているのかのサンプルを元にご説明したいと思います。

まずはローカルPC上でフロントエンドのプログラムを動かす環境を作る必要があります。下記のnoteの記事を参考に、まずは環境を構築してください。


環境を構築し、テスト用のプログラムは動きましたか?
動いたのであれば準備完了です。

さぁ、始めましょう!

チャトモンのソースコード

東京AI祭のWEBアプリのプログラミングソースコードは非常に長く、さらに綺麗ではないので、それを元に説明しても混乱するだけかと思います。
ですので、それより簡略化したソースコード元にご説明いたします。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Chat Monster Battle</title>
  
  <style>
    /* アニメーション用のCSS */
    @keyframes pulse {
      0% { background-color: #fff; }
      50% { background-color: #F88; }
      100% { background-color: #fff; }
    }
    /* 通信中に背景色をアニメーションさせるクラス */
    .communicating {
      animation: pulse 1.5s infinite;
    }
  </style>

</head>
<body>
  <h1>Chat Monster Battle</h1>

  <h2>モンスター1</h2>
  <input type="file" accept="image/*" id="fileInput1" onchange="handleFileChange(1)"><br /><br />
  ※画像データを文字列に変換したもの<br />
  <textarea style="width:100%;height:100px;margin-top:5px;overflow-y: auto;background-color: #CCEEFF;" readonly id="base64Result1"></textarea>
  ※モンスターの能力<br />
  <textarea style="width:100%;height:300px;overflow-y: auto;background-color: #CCEEFF;" readonly id="gpt4v1"></textarea>
  <img id="previewImage1" src="" alt="Uploaded Image" style="max-width: 516px; display: none;">

  <h2>モンスター2</h2>
  <input type="file" accept="image/*" id="fileInput2" onchange="handleFileChange(2)"><br /><br />
  ※画像データを文字列に変換したもの<br />
  <textarea style="width:100%;height:100px;margin-top:5px;overflow-y: auto;background-color: #CCEEFF;" readonly id="base64Result2"></textarea>
  ※モンスターの能力<br />
  <textarea style="width:100%;height:300px;overflow-y: auto;background-color: #CCEEFF;" readonly id="gpt4v2"></textarea>
  <img id="previewImage2" src="" alt="Uploaded Image" style="max-width: 516px; display: none;">

  <br /><br />
  <button onclick="battle()">戦い開始</button><br />
  <textarea style="width:100%;height:300px;overflow-y: auto;background-color: #FFEECC;" readonly id="battle"></textarea>
  <br /><br />

  <script>
    let chatGptApiKey = window.prompt("Please enter your OpenAI API key:");
    const endPoint = "https://api.openai.com/v1/chat/completions";

    // 通信中のフラグ
    let isCommunicating = false;

    // 画面をロックする関数
    function lockScreen() {
      isCommunicating = true;
      // ボディ要素に通信中を示すクラスを追加
      document.body.classList.add('communicating');
      // すべてのfile inputを無効化
      document.querySelectorAll('input[type="file"]').forEach(input => {
        input.disabled = true;
      });
      // 戦い開始ボタンも無効化
      document.querySelector('button').disabled = true;
    }

    // 画面のロックを解除する関数
    function unlockScreen() {
      isCommunicating = false;
      // ボディ要素から通信中を示すクラスを削除
      document.body.classList.remove('communicating');      
      // すべてのfile inputを有効化
      document.querySelectorAll('input[type="file"]').forEach(input => {
        input.disabled = false;
      });
      // 戦い開始ボタンも有効化
      document.querySelector('button').disabled = false;
    }

    // 画像のアップロード
    function handleFileChange(monsterNumber) {
    
      // 通信中であれば処理しない
      if (isCommunicating) return;
      lockScreen();    // 通信中フラグを立てる

      const fileInput = document.getElementById(`fileInput${monsterNumber}`);
      const selectedFile = fileInput.files[0];
      if (selectedFile) {
        const reader = new FileReader();
        reader.onload = function(event) {
          const base64String = event.target.result.split(',')[1];
          document.getElementById(`previewImage${monsterNumber}`).src = event.target.result;
          document.getElementById(`previewImage${monsterNumber}`).style.display = 'block';
          document.getElementById(`base64Result${monsterNumber}`).innerText = base64String;
          getMonStatus(base64String, chatGptApiKey, monsterNumber);
        };
        reader.readAsDataURL(selectedFile);
      }
    }
    
    // モンスターのステータスを取得する
    function getMonStatus(base64Image, chatGptApiKey, monsterNumber) {
      const modelName = "gpt-4-vision-preview";
      const messages = [
        {
          role: "system",
          content: "あなたは非常に創造力豊かに回答してください。",
        },
        {
          role: "user",
            content: [
              {
                type: "text",
                text: "画像を1体のモンスターだと想像してください。下記の項目を考えて数値の場合は1000までの数値で表して下さい。各項目は項目名も表示し、カンマ(,)区切りで分けてください。"
                    + "\n 名前(文字) "
                    + "\n 見た目(文字) "
                    + "\n 身長(文字 or 数字+単位)"
                    + "\n 体重(文字 or 数字+単位)"
                    + "\n 種族(文字) "
                    + "\n 属性(文字) "
                    + "\n レベル(数値) "
                    + "\n 攻撃力(数値) "
                    + "\n 防御力(数値) "
                    + "\n 魔法攻撃力(数値) "
                    + "\n 魔法防御力(数値) "
                    + "\n スピード(数値) "
                    + "\n スキル(文字) "
                    + "\n 使える魔法(文字) "
                    + "\n 特殊能力(文字) "
                    + "\n 移動範囲(文字) "
                    + "\n 攻撃範囲(文字) "
                    + "\n その他特記事項(文字) "
              },
              {
                type: "image_url",
                image_url: {
                  url: `data:image/jpeg;base64,${base64Image}`,
                  detail: "low",
                },
              },
            ],
        },
      ];

      const requestOptions = {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${chatGptApiKey}`
        },
        body: JSON.stringify({
          model: modelName,
          messages: messages,
          max_tokens: 1000,
        }),
      };
      console.log("Send:" + JSON.stringify(requestOptions));

      fetch(new Request(endPoint, requestOptions))
        .then(res => res.json())
        .then(json => {
          document.getElementById(`gpt4v${monsterNumber}`).textContent = json.choices[0].message.content.replace(/,/g, '\n');
          unlockScreen();// 通信が完了したら画面のロックを解除する
        })
        .catch(err => {
          console.error("Error:", err);
          alert(err);
          // エラーが発生した場合も画面のロックを解除する
          unlockScreen();
        });
    }

    // モンスターバトル結果の取得
    function battle() {
      if (isCommunicating) return;
      let modelName_Battle = "gpt-3.5-turbo-1106";

      // モンスター1とモンスター2のステータスを取得
      const monster1Stats = document.getElementById('gpt4v1').textContent.replace('\n',/,/g).replace('"','').trim();
      const monster2Stats = document.getElementById('gpt4v2').textContent.replace('\n',/,/g).replace('"','').trim();

      // どちらかのモンスターのステータスが未入力の場合、メッセージを表示して終了
      if (!monster1Stats || !monster2Stats) {
        document.getElementById('battle').value = "モンスターを選択してください。";
        return;
      }

      // モンスターの戦闘を開始するプロンプトを作成
      const prompt = `2体のモンスターを闘わせ、その闘いの過程を時系列に詳細に説明し、最後に闘いの勝者を決めて下さい。 開始時間と戦う場所はランダムに決めて下さい 。\n【モンスター1】\n${monster1Stats}\n\n【モンスター2】\n${monster2Stats}\n`;

      // チャットの初期メッセージを定義
      const messages = [
        {
          role: "system",
          content: "あなたは非常に創造力豊かに回答してください。",
        },
        {
          role: "user",
          content: `${prompt}`
        }
      ];

      const requestOptions = {
          method: "POST",
          headers: {
              "Content-Type": "application/json",
              Authorization: `Bearer ${chatGptApiKey}`
          },
          body: JSON.stringify({
              model: modelName_Battle,
              messages: messages,
              max_tokens: 3000
          })
      };

      lockScreen(); // 通信中に画面をロックする
      console.log("Send:" + JSON.stringify(requestOptions));

      fetch(new Request(endPoint, requestOptions))
        .then(res => res.json())
        .then(json => {
          const battleResult = json.choices[0].message.content;
          const battleInfo = `【闘いの結果】:\n${battleResult}`;
          document.getElementById('battle').value = battleInfo;
          unlockScreen();
        })
        .catch(err => {
          console.error("Error:", err);
          alert(err);
          unlockScreen();
        });
    }
  </script>
</body>
</html>


・実行イメージ
image.png 441.17 KB

ChatGPTによるソースコードの説明・解説

ソースコードは私が説明・解説しても良いのですが、そこはせっかくChatGPTがあるので、ChatGPTに説明してもらいましょう。
もっと細かな不明点を知りたいのであれば、それについてもChatGPTに聞きましょう。せっかくなので、これを機会に…プログラム学習にChatGPTを利用することに慣れていきましょう!

以下がChatGPTによるソースコードの解説になります。



このHTMLとJavaScriptのプログラムは、「Chat Monster Battle」という名前のWebページで、ユーザーが2つの異なるモンスターを選択し、それらを戦わせることができるインタラクティブなアプリケーションです。
プログラムの主なコンポーネントと役割を以下に解説します。

HTML部分

【基本構造】
<!DOCTYPE html>で始まり、html要素でページ全体が構成されています。lang="ja"は、ページの言語が日本語であることを指定しています。
【head要素】
ページのメタデータを含む。meta charset="UTF-8"は文字エンコーディングをUTF-8に設定しています。titleタグでページのタイトルを「Chat Monster Battle」と定義しています。

CSS部分

【アニメーション】
@keyframes pulseというキーフレームを使い、背景色が白(#fff)から赤(#F88)、再び白へと変化するアニメーションを定義しています。このアニメーションはcommunicatingクラスが適用された要素に1.5秒間隔で無限に繰り返されます。

JavaScript部分

【APIキーの取得】
prompt関数を使用して、ユーザーにOpenAI APIキーを入力させ、これをchatGptApiKey変数に保存します。
【画面のロックと解除】
lockScreenとunlockScreen関数は、通信中にページの操作を制限するためのものです。これらは入力フィールドやボタンを無効化し、通信中クラスを追加または削除します。

モンスター情報の取得と表示

【ファイルアップロード】
handleFileChange関数は、ファイル入力から画像を読み込み、Base64エンコーディングされた文字列としてtextareaに表示し、画像をプレビューします。そしてgetMonStatus関数を呼び出して、画像からモンスターのステータスを取得します。
【モンスターのステータス取得】
getMonStatus関数は、OpenAI APIを使用してモンスターのステータスを生成します。これには画像データと一連の指示が含まれます。

モンスターバトルの実行

【バトルの開始】 
battle関数は、モンスターのステータスを基にバトルを行い、結果をtextareaに表示します。この処理もAPIを介して行われ、モンスター間の詳細な戦闘シナリオが生成されます。

このプログラムは、Web技術を活用してリアルタイムでインタラクティブな体験を提供するものであり、特にJavaScriptを使った非同期通信とAPIの統合の良い例と言えます。


ソースコードファイル

先ほどのHTMLファイルをzip形式でおいておきます。
ただし、このブログは外部公開にしていますが、CDLEコミュニティーメンバーでないと添付ファイルは見れないと思います。
見れない方は、上部あるソースコードをコピペすれば同じものになりますので、そちらで対応してください。

最後に・・・

ゴールデンウィークに「生成AIEXPO in 名古屋」にて、この仕組みを利用した「チャトモン(ChatMon)」の子供向け体験イベントが行われます。
このように…このフロントエンドのみの仕組みは、イベントで利用するコンテンツとしては最適です。

ぜひ挑戦してみて下さい!