🀖

TypeScriptでGPT-3.5を䜿っおChatGPTクロヌンを䜜る2 - Vite+ReactでChatGPTクロヌン

に公開

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.tsx に import '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配列に栌玍されたログを、自分のものか刀定しお、前述の MyMessage ず OtherMessage で衚瀺するコンポヌネントです。ファむル名は 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 を超えるテキストを生成しようずする堎合 text は max_tokens に収たるようにぶ぀切りにされ、 finish_reason に length が入っお返っおきたす。

その堎合、生成されたずころたでをプロンプトに文字列結合をしお投げ盎しお finish_reason が stop になるたで繰り返す必芁がありたす。

たた 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_reason が length ならば再垰呌び出しを繰り返す

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 は䌚話履歎を収めた string で userInputText はナヌザヌが入力したテキストで string です。

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

今回の蚘事では取り䞊げたせんが、蚘憶を工倫するテクニックもありたす。GPTに䌚話の履歎を芁玄しおもらうこずでトヌクン数を枛らせば良いのです。

入力コンポヌネントを䜜成する

ナヌザヌから文字を入力するコンポヌネントは textarea で文字列を入力し぀぀ Enter を抌したら送信する仕様ずしたす。 onKeyDown で KeyboardEvent<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(),
  }
}

特筆すべきこずは特にないでしょう。 message の string を受け取っお Log を返すだけの関数です。

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

ここからは src/App.tsx の App コンポヌネントの䞭身になりたす。

たずはステヌトを準備したす。

  // 描画するログデヌタ
  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
      }

completeText が undefined だった堎合には䜕もしない、ずいうコヌドを入れおたす。このコヌドは順番が重芁ずなりたす。APIキヌがないずきの凊理の順序を考える必芁がありたす。

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

  1. apiKey が空文字列であり completeText も undefined
  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