LoginSignup
5
3

More than 1 year has passed since last update.

【チャットAI】更新:ChatGPTのAPI呼び出しをReact+Node.jsでstream対応実装してみた(要約機能、会話保存機能付き)

Last updated at Posted at 2023-03-12

概要

前回の記事で、ChatGPTのAPI呼び出しをReact+Node.jsで実装してみましたが、APIの呼び出しがstreamに対応できておらず、APIから回答の全文を受け取った後に画面に表示していました。
今回はstream対応版として、単語や文字ごとに非同期的に回答を画面に表示するよう修正しましたので、その内容を説明します。

ソースをGitHubにて公開しています。
本記事では、動作手順と実装内容の抜粋を説明します。

sample-chatgpt-nodejs

Playgroundとは何が違う?

"gpt-3.5-turbo"は、OpenAI APIのPlaygroundからも利用が可能です。
今回作成したものはおおよそ同じことができますが、以下の違いがあります。

  • 会話の保存ができます。
  • 「summalize」ボタンを押すと、それまでの会話の要約を作ります。APIは、トークン数(≒扱える文章の長さ)には上限があるため、長くなってきたらこのボタンを押すことで要約し、会話を続けることができます。
  • Temparetureなどパラメーターは調整できません。

使用例 (※再掲)

起動すると、以下のような画面が開きます。

image.png

  • 左上の「System」が、システムパラメーターです。ChatGPTの役割を記載します。
  • その下の「request」が、ユーザーが書き込むリクエスト内容です。入力してから、「Shift+Enter」または「send message」ボタンで送信します。
  • 右の「chat gpt response」が、ユーザーとアシスタント(ChatGPT)の会話の履歴です。リクエストを投げると、この内容も自動的にAPIに渡されます。このため、会話の経緯も込みでAPIとやり取りが可能です。尚、経緯を書き換えることも可能です。
  • 画面中央あたりの「summarrize」が、会話の要約ボタンです。右側の会話をクリアして、要約したものに書き換えます。
  • 左下の「save」の欄に任意の名前を入力してsaveボタンを押すと、画面の内容を保存します。loadボタンを押すと、saveした時の内容を復元します。

動画:

動作手順 (※再掲)

sample-chatgpt-nodejsのReadmeでも説明してますが、ざっくりこの記事でも説明します。

1.Node.js(npm)をPCにインストールする

Node.jsで動作するため、npmがコマンドプロンプトから実行できるようにインストールしてください。

Node.js

2.GitHubからクローンする

sample-chatgpt-nodejsをローカルにクローンしてください。
GitHubのクライアントがあると便利ですが、無い場合は「Code」から「Download.zip」でダウンロードして解答してもOKです。

3.backフォルダで「npm install」を実行する。

backフォルダに、Reactをリリース形式にしたJavaScript類と、Node.jsとして動作するtsファイルなどがあります。
「npm install」を実行することで、Node.jsとして動作するために必要なプラグインをダウンロードします。

$ cd back
$ npm install

4.環境変数にAPIキーを設定する。

定番ですが、環境変数にAPIキーを設定します。
APIキーの取得方法はOpenAI APIのページなどを参照してください。

$ export OPENAI_API_KEY="APIキー"

5.Node.jsを起動する。

backフォルダにて、Node.jsを起動します。

npm start

実装内容の説明

実装内容はこちらのスレッドを参考にさせていただきました。
API呼び出しの実装のあたりを紹介します。

サーバーは、Node.jsのフレームワークのExpressを利用しました。
React(フロントエンド)からfetchでNode.js(バックエンド)の「/api」をcallし、OpenAIのAPIを呼び出して応答を画面表示します。

1.バックエンドの実装(Expressとして起動)

requestはフロントからJSONで受け取ります。

(抜粋)
import express from "express";

const app: express.Express = express();
app.use(express.json());
const port = 8000;

app.post("/api", (req: express.Request, res: express.Response) => {
  const body = req.body;
  const jsonData = JSON.stringify(body, null, 2);

  // ここからOpenAI APIの呼び出し

});

以下のようにconfigにAPIキーを設定します。

(抜粋)

import { ChatCompletionRequestMessage, Configuration, OpenAIApi } from "openai";

let configuration = new Configuration({ apiKey: process.env.OPENAI_API_KEY });

2.API呼び出し

ここからが、前回の記事と異なる実装の説明です。
まず、createChatCompletionの呼び出しで、modelやmessagesの引数に追加で「stream: true」を設定します。また、responseTypeに"stream"を指定します。
※OpenAIのライブラリ内部でaxiosを利用しているようです。

(抜粋)
import { Readable } from "stream";

// メッセージのArrayを作成
const messages:Array<ChatCompletionRequestMessage> = new Array<ChatCompletionRequestMessage>();

// systemを設定する
messages.push({ role: "system", content: jsonData.system });

// メッセージを設定する
messages.push({ role: "user", content: jsonData.message });

// call api
const completion = await openai.createChatCompletion({
    model: "gpt-3.5-turbo",
    messages: messages,
    stream: true,
}, { responseType: 'stream' });

すると、以下のようにdataをReadableとして取得できます。

(抜粋)
const stream = completion.data as any as Readable;

streamのonで"data"の処理を実装することで、APIから応答を1文字~1単語ずつくらいで受け取ることができます。
以下のchunkは、APIからの応答が分割されたものです。

(抜粋)
// stream on
stream.on("data", (chunk) => {
    try {
        let str: string = chunk.toString();

        // [DONE] は最後の行なので無視
        if (str.indexOf("[DONE]") > 0) {
            return;
        }

        // nullは無視
        if (str.indexOf("delta\":{}") > 0) {
            return;
        }

        // ※APIからの応答をクライアントに返す。後で説明。

    });
    } catch (error) {
        console.error(error);
    }
});

ここで、chunkは以下のような内容になっています。
1回につき、1行~数行がchunkに設定されます。
※説明のため、一部の属性を省略して表示しています。

一回分の例
data: {"id":"chatcmpl-XXXXXX","choices":[{"delta":{"content":"Hello"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-XXXXXX","choices":[{"delta":{"content":"!"},"index":0,"finish_reason":null}]}

例えば、「Hello! How may I assist you today?」が応答の全文の場合、APIの1回の呼び出しの応答の全体(全部のchunkを繋げたもの)は以下のようになります。

全体
data: {"id":"chatcmpl-XXXXXX","choices":[{"delta":{"role":"assistant"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-XXXXXX","choices":[{"delta":{"content":"Hello"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-XXXXXX","choices":[{"delta":{"content":"!"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-XXXXXX","choices":[{"delta":{"content":" How"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-XXXXXX","choices":[{"delta":{"content":" may"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-XXXXXX","choices":[{"delta":{"content":" I"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-XXXXXX","choices":[{"delta":{"content":" assist"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-XXXXXX","choices":[{"delta":{"content":" you"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-XXXXXX","choices":[{"delta":{"content":" today"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-XXXXXX","choices":[{"delta":{"content":"?"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-XXXXXX","choices":[{"delta":{},"index":0,"finish_reason":"stop"}]}
data: [DONE]

このため、「choices[0].delta.content」が、フロントに返すべきメッセージの部分ということになります。

3.フロントへの返却

先ほどの実装の続きです。
改行文字でsplitして、先頭の"data: "を取り除き、JSONとしてparseして「choices[0].delta.content」をフロントに返却します。

(抜粋)
// Lines to json
const lines: Array<string> = str.split("\n");
lines.forEach(line => {

    // 先頭部分は削除
    if (line.startsWith("data: ")) {
        line = line.substring("data: ".length);
    }

    // 空行は無視
    if (line.trim() == "") {
        return;
    }

    // JSONにparse
    const data = JSON.parse(line);
    if (data.choices[0].delta.content === null || data.choices[0].delta.content === undefined) {
        return;
    }

    // 標準出力(確認用)
    process.stdout.write(data.choices[0].delta.content);

    // フロントに返却
    res.write(JSON.stringify({ text: data.choices[0].delta.content }));
}

更に、onで"error"と"end"も実装しておきます。

(抜粋)
stream.on("end", () => {
    res.end();
});
stream.on("error", (error) => {
    console.error(error);
    res.end(JSON.stringify({ error: true, message: "Error generating response." }));
});

4.フロント側の実装

上記までで実装した/apiの呼び出し例を説明します。

  • レスポンスを"utf-8"にデコードする必要があるため、TextDecoderを利用します。
  • bodyのreaderを取得して、APIからの応答を1個ずつ処理します。
  • バック側で「res.end();」が呼び出された場合、doneがtrueになるため、doneがfalseの間はchunkを取得し続けます。
(React抜粋)
const fetchData = async (data: any) => {
  let resJson: any = [];
  let res = await fetch("api", {
    method: 'POST',
    mode: 'cors',
    cache: 'no-cache',
    headers: {
      'Content-Type': 'application/json'
    },
    redirect: 'follow',
    referrerPolicy: 'no-referrer',
    body: sendData
  });

  // get reader
  const reader = res.body!.getReader()!;
  // decode to utf-8
  const decoder = new TextDecoder("utf-8");

  const readChunk = async () => {
    return reader.read().then(({ value, done }): any => {
      try {
        if (!done) {
          let dataString = decoder.decode(value);
          const data = JSON.parse(dataString);
          console.log(data);

          if (data.error) {
            console.error("Error while generating content: " + data.message);
          } else {
            response = data.text;
            // write to HTML
            :
            :
          }
        } else {
          console.log("done");
        }
          
      } catch (error) {
        console.log(error);            
      }
      if (!done) {
        return readChunk();
      }
    });
  };
  readChunk();
};
fetchData({});

補足 (※再掲)

  • 突貫で作ったので、ソースが綺麗じゃないのはご容赦ください。
  • 保存した名前が何だったかわからなくなった場合は、backフォルダのひとつ上のフォルダを確認してください。リクエスト内容がjsonで保存されています。
  • 利用にあたっては自己責任でお願いします。
  • APIキーが誤っていたり、トークン数を超えると、応答が「undefined」になります。必用に応じてやり直してください。(ちょうぜつ手抜き実装)

まとめ

これをベースに、新しいAPIの利用をもっと試してみたいと思います。

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3