React+Next.js+TypeScript+FirestoreでChatGPTクローン作成
ChatGPT APIが公開されたし格安だし、個人で使う分にはFirestoreも無料枠で収まるからGPT plusプラン解約できるのでは…?というモチベで当初API公開に合わせて着手してみたものの、途中から技術検証のほうに夢中になってしまって結局完成まで1ヶ月近く掛かってしまった。
その後GPT-4が公開されたしPluginsも待ち状態なので、結局有料プランのままで運用しています。GPT関連の技術は毎日のように新しい技術が出てきて話題が尽きないですね。
概要
成果物
実装内容はタイトルがほぼ全て。今回はHostingまではしていない。
下記は過程で残したスクショ(間1ヶ月空いているのが試行錯誤+検証期間)。
実行環境
色々試してみたかったので比較的新しめ。ただNextJsはほぼv13の新機能を採用できなかった。。
- React: 18.2.0
- TypeScript: 5.0.2
- Recoil: 0.7.7
- Next.js: 13.2.4
- Firebase JavaScript SDK: v9
リポジトリ
使い方
取り敢えずこれで立ち上がる。
yarn install
yarn dev
質問(submit)した際にChatGPT APIからの回答を得るためには、Open AIのサイトで取得したAPI keyのセットが必要。
更にそれをFirebaseに履歴を残すために、FirestoreへのWebアプリケーションの設定により得られる firebaseConfig = { ... }
情報を .env
ファイルに記述する必要がある。
ただし認証/履歴保持が不要なのであれば firebaseConfig
は除外可能(該当箇所のコメントアウトとセット)。
OPENAI_ENDPOINT=https://api.openai.com/v1/chat/completions
OPENAI_APIKEY=
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGE_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
データ構成
本アプリケーションの登場人物はスレッドとメッセージのみ。ざっくり構成をまとめると下記のような感じ。
全thread(=threadList)に各threadIdがあり、そのthreadにmessagesが紐づいていて、その中の各messageIdが role: system | user | assistant
のメッセージオブジェクトに対応している。本家をイメージしてもらえば解りやすいかと。
(mermaid記法、上下関係は指定できないのだろうか)
実装
リポジトリ自体はpublicにしているので掻い摘んで説明。後で実装内容を忘れそうな自分へのメモ書き程度に。
なおFirestoreを採用しなくとも「認証機能が消える/リロードで履歴が消える」というだけで、ChatGPT APIとの対話アプリケーションは動く。単にアカウント登録で取得した Open AI API を使ったローカルで動くクローンアプリが欲しいということであれば、Firebase SDK関連の箇所はすっ飛ばして良い。
[1] Webアプリケーション実装
Recoilによる状態管理
作成したatom, hooksは下記の通り。今回はシンプルなAPI問い合わせなのでselectorも採用していない。
atom
-
messageAtom<FirebaseMessage[], ThreadId | null>
- ChatGPT APIにリクエスト送信 / Firestoreにログ保存するベースとなるメッセージ履歴
- 引数 threadId のatomFamily
-
threadListAtom<FirebaseThread[]>
- スレッドId(+タイトル)一覧
-
currentThreadIdAtom<ThreadId>
- 現在選択中のスレッドId
-
isFirstPostAtom<boolean>
- 初回投稿判別フラグ
- 既存のスレッド選択時はfalse、新規スレッド投稿時はtrue
-
isLoadingAtom<boolean>
- ローディングフラグ
- API問い合わせ中にtrue、それ以外はfalse
hooks
-
useAuth
- フォームデータを元にFirebase Authenticationにユーザ登録
- フォームデータを元にFirebase Authenticationにパスワード認証
-
useFirebaseInitialize
- 初回ロード時にFirestoreからthreadListを取得
-
useMessageInitialize
- threadId変更時にmessagesを取得
-
useFirebase
- onClickNew: [+New Thread] 選択時のイベント管理
- addMessages: チャット欄submit時の挙動管理
- isLoading: チャット欄submit時の状態管理
Next.js API Routes + ChatGPT APIでリクエスト送信
最初あまり深く考えずに単純に .env
ファイルに NEXT_PUBLIC_*
を記述、hooksから process.env.NEXT_PUBLIC_*
で参照してリクエスト処理を書いていたがAPIキーが露呈してしまうことに後から気付いた(初心者)。。
そのためNext.jsのAPI Routesを使用してエンドポイントを介してリクエスト処理をすることに。
import axios from 'axios'
import { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const apiKey = process.env.OPENAI_APIKEY
const url = process.env.OPENAI_ENDPOINT
if (req.method !== 'POST') {
res.status(405).json({ message: 'Method Not Allowed' })
return
}
try {
const response = await axios.post(url, req.body, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
})
res.status(200).json(response.data)
} catch (error) {
res.status(500).json({ message: 'Internal Server Error' })
}
}
チャットフォーム
Chakra UI
と react-hook-form
, react-syntax-highlighter
をベースに作成したシンプルなもの。デザインも本家寄せなので特に説明すべきことは無さそう。似せつつ自分用に欲しい要素を少し加えた感じ。
ChatGPT APIから受け取った結果を本家っぽく順に文字表示するなら、 setTimeout
や setInterval
を使ってディレイを掛けると良いかも(他アプリケーションで実装したが本アプリケーションには不要かと思い除外した)。
[2] Firebase関連実装
Firebase Authenticationによる認証
今回は自分以外のユーザを想定していないので簡易なメールアドレス認証とし、コンポーネント作成+挙動確認をする程度で試してみた。
参考にしたのはこの辺り[1][2][3]。
上記URLを参考にFirestore側でのアクセス権限も設定。この辺りの厳格さは利用者規模によるかと。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /threads/{threadId} {
allow read, write: if request.auth != null;
match /messages/{messageId} {
allow read, write: if request.auth != null;
}
}
}
}
ChatGPT APIに対しての req/res をFirestore Databaseに保存
そもそもFirestoreのデータモデルの理解からだったため、ここの検証にかなりの時間が掛かった。
コレクション、ドキュメント、サブコレクション、ドキュメントの4階層で成り立っており、 データ構成 箇所と対応付けるとこんな感じになる。
データ階層 | Firestore | 説明 |
---|---|---|
thread | collection | スレッド一覧 |
threadId(s) | document | 単一スレッド |
messages | sub collection | スレッド内メッセージ一覧 |
messageId(s) | document | 単一メッセージ(from user/assistant) |
これを受けて useFirebase
内ではパスと共に操作を指定する。下記はその一例。
// COLLECTION(=thread), threadId, SUBCOLLECTION(=messages) を指定し、新規にdocument(=newMessage)を追加
const addFirestoreDoc = async (
newMessage: ChatGptMessage,
threadId: string
) => {
const mRef = collection(db, COLLECTION, threadId, SUBCOLLECTION)
await addDoc(mRef, {
role: newMessage.role,
content: newMessage.content,
createdAt: Timestamp.fromDate(new Date()),
})
}
// COLLECTION(=thread), threadId を指定し、既存document(=thread)のタイトルを更新
const addFirestoreDocTitle = async (title: string, threadId: string) => {
await setDoc(doc(db, COLLECTION, threadId), {
title: title,
createdAt: Timestamp.fromDate(new Date()),
})
}
[3] 実装中に直面した問題
そもそも最初viteで開発スタートした
軽量で開発が快適という単純な前情報から、使ってみたかったこともあって当初はviteでプロジェクトを作成した。
サクサク実装が進むので満足していたのだが、 API実装時のキー露呈問題 に直面してNext.jsに乗り換えてしまった。
プロキシ設定で踏み台を作成する[4]とか vite-plugin-ssr
プラグインを採用してバックエンドAPIを別途デプロイするといった可能性も考えたうえで、諸々面倒になって単一デプロイで済むNext.jsに乗り換えたというのが正直なところ。
Next.js v13 + api問題
一応ベースとしては app directory
構成ではあるものの、 上記API を Route Handlers[5] で実装しようと試行錯誤してもうまくいかなかったのでapi実装だけのために pages/api
ディレクトリを作成し API Routes[6] で実現している。
Next.js v13 + Recoil問題
或る程度開発が進んだ辺りで Server Component
とRecoilのようなThird-party packagesとの相性はあまり良くないというのが公式の見解[7] だと知り、 API問題も相まって途中Next.jsのバージョンを12に下げてやり直そうかと何度か悩んだ。
実際共通コンポーネントであるSidebar(=スレッド一覧)は事前にFirestoreからデータを取得し、 app/pages.tsx
では初期化されたメッセージを元に配列連結を、app/[threadId]/pages.tsx
では threadId
を引数に過去のメッセージ履歴を呼び出したうえで配列連結をする想定だったが app/layout.tsx
でのデータフェッチがどうもうまくいかなかった。
結局 */pages.tsx
でそれぞれスレッドコレクション取得処理を、更にその配下の <Chat />
コンポーネントに */pages.tsx
で取得した query parameterを渡して無理くりメッセージサブコレクション取得処理を実行している。そこらじゅうで 'use client'
ディレクティブを採用しているのでもはや app directory である必要がほぼない。。
まとめ
だいぶ端折りながらざっと説明が必要そうな箇所をまとめてみた。必要に応じてまた更新する予定。
料金についてはあまり気にしていなかったが、何度も処理を書き直して無駄に読み取り回数が嵩んだ日ですら1,000件/日程度で、無料枠上限の1/50程度なのでこの用途であれば問題無さそう。後はそのままFirebase Hostingにデプロイすれば少人数ならそのまま使っても良いかもしれない。
その他参考URLは都度付けたしで。[8]
Discussion