🤖

TypeScriptでGPT-3.5を使ってChatGPTクローンを作る2 - Vite+ReactでChatGPTクローン

2023/02/13に公開

ChatGPTには、ニュースにならない日がないくらいの話題性がありますが、そんなChatGPTも決して遠くの技術ではありません。完全に同じものは無理でも、それに近いクローンを作ることはさほど難しくありません。

この記事はTypeScriptでGPT-3.5を使ってChatGPTクローンを作る第二弾で、今回はVite+React+TypeScript+Tailwind CSSでChatGPTクローンをウェブアプリとして作ってみます。

第一弾の記事では大規模言語モデル(以下LLM)であるGPTの使い方や、DenoでOpenAI GPT APIとGoogle Custom Searchを使って時事ネタも対応できる検索エージェントを作りました。今回の記事は、よいウェブフロントエンド開発者に馴染み深いように書いています。

対象読者

  • Reactやカスタムフック関数について理解している人を前提としています。
  • GPT・LLMについては第一弾の記事で解説しているので、詳しく知りたい方はそちらをご覧ください。

この記事で作るもの

  • Vite+Reactで動くウェブアプリケーション
  • ユーザーが入力した OpenAI APIキーを使って GPT API を叩く
  • ChatGPTっぽいもの

となります。

なお、この記事で書いたコードをさらに発展させたものを https://github.com/erukiti/chatgpt-clone に公開しているので、そちらも併せてご覧ください。

おさらい

GPT(Generative Pre-trained Transformer)とは、OpenAIが開発している大規模言語モデル(LLM)であり、記事執筆時点での最新は GPT-3.5 と呼ばれるものです。GPTは与えた文字列に続く文字列を確率に基づいて予測して補完する機能を持ちます。

たとえば、「むかしむかしあるところに」という文字列を与えれば、高確率で「おじいさんとおばあさんがいました」が生成されるでしょう。

この記事で使っている text-davinci-003 モデルなど OpenAI の GPT は従量課金です。詳しくは https://openai.com/api/pricing/ の Language Models をご覧ください。

$0.0200 / 1K tokens

世間を騒がせているChatGPTや、新しいBing・Edgeは、このGPTを使ったアプリと認識しておけばよいでしょう。

ポイント

  • GPTは大規模言語モデルであり、文字列を予測して補完するサービスが提供されている
  • ChatGPTや新しいBing・Edgeは、GPTを使ったアプリケーションである

Vite+React+TypeScript+Tailwind CSSをセットアップする

本格的に作るならNext.jsの方が良いですが、今回作るものはごく簡単なもので、Vite+Reactの方がセットアップが楽ちんなのでこちらにします。

# yarnの場合
yarn create vite chatgpt-clone --template react-ts 

# npmの場合
npx create-vite chatgpt-clone --template react-ts

chatgpt-cloneというディレクトリができるので cd chatgpt-clone で移動します。

次にCSSをいい感じに扱うためにTailwind CSSをセットアップします。

# yarnの場合
yarn add -D tailwindcss@latest postcss@latest autoprefixer@latest
yarn tailwindcss init -p

# npmの場合
npm i -D tailwindcss@latest postcss@latest autoprefixer@latest
npx tailwindcss init -p

Tailwind CSSのバージョンによると思いますが tailwind.config.js もしくは tailwind.config.cjs というファイルが生成されます。それが Tailwind CSS の設定ファイルです。
.cjs は Node.js 独自のもので CommonJS という古代のモジュールシステムに沿ったものですが、今回はここをあれこれしたいわけではないので、生成したやつをそのまま使います。

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./src/**/*.{js,ts,jsx,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
};

content 配列に "./src/**/*.{js,ts,jsx,tsx}" を追加します。

次に src/main.tsximport 'tailwindcss/tailwind.css' を追加します。

// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import 'tailwindcss/tailwind.css'
import './index.css'

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Tailwind CSSのCSSを読み込む方法は色々ありますが、一番お手軽なのはこの方法です。
これで Tailwind CSSが使えるようになりました。
背景色を設定するために src/index.css を書き換えます。

/* src/index.css */
body {
  background-color: #8a9ccb;
}

あとは src/App.tsx を変更して動作を見てみましょう。

// src/App.tsx
import { FC } from 'react'

const App: FC = () => {
  return <div className="text-red-500 text-9xl">hoge</div>
}

export default App

src/app.tsx をこのように修正して動作を確認すると、赤くてでかい文字になるのでCSSが当たっていることが確認できます。

起動方法は以下の通りで localhost:5173 で動作を確認できます。

# yarnの場合
yarn dev

# npmの場合
npm run dev

ここまでで Vite+React+TypeScript+TailwindCSS の準備ができました。

いずれViteやNext.jsもDenoで問題なく動くようになると思っていますが、これも本質ではないので置いておきます。

openai npmをインストールする

前回も使った openai npm ですが、今回はNode.jsであるため npm もしくは yarn コマンドで npm パッケージのインストールが必要です。

#yarn
yarn add openai

#npm
npm i openai

チャットアプリを作る

さて、今回の記事で作るのは ChatGPTクローン、つまりはチャットアプリです。チャットをするための画面を組み立てる必要があります。

まず型をつくる

画面に先駆けて、ログの型を定義します。以下の型を src/types.ts に保存します。
ユニオン型の前者はユーザー自分の発言のログで、後者は話し相手(AI)の発言のログとします。

// src/types.ts
export type Log =
  | {
      imagePath?: never
      name?: never
      message: string
      at: Date
    }
  | {
      imagePath: string
      name: string
      message: string
      at: Date
    }

ログに最低限必要なのはアイコンを表示するための imagePath と名前を示す name とメッセージの中身 message といつ発言したのかを示す at でしょう。

ユーティリティ関数を作る

チャット画面に時間を表示するためのユーティリティ関数を src/utils.ts に用意します。

// src/utils.ts
export const formatTime = (date: Date) =>
  `${date.getHours().toString().padStart(2, '0')}:${date
    .getMinutes()
    .toString()
    .padStart(2, '0')}`

Date オブジェクトから時・分を取り出して書式整形するものです。

自分の発言を描画する MyMessage

ここからは実際に発言を描画するコンポーネントを作りましょう。まずは自分の発言に使う MyMessage です。ファイル名は src/MyMessage.tsx とします。

// src/MyMessage.tsx
import { FC } from 'react'
import { formatTime } from './utils'

type Props = {
  message: string
  at: Date
}

export const MyMessage: FC<Props> = ({ message, at }) => {
  return (
    <div className="flex gap-2 justify-end">
      <div>
        <div className="flex items-end gap-2">
          <div>{formatTime(at)}</div>
          <div className="mt-2 p-4 bg-[#5fcb76] rounded-2xl max-w-[400px] drop-shadow-md">
            {message}
          </div>
        </div>
      </div>
    </div>
  )
}

ここではIEが滅んだおかげで使えるようになった gap-2 を活用しています。flexレイアウトやGridレイアウトでアイテムの間隔をいい感じにしてくれる便利CSSです。

相手の発言を描画する

今度は話し相手の発言を描画するコンポーネントです。こちらは OtherMessage コンポーネントで src/OtherMessage.tsx です。

// src/OtherMessage.tsx
import { FC } from 'react'
import { formatTime } from './utils'

type Props = {
  imagePath: string
  name: string
  message: string
  at: Date
}

export const OtherMessage: FC<Props> = ({ imagePath, name, message, at }) => {
  return (
    <div className="flex gap-2 justify-start">
      <img src={imagePath} className="rounded-full w-12 h-12" />
      <div>
        <div className="font-bold">{name}</div>
        <div className="flex items-end gap-2">
          <div className="mt-2 p-4 bg-white rounded-2xl max-[400px] drop-shadow-md">
            {message}
          </div>
          <div>{formatTime(at)}</div>
        </div>
      </div>
    </div>
  )
}

Messages

Log配列に格納されたログを、自分のものか判定して、前述の MyMessageOtherMessage で表示するコンポーネントです。ファイル名は src/Message.tsx とします。

// src/Message.tsx
import { FC } from 'react'
import { MyMessage } from './MyMessage'
import { OtherMessage } from './OtherMessage'
import { Log } from './types'

type Props = {
  logs: Log[]
}

export const Messages: FC<Props> = ({ logs }) => {
  return (
    <div className="flex flex-col gap-4">
      {logs.map((log, i) => {
        if (log.name) {
          return <OtherMessage key={i} {...log} />
        } else {
          return <MyMessage key={i} {...log} />
        }
      })}
    </div>
  )
}

ここまでで会話を表示するパーツが揃いました。あとは画面全体のCSSやTSXを作成して、実際にログを描画してみましょう。

ログを描画する

チャットアプリの話し相手は「GPTちゃん」という名前にします。GPTちゃんの画像は適当にStable Diffusionに「GPTちゃん」という文言で生成してもらいました。その画像の顔を切り出して /public/gpt-chan.png に配置しておきます。

GPTちゃん

さて App.tsx でログを描画してみましょう。

// src/App.tsx
import { FC } from 'react'
import { Log } from './types'
import { Messages} from "./Messages"

const App: FC = () => {
  const logs: Log[] = [
    {
      name: 'GPTちゃん',
      message: 'GPTちゃんです。楽しくお話ししましょう。',
      imagePath: '/gpt-chan.png',
      at: new Date(),
    },
    {
      message: 'ChatGPTについて教えて',
      at: new Date(),
    },
  ]

  return (
    <div className="w-full h-screen flex justify-center">
      <div className="pt-10 pb-12 w-full min-h-full px-5 max-w-[600px]">
        <Messages logs={logs} />
      </div>
    </div>
  )
}

export default App

どこかでみたような画面に見えますがきっと気のせいです。

会話を組み立てる

画面はおおよそ出来上がったので、次は会話を組み立てていきましょう。

まず completeText 関数を作る

前回の記事 では OpenAI GPT API を叩いて自然言語処理をするというのを、手動でやってみました。

プロンプトを試す検証過程なら別にそれでもいいのかもしれませんが、流石にアプリとしては使い物にならないので、便利に使える completeText という関数を作ってみます。この関数は async を使った非同期関数で prompt を与えたら、その文字列を補完したものを返すという仕様にします。

前回の記事にも書いた通り、GPT API の結果は

  choices: [
    {
      text: 'ラーメンは、肉類や野菜など素材が多様なため、栄養の健康維持には大変有益です。ただし、かなり油っぽく、塩分も多いなどの注意も必要です。',
      index: 0,
      logprobs: null,
      finish_reason: "stop"
    }
  ],
}

というようなオブジェクトです。

choices[0].text には補完したテキストが入っています。ただしオプションの max_tokens を超えるテキストを生成しようとする場合 textmax_tokens に収まるようにぶつ切りにされ、 finish_reasonlength が入って返ってきます。

その場合、生成されたところまでをプロンプトに文字列結合をして投げ直して finish_reasonstop になるまで繰り返す必要があります。

また choices は配列で、複数帰ってくる可能性があります。

GPTの性質を思い出してください。プロンプトとして与えた文章に続きうる文字列を補完し、複数の可能性がある場合ランダムにそれらを選び取ります。オプションで n: 2 以上を指定するとそれら複数の可能性のうち上位のn個が返ってきます。

当然のことながら、お金がn倍かかるため特殊な事情がなければ n: 1 に固定しておけばよいでしょう。

前回の記事ではDenoだったので、いったん参考用としてDeno版を見ていきます。

Deno版 completeText

import 'https://deno.land/std@0.171.0/dotenv/load.ts'
import { Configuration, OpenAIApi } from 'npm:openai'

const apiKey = Deno.env.get('OPENAI_API_KEY')!
const configuration = new Configuration({ apiKey })
const openai = new OpenAIApi(configuration)

/** prompt で与えた文章を補完する */
export const completeText = async (prompt: string): Promise<string> => {
  const { data } = await openai.createCompletion({
    prompt,
    model: 'text-davinci-003',
    max_tokens: 200,
  })

  const choice = 0

  const isContinue = data.choices[choice].finish_reason === 'length'
  const text = data.choices[choice].text || ''

  if (!isContinue) {
    return prompt + text
  }

  return completeText(prompt + text)
}

Deno版はただの関数です。

  • openai.createCompletion で結果を取得する
  • 取得した結果の finish_reasonlength ならば再帰呼び出しを繰り返す

React版 useCompleteText

今回作るアプリではユーザーが入力したAPIキーを使うようにします。

ウェブサービス提供者が取得したAPIキーを使う場合、ウェブアプリのみで作ると、APIキーがユーザーに丸見えになってしまい、そのキーを使っていくらでもOpenAI APIにアクセスできてしまって困るため、今回はユーザーが入力したキーを使います。

普通にウェブサービスとしてやる場合は、バックエンド側にAPIキーを置くと良いでしょう。

そのために useCompleteText というカスタムフック関数を作りましょう。仕様としてはDeno版とは違って apiKey が指定されていれば completeText 関数に該当するものを返す関数とします。ファイル名は src/completeText.ts とします。

// src/completeText.ts
import { Configuration, OpenAIApi } from 'openai'
import { useMemo } from 'react'

/** prompt で与えた文章を補完する completeText を返す */
export const useCompleteText = ({ apiKey }: { apiKey: string }) => {
  return useMemo(() => {
    if (!apiKey) {
      return undefined
    }

    const configuration = new Configuration({ apiKey })
    const openai = new OpenAIApi(configuration)

    const completeText = async (prompt: string): Promise<string> => {
      const { data } = await openai.createCompletion({
        prompt,
        model: 'text-davinci-003',
        max_tokens: 200,
      })

      const choice = 0
      const isContinue = data.choices[choice].finish_reason === 'length'
      const text = data.choices[choice].text || ''

      if (isContinue) {
        return completeText(prompt + text)
      } else {
        return prompt + text
      }
    }

    return completeText
  }, [apiKey])
}

チャットをするプロンプトを作る

OpenAI が例示するチャットサンプル のプロンプトは以下です。プロンプトはLLMに自然言語処理をしてもらうために与える文字列のことです。

The following is a conversation with an AI assistant. The assistant is helpful, creative, clever, and very friendly.

Human: Hello, who are you?
AI: I am an AI created by OpenAI. How can I help you today?
Human: I'd like to cancel my subscription.
AI:

大体これで問題はないですが、これだけだと記憶を持つことができません。

そこで会話データを差し込むようにしたものを TypeScript で表現してみます。

  const prompt = `The following is a conversation with an AI assistant. The assistant is helpful, creative, clever, and very friendly.

${memory.trim()}
Human: ${userInputText}
AI: `

memory は会話履歴を収めた stringuserInputText はユーザーが入力したテキストで string です。

最も原始的なやり方ではありますが、記憶を実現したプロンプトになりました。GPT最新モデルである text-davinci-003 だと処理できるトークン数は 4000 が最大です。英語だと1ワードが1〜3トークンくらいですが、日本語だと大雑把にいって4000文字に満たないくらいだと思っていればいいと思います。ベタに記憶するとこの4000トークンが記憶の限界ということになります。

今回の記事では取り上げませんが、記憶を工夫するテクニックもあります。GPTに会話の履歴を要約してもらうことでトークン数を減らせば良いのです。

入力コンポーネントを作成する

ユーザーから文字を入力するコンポーネントは textarea で文字列を入力しつつ Enter を押したら送信する仕様とします。 onKeyDownKeyboardEvent<HTMLTextAreaElement> を処理します。

このとき IME で入力中は Enter を無視する仕様にしないと IME の確定のために Enter を押した時の入力を拾ってしまいます。じつは ChatGPT の初期の頃に同じバグがありました。

// src/Input.tsx
import { FC, useCallback, useState } from 'react'

type Props = {
  onSubmit: (text: string) => void
}

export const Input: FC<Props> = ({ onSubmit }) => {
  // 入力文字列
  const [userInputText, setUserInputText] = useState('')
  // IME で入力中かどうかのフラグ
  const [isComposition, setIsComposition] = useState(false)

  const handleKeyDown = useCallback(
    (ev: React.KeyboardEvent<HTMLTextAreaElement>) => {
      if (ev.code === 'Enter' && !isComposition && userInputText.trim()) {
        ev.preventDefault()
        onSubmit(userInputText.trim())
        setUserInputText('')
      }
    },
    [isComposition, onSubmit, userInputText]
  )

  const handleChange = useCallback(
    (ev: React.ChangeEvent<HTMLTextAreaElement>) => {
      setUserInputText(ev.target.value)
    },
    []
  )

  return (
    <textarea
      className="w-full p-2 max-w-[800px] rounded-lg h-24 resize-none border-0 outline-none drop-shadow-lg"
      onChange={handleChange}
      onKeyDown={handleKeyDown}
      onCompositionStart={() => setIsComposition(true)}
      onCompositionEnd={() => setIsComposition(false)}
      value={userInputText}
    />
  )
}

チャットアプリを組み立てる

さて大体のものが揃ってきたので、あとはチャットアプリを組み立てましょう。

ログを組み立てるヘルパーを作る

AIのログを作る createAiLog と自分のログを作る createMyLog を作ります。

const AI_NAME = 'GPTちゃん'
const AI_IMAGE_PATH = '/gpt-chan.png'

export const createAiLog = (message: string): Log => {
  return {
    name: AI_NAME,
    imagePath: AI_IMAGE_PATH,
    message,
    at: new Date(),
  }
}

export const createMyLog = (message: string): Log => {
  return {
    message,
    at: new Date(),
  }
}

特筆すべきことは特にないでしょう。 messagestring を受け取って Log を返すだけの関数です。

チャットアプリのステートを準備する

ここからは src/App.tsxApp コンポーネントの中身になります。

まずはステートを準備します。

  // 描画するログデータ
  const [logs, setLogs] = useState<Log[]>([
    createAiLog('APIキーを入力してください。'),
  ])
  // completeTextを実行するためのOpenAI APIキー
  const [apiKey, setApiKey] = useState('')
  // completeText関数
  const completeText = useCompleteText({ apiKey })
  // 会話の記憶
  const [memory, setMemory] = useState('')

ログの初期値として、GPTちゃんに「APIキーを入力してください」という発言を入れています。

ユーザーが文字列を入力したときのハンドラを作る

handleSubmit 関数は Input コンポーネントの onSubmit に食わせるためのコールバックです。

  const handleSubmit = useCallback(
    async (text: string) => {
      const appendLog = (log: Log) => {
        setLogs((prev) => [...prev, log])
      }
      const appendMemory = (name: string, message: string) => {
        setMemory((prev) => `${prev}${name}: ${message.trim()}\n`)
      }
      const sayAi = (message: string): void => {
        appendLog(createAiLog(message))
        appendMemory('AI', message)
      }

logステートにログを追加するための appendLog 関数と、会話の記憶を追加する appendMemory 関数と、AIが発言するときに使う sayAi 関数を用意しました。

      if (!apiKey) {
        setApiKey(text)
        sayAi('GPTちゃんです。なんでも聞いてください。楽しくお話ししましょう。')
        return
      }

APIキーが未入力のときに、ユーザーが入力したメッセージは、APIキーとして扱います。ひとまず正しいものであると仮定した上で会話を始めるためにGPTちゃんに発言をさせます。ここで早期リターンをするため、この時点では completeText は実行しません。

      if (!completeText) {
        return
      }

completeTextundefined だった場合には何もしない、というコードを入れてます。このコードは順番が重要となります。APIキーがないときの処理の順序を考える必要があります。

最初は apiKey が空文字列であり completeTextundefined です。この状態で handleSubmit で文字列が入力されるとまず setApiKey(text) が実行され、その結果 apiKey が空文字列じゃなくなり completeText に関数が入ります。

  1. apiKey が空文字列であり completeTextundefined
  2. ユーザーが文字列が入力して setApiKey(text) が実行される
  3. apiKey にAPIキーが入って completeText に関数が入る

apiKey が空じゃなくてかつ completeText が空というタイミングは無いはずですが、安全策として入れておいた方が良いでしょう。

      const prompt = `The following is a conversation with an AI assistant. The assistant is helpful, creative, clever, and very friendly.

${memory.trim()}
Human: ${text}
AI: `
      console.log(prompt)

      appendLog(createMyLog(text))
      appendMemory('Human', text)

      const response = await completeText(prompt).catch(() => {
        setApiKey('')
        appendLog(createAiLog('有効なAPIキーを入力してください。'))
      })

      response && sayAi(response.slice(prompt.length))
    },
    [apiKey, completeText, memory]
  )

console.log(prompt) しておくとプロンプトのデバッグがしやすいです。
あとはこのプロンプトを completeText を実行して GPT に投げます。
成功した時は文字列が返ってきて、失敗した場合は .catch の中が実行されて文字列が空になります。そのため response が空じゃなかったら sayAi を実行するようにしています。

response.slice(prompt.length) としているのは、返ってくる文字列はテキストが補完された全体が返ってくるためです。

画面を作る

さっき実験で作った画面に、入力ウィンドウの追加と横幅の調整をしたいのでだいぶんいじっています。

  return (
    <div className="flex justify-center">
      <div className="max-w-[800px] w-full pt-10">
        <Messages logs={logs} />
        <div className="w-full h-40" />
      </div>
      <div className="fixed bottom-0 left-0 w-full h-40 flex justify-center items-center bg-transparent">
        <Input onSubmit={handleSubmit} />
      </div>
    </div>
  )
}

export default App

<div className="w-full h-40" /> で入力ウィンドウのために空白領域を設定して <div className="fixed bottom-0 left-0 w-full h-40 flex justify-center items-center bg-transparent"> で画面の下に入力領域を固定しています。

ちゃんとOpenAI GPT APIにプロンプトを投げてその返答を描画しています。

最後に

今回作ったのと同様のものを https://github.com/erukiti/chatgpt-clone にて公開しています。

改修案

  • 記憶の持ち方を工夫する
  • プロンプトを工夫してキャラ付けをする
  • 会話の記録を localStorage などで保持できるようにする
  • React Suspense でAIの発言中アニメーションを実装する
  • completeText を修正して、トークン数を取得できるようにして、かかる費用を予測する

などが改修案として考えられるところでしょうか。

次の記事

次の記事ではもっとプロンプトに踏み込んでみたいと思っています。

Discussion