DigiPress

Highly Flexible WordPress Theme

メニュー

React で ChatGTP API を利用した AI 連携プログラムを作成してみる

React で ChatGTP API を利用した AI 連携プログラムを作成してみる

今話題沸騰中の OpenAI が開発した人工知能(AI)チャットボット「ChatGPT」のAPI が公開されました。
これにより、今後加速度的にあらゆる開発言語で ChatGPT の API が利用され、様々なアプリやサービスに組み込まれて便利になっていくと思います。

そこで、今回は試しに ChatGPT API を利用した、誰でもゼロから作れる簡単なチャットプログラムを React で作ってみたいと思います。

2023/3/14 に最新の言語モデル GPT-4 が公開されました。
GPT-4 では、テキストだけでなく、画像、音声、動画もインプットとして渡すことができるようになっています。
例えば、手書きのメモやスケッチをスマホで撮影した画像を送るだけで AI が回答や成果物を提示してくれます。


開発環境の作成

まずはローカルに React の開発環境をセットアップします。
Node.js がインストールされていない場合は、以下を参考に React でのアプリ開発をする環境をまずは整えておきましょう。

早速アプリを作っていきます。
まず、ターミナルを起動して開発環境を作成するディレクトリまで移動しておきます。
以下では /Users/myname/LocalSites というパスに移動しています。

アプリ出力先に移動
cd /Users/myname/LocalSites

今回のサンプルで作るアプリ(ディレクトリ)名は「chat-sample-app」として移動した場所に React アプリを作成します。

アプリの作成
npx create-react-app chat-sample-app

プロンプトが正常に返ってきたら、「chat-sample-app」というアプリ専用のフォルダが作成されているので、そこに移動し、ローカルサーバーの動作確認をします。

サーバーの起動
# アプリディレクトリに移動
cd chat-sample-app

# ローカルのサーバーを起動
npm start

npm start でブラウザでローカルのページが自動的に開き、以下の表示になればローカルの開発環境は整いました。

作成直後の状態
初期状態(App.js を実行したもの)

参照できない場合は、URL を http://localhost:3000/ として直接開いてみてください。

ChatGPT API と通信する処理

axios ライブラリのインストール

ChatGPT API のように外部のリソースとデータをやり取りするには、fetch というブラウザの標準ライブラリを利用することで実現できますが、axios というライブラリを利用する方がエラーハンドリングやデータの取得が楽になり、コードも簡潔で読みやすくなるため、今回はこちらのライブラリを使用することにします。

npm install axios --save

chat.js の作成

インストールが完了したら、いよいよ ChatGPT と会話するためのコードを書いていきます。

/chat-sample-app/src ディレクトリに、今回はとりあえず「chat.js」という名前の JavaScript ファイルを作成し、axios モジュールをまずはインポートします。

chat.jsaxios のインポート
import axios from 'axios';

OpenAI のドキュメントを参考に、ChatGPT API のエンドポイントとデータのやり取りに必要なパラメータを準備します。

https://platform.openai.com/docs/api-reference/chat

現時点のエンドポイントは https://api.openai.com/v1/chat/completions、パラメータはチャットプログラムのモデルID(model)、そして渡すメッセージ(messages) が必須のようです。

これらの情報を元に axios を利用して API にリクエストを送信します。

chat.jsAPI とやり取りする処理
import axios from 'axios';

const API_URL = 'https://api.openai.com/v1/';
const MODEL = 'gpt-3.5-turbo';
const API_KEY='APIキーをここに貼り付け';

export const chat = async ( message ) => {
  try {
    const response = await axios.post( `${ API_URL }chat/completions`, {
      // モデル ID の指定
      model: MODEL,
      // 質問内容
      messages: [
        {
          'role': 'user',
          'content': message,
        }
      ],
    }, {
      // 送信する HTTP ヘッダー(認証情報)
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${ API_KEY }`
      }
    });
    // 回答の取得
    return response.data.choices[0].message.content;

  } catch ( error ) {
    console.error( error );
    return null;
  }
}
リクエストボディに含める項目
model

利用する ChatGPT のモデル名。
現時点では、gpt-4, gpt-4-0314, gpt-4-32k, gpt-4-32k-0314, gpt-3.5-turbo, gpt-3.5-turbo-0301 のいずれかを指定できます。

2023/3/14 に最新モデル GPT-4 が公開されました。
ただし、最新モデルを試すには ChatGPT Plus ($20/月)へアップグレードする必要があります。

messages

ChatGPT に送るメッセージオブジェクトの配列。

オブジェクトには、role (AIへ送信するコンテンツの役割)と content (メッセージ、指示)を指定します。

role には system, assistant, user の3タイプを指定できます。

最低限必要な roleuser で、この場合のcontent は AI に送る文章、つまりユーザーから ChatGPT に質問するメッセージを指定します。

role: user
messages: [
  {
    'role': 'user',
    'content': '簡単で美味しいおかずのレシピを教えてください。'
  }
]

assistantcontent は ChatGPT からの返答を渡します。
直前のチャットの返答内容を assistantcontent に渡しておくと、抽象的な文章でもやり取りを踏まえた自然な回答が返ってきます。

role: assistant
messages: [
  {
    'role': 'assistant',
    'content': '以下は簡単で美味しいおかずのレシピです。\n【豆腐とひき肉の煮物】\n必要な材料:- 絹豆腐 1丁 ...'
  }
]

sysytem は AI への指示役としての情報を渡すことができます。
具体的には、AI のキャラ設定や語尾、文頭、文末の決め台詞などを content に細かく指示しておくと、そのキャラクターになりきって回答してくれるようになります。

role: system
messages: [
  {
    'role': 'system',
    'content': 'あなたは「北斗の拳」のジャギです。ジャギになったつもりで罵詈雑言を交えながら回答してください。一人称は「俺様」にしてください。最後は「{a}より優れた{b}などいねぇ!」で締めてください。変数({a},{b})の部分はランダムに変えてください。'
  }
]

https://platform.openai.com/docs/guides/chat/introduction

その他にもいくつかパラメータが提供されていますが、他のパラメータについては公式ドキュメントを参考にしてください。

https://platform.openai.com/docs/api-reference/chat

API へのリクエストが成功すると、以下のようなレスポンスが返ってきます。

レスポンス例
{
  "id": "chatcmpl-123",
  "object": "chat.completion",
  "created": 1677652288,
  "choices": [{
    "index": 0,
    "message": {
      "role": "assistant",
      "content": "\n\nHello there, how may I assist you today?",
    },
    "finish_reason": "stop"
  }],
  "usage": {
    "prompt_tokens": 9,
    "completion_tokens": 12,
    "total_tokens": 21
  }
}

このうち、必要なのは “choices” -> “message” -> “content” のデータなので、chat.js が返す値(回答)は以下のようにします。

回答の取得
return response.data.choices[0].message.content;

App.js の入れ替え

最初に npx コマンドで作成したプロジェクトに出力されている /chat-sample-app/src/App.js のコードを以下に置き換えます。

App.js
import React, { useState } from 'react';
import { chat } from './chat';  // chat.js のインポート

const App = () => {
  // メッセージの状態管理用
  const [ message, setMessage ] = useState( '' );
  // 回答の状態管理用
  const [ answer, setAnswer ] = useState( '' );

  // メッセージの格納
  const handleMessageChange = ( event )  => {
    setMessage( event.target.value );
  }

  // 「質問」ボタンを押したときの処理
  const handleSubmit = async ( event ) => {
    event.preventDefault();

    // chat.js にメッセージを渡して API から回答を取得
    const responseText = await chat( message );

    // 回答の格納
    setAnswer( responseText );
  }

  // チャットフォームの表示
  return (
    <div>
      <form onSubmit={ handleSubmit }>
        <label>
          <textarea
            rows='5'
            cols='50'
            value={ message }
            onChange={ handleMessageChange }
          />
        </label>
        <div>
          <button type="submit">質問する</button>
        </div>
      </form>
      { answer && (
        <div>
          <h2>回答:</h2>
          <p>{ answer }</p>
        </div>
      ) }
    </div>
  );
}

export default App;
処理の流れ

<textarea> 要素で質問内容を入力するフォームを表示し、入力したメッセージは保管されます。

「質問する」ボタンがクリックされると保管されているメッセージを chat.js からインポートした chat 関数に渡して axios で ChatGPT の API と通信を行い、レスポンスが成功すると受け取ったデータ(JSON)から回答にあたるデータ(content)のみが返ってきます。

回答を受け取るとその値は setAnswer 関数で answer 変数に格納され、画面に表示されます。

フォームの確認

ここまで完了したら、とりあえずローカル( http://localhost:3000/ )のページを更新して以下のフォームが表示されることを確認しましょう。

API キーが指定されていないのでこの時点ではまだ機能しません。

API キーの取得

完成したフォームから ChatGPT とデータをやり取りするための API キーを生成します。

OpenAI のアカウントを作成していない場合は、まずはアカウントを作成します。

サインアップ

ログイン後、右上のアカウントのロゴをクリックして「View API Keys」を選択します。

「Create new secret key」をクリックして API キーを生成し、コピーします。

コピーボタンで API キーを取得

API キーのセット

chat.js にてまだ未指定の状態だった API キー用の定数(API_KEY)にコピーした キーを貼り付けます。

chat.jsAPI キーの貼り付け
const API_KEY='APIキーをここに貼り付け';

動作確認

これで ChatGPT の API を利用する基本準備が整いました。
再度ページを更新して適当に質問してみましょう。

実行結果

AI からちゃんと回答が返ってきました。

ただ、レスポンスに含まれる改行コード(\n) は HTML では当然解釈されないため、Webページ上では1行になってしまい見づらいです。
改行コードが見つかった場合は、改行タグ(<br />)を挿入して表示するようにしてみます。

質問と回答は pre 要素で括っても良い場合は、改行はそのまま表示されるので以下の修正は不要です。

修正した回答部分
{ answer && (
  <div>
    <h2>回答:</h2>
    <p>{ answer.split( /\n/ )
          .map( ( item, index ) => {
            return (
              <React.Fragment key={ index }>
                { item }
                <br />
              </React.Fragment>
            );
          } )
        }
    </p>
  </div>
) }
実行結果HTMLでの改行に対応

ちゃんと改行されるようになりました。

処理の最適化と精度の向上

ここまでのコードのままでは、コンポーネントのレンダリングや送信ボタンをクリックされるたびに API リクエストを送信するため大量のリクエストを送信してしまう恐れがあります。
また、API と通信中の間のローディング表示がないため、リクエストを実行してもしばらく反応がないようにみえてしまいます。

なるべく不要な API リクエストを行わないよう事前にチェックをしたり、ローディング表示にも対応させてみましょう。

ローディング表示の状態管理には React の useState フックを利用します。
さらに続けてチャットを行うことを考慮し、直前のメッセージ内容は React の useRef フックで保持しておくようにします。

// メッセージの状態管理用のステート
const [ message, setMessage ] = useState( '' );

// 回答の状態間利用のステート
const [ answer, setAnswer ] = useState( '' );

// 会話の記録用のステート
const [ coversation, setCoversation ] = useState( [] );

// ローディング表示用のステート
const [ loading, setLoading ] = useState( false );

// 前回のメッセージの保持、比較用
const prevMessageRef = useRef( '' );

会話内容の記録

チャットは連続したやりとりを通して会話の内容や流れを考慮しながら行う必要があります。
この自然な会話の流れを実現するには、ユーザーと AI の会話の内容を記録し、そこに続けて質問する新しいメッセージを追加しながら API リクエストを送信する必要があります。

具体的には、会話をする度に conversation 変数にユーザーと AI のメッセージのやり取り(オブジェクトの配列)を追加で記録し、role: usercontent の値として conversation 変数を渡すようにすることで、それまでのやり取りを踏まえた上で AI がより適切な回答をしてくれるようになります。

// 回答が取得されたとき
  useEffect( () => {
    // 直前のチャット内容
    const newConversation = [
      {
        'role': 'user',  // ユーザー
        'content': message,  // 直前の質問内容
      },
      {
        'role': 'assistant',  // ChatGPT
        'content': answer,  // 直前の回答
      },
    ];

    // 会話の記録(直前のチャット内容の追加)
    setConversation( [ ...conversation, ...newConversation ] );

    // メッセージの消去(フォームのクリア)
    setMessage( '' );
  }, [ answer ] );

useEffect フックによって answer の変更を監視し、AI から回答(answer)が得られるとその会話の内容をスプレッド構文(...)で展開して conversation に追加で記録します。

質問の送信と回答の取得

「質問する」ボタンがクリックされると、<form onSubmit={ handleSubmit }> によって handleSubmit 関数が実行されますが、このフォーム送信処理の中でローディング表示の開始とフォーム内容のチェックをして API リクエストの送信を判断するようにします。

また、フォームを送信するたびに handleSubmit 関数が再生成されないように useCallback フックを利用して loading, message, conversation の値に変更があった場合のみに限定します。

handleSubmit 関数
// フォーム送信時の処理
const handleSubmit = useCallback( async ( event ) => {
  event.preventDefault();

  // フォームが空のとき
  if ( !message ) {
    alert( 'メッセージがありません。' );
    return;
  }

  // APIリクエスト中はスルー
  if ( loading ) return;

  // APIリクエストを開始する前にローディング表示を開始
  setLoading( true );

  try {
    // API リクエスト
    const response = await axios.post( `${ API_URL }chat/completions`, {
      model: MODEL,
      messages: [
          ...coversation,
        {
          'role': 'user',
          'content': message,
        }
      ],
    }, {
      // HTTPヘッダー(認証)
      headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${ API_KEY }`
      }
    });

    // 回答の取得
    setAnswer( response.data.choices[0].message.content.trim() );

  } catch ( error ) {
    // エラーハンドリング
    console.error( error );

  } finally {
    // 後始末
    setLoading( false );  // ローディング終了
    prevMessageRef.current = message; // 今回のメッセージを保持
  }
}, [ loading, message, conversation ] );

フォームが送信されると、まず内容(message)を確認し、空の場合はアラートを表示して終了するようにします。
さらに短時間に大量の API リクエストを送信しないよう、直前の API リクエストが完了していない間(ローディング中)は何も処理せずスルーさせます。

API リクエストが行われる直前にはローディング中のフラグを立てます( setLoading( true ) )。

finally によって、リクエストが成功しても失敗しても最後にローディング中の状態を終了( setLoading( false ) )し、今回の質問内容( message )を一旦保存( prevMessageRef.current = message )します。

チャットコンテンツのメモ化

質問をして AI から回答が返ってきたらその内容を HTML でフォームの下に表示していますが、質問と回答のテキスト以外は更新する必要がないため、React.memo メソッドで1度レンダリングされた結果を再利用して不要なレンダリングを行わないようにします。

// チャット内容
const ChatContent = React.memo( ( { prevMessage, answer } ) => {
  return (
    <div className='result'>
      <div className='current-message'>
        <h2>質問:</h2>
        <p>{ prevMessage }</p>
      </div>
      <div className='current-answer'>
        <h2>回答:</h2>
        <p>{ answer.split( /\n/ )
              .map( ( item, index ) => {
                return (
                  <React.Fragment key={ index }>
                    { item }
                    <br />
                  </React.Fragment>
                );
              } )
            }
        </p>
      </div>
    </div>
  )
} );

これにより、return 内の回答を表示する部分は以下のように書き換えます。

{ answer && !loading && (
  <ChatContent
    prevMessage={ prevMessageRef.current }
    answer={ answer }
  />
) }

ローディング表示

API リクエスト中( loading == true )のときだけローディング表示をします。
回答が表示される要素の前にローディング表示の要素を仕込んでおきます。

return 内
{ loading && (
  <div>
    <p>回答中...</p>
  </div>
) }

全体のコード(chat.js)

ここまでの処理をまとめた chat.js 全体のコードは以下のように書き換えます。

chat.js
import React, { useCallback, useEffect, useState, useRef } from 'react';
import axios from 'axios';

const API_URL = 'https://api.openai.com/v1/';
const MODEL = 'gpt-3.5-turbo';
const API_KEY='ここにAPIキーを指定';

const Chat = () => {
  // メッセージの状態管理用のステート
  const [ message, setMessage ] = useState( '' );
  // 回答の状態管理用のステート
  const [ answer, setAnswer ] = useState( '' );
  // 会話の記録用のステート
  const [ conversation, setConversation ] = useState( [] );
  // ローディング表示用のステート
  const [ loading, setLoading ] = useState( false );
  // 前回のメッセージの保持、比較用
  const prevMessageRef = useRef( '' );

  // 回答が取得されたとき
  useEffect( () => {
    // 直前のチャット内容
    const newConversation = [
      {
        'role': 'assistant',
        'content': answer,
      },
      {
        'role': 'user',
        'content': message,
      }
    ];

    // 会話の記録(直前のチャット内容の追加)
    setConversation( [ ...conversation, ...newConversation ] );

    // メッセージの消去(フォームのクリア)
    setMessage( '' );
  }, [ answer ] );

  // フォーム送信時の処理
  const handleSubmit = useCallback( async ( event ) => {
    event.preventDefault();

    // フォームが空のとき
    if ( !message ) {
      alert( 'メッセージがありません。' );
      return;
    }

    // APIリクエスト中はスルー
    if ( loading ) return;

    // APIリクエストを開始する前にローディング表示を開始
    setLoading( true );

    try {
      // API リクエスト
      const response = await axios.post( `${ API_URL }chat/completions`, {
        model: MODEL,
        messages: [
          ...conversation,
          {
            'role': 'user',
            'content': message,
          },
        ],
      }, {
        // HTTPヘッダー(認証)
        headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${ API_KEY }`
        }
      });

      // 回答の取得
      setAnswer( response.data.choices[0].message.content.trim() );

    } catch ( error ) {
      // エラーハンドリング
      console.error( error );

    } finally {
      // 後始末
      setLoading( false );  // ローディング終了
      prevMessageRef.current = message; // 今回のメッセージを保持
    }
  }, [ loading, message, conversation ] );

  // チャット内容
  const ChatContent = React.memo( ( { prevMessage, answer } ) => {
    return (
      <div className='result'>
        <div className='current-message'>
          <h2>質問:</h2>
          <p>{ prevMessage }</p>
        </div>
        <div className='current-answer'>
          <h2>回答:</h2>
          <p>{ answer.split( /\n/ )
                .map( ( item, index ) => {
                  return (
                    <React.Fragment key={ index }>
                      { item }
                      <br />
                    </React.Fragment>
                  );
                } )
              }
          </p>
        </div>
      </div>
    )
  } );

  // フォームの表示
  return (
    <div className='container'>
      <form className='chat-form' onSubmit={ handleSubmit }>
        <label>
          <textarea
            className='message'
            rows='5'
            cols='50'
            value={ message }
            onChange={ e => {
              setMessage( e.target.value ) ;
            } }
          />
        </label>
        <div className='submit'>
          <button type="submit">質問する</button>
        </div>
      </form>
      { loading && (
        <div className='loading'>
          <p>回答中...</p>
        </div>
      ) }
      { answer && !loading && (
        <ChatContent
          prevMessage={ prevMessageRef.current }
          answer={ answer }
        />
      ) }
    </div>
  );
}

export default Chat;

フォームの内容( return )も chat.js にまとめたので、Chat() 関数としてエクスポートしておき、index.js からインポートできるようにします。

index.js の修正

npx コマンドで最初に作成された index.js から、不要になった App.js のインポートと読み込みを削除し、代わりに chat.js をインポートし、<App /> から <Chat /> に置き換えます。

index.js修正後
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import Chat from './chat';  // -> 追加
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <Chat />
  </React.StrictMode>
);

reportWebVitals();

動作確認

改めて http://localhost:3000 を更新して動作確認をしてみます。

「質問する」ボタンをクリック(フォーム送信)すると、まず「回答中…」というローディング表示を行い、API リクエストが完了しデータ(回答)を受け取るとローディング表示は解除され、質問と回答が表示されます。

フォームの内容は自動的にクリアされ、再びチャットができるようになります。

スタイルシートについては全くいじっていないので、見た目についてはここでは全く言及していませんが、chat.js のフォームの構成要素をカスタマイズし、それぞれ class をつけて /src/inidex.css に CSS を記述すれば自由に変更できます。

また、ローディング中の表示もテキストではなくスピナーアイコンや CSS SVG で構成した図形に変更すると、視覚的にはよりいいかもしれません。

AI にキャラを設定する(system)

rolesystem をセットした場合、その content には AI にどのように振る舞って回答するかを指示しておくことができます。
つまり、チャットボットに様々なキャラクターになりきってもらって会話をやり取りすることができるようになります。

例えば、会話記録用のステート(conversation)の初期値を以下のようにしておくと、北斗の拳のジャギになりきった文面でチャットボットから返答が返ってきます。

role: system
// 会話の記録用のステート
const [ conversation, setConversation ] = useState( [
  {
    'role': 'system',
    'content': 'あなたは「北斗の拳」のジャギです。ジャギになったつもりで罵詈雑言を交えながら常に偉そうに回答してください。一人称は「俺様」にしてください。文末は「{a}より優れた{b}などいねぇ!」で締めてください。変数({a},{b})の部分はランダムに変えてください。',
  }
] );

content には、どんなキャラクターで、文頭や文末、一人称などまで日本語の文章で指示できるのがすごいです。
さらに変数的な要素を文章中に織り交ぜてもそれを解釈してくれます。

このようにチャットボットの初期設定を system から指示をしておくと、会話の様子が一変して以下のようなチャットを楽しむことができます。

なんだかんだで教えてくれるジャギ様
お礼を言っても満足しないと言いながら追加でおすすめスポットを教えてくれるツンデレ具合
口調は悪いがむちゃくちゃ心配してくれる上に「お前は俺様より価値がある」と言って文末の指示が真逆に。
さらに感謝を伝えると、またまた追加でおすすめのラーメン屋さんを紹介されました。

いかかでしょうか。
ChatGPT、めちゃくちゃ賢いですね。。

ちなみに、このチャットボットアプリで表示しているキャラクターの画像も OpenAI の画像生成 API で作成した AI 画像です。

React で画像生成 API を利用した Web アプリのサンプルは以下をご覧ください。


まとめ

今回は React で ChatGTP の API を利用して外部から AI とチャットをする簡単な Web アプリを作ってみましたが、とても簡単に実装できることがわかったと思います。

これをベースに、WordPress のブロックエディターや FSE(フルサイト編集)のどこかにチャットフォームを呼び出せるようにしておき、例えばデザイン中にあるカラーコードに合う他のカラーコードのパターンが知りたいときなどに、エディター上から直接 AI に回答のフォーマット(JSON形式など)を指定して質問してみるなど、様々な形で活用できるのではと思います。

OpenAI では他にも欲しい画像の説明を解釈して画像生成をする AI の API もあり、提供中の WordPress のブロックエディター用プラグイン「DigiPress Ex – Blocks」に組み込んで、欲しい画像を AI で生成し、直接アップロードをしてブロックで利用する画像として指定するなど、現在どのような便利な連携方法があるか検討しています。

画像生成の API はまた別の機会に試してみたいと思います。

画像素材 : 著作者:upklyak/出典:Freepik

Share / Subscribe
Facebook Likes
Tweets
Hatena Bookmarks
Pinterest
Pocket
Feedly
Send to LINE