OpenAIを使用したChatアプリをAppHostingでデプロイしました!

Posted date at 2024-10-04

GoogleCloud

API

React

Next.js

TailwindCSS

Udemy

ChatGPT

説明文

 Next.js14を使用したアプリ開発のUdemyを受講し、OpenAI APIの使い方とFireBase(FireStoreFireStorage)に関する深い知識(実装方法)を学びましたので共有します。また、アプリのデプロイにはFirebase(GoogleCloud)の新サービスAppHostingを使用しましたのでそちらもご紹介します。

 共有次項は以下4点です。

 ・Udemy講座と制作したアプリ(触っていただいて大丈夫です)

 ・機能とAPIを使用している部分のコードの抜粋(イメージが掴めると思います)

 ・AppHostingの紹介

 ・FireStoreとFireStorageの踏み込んだ使い方


🚀受講した講座

座名『AIチャットアプリ開発』Next.jsとOpenAI APIで5つのAI機能を持ったChatGPTのようなアプリを作成

講師 ポテナス さん

動画再生15.0時間  林がかけた時間39.5時間

説明文

 林のレビューを張っておきます。実装上の細かな点(⇐完成度の高いものを作るうえで必要)が学べて良かったです。

review.png


🚀制作したアプリ

 OpneAPIの利用料は$4.50チャージしてあります。今後使う予定はないため、使用していただいてかまいません。

 チャージがなくなり次第終了です。


🚀アプリの機能とAPIの説明

🐡Conversation機能

 会話をする機能です。デプロイしたアプリではこちらの記事で紹介し涼子さん🤖をセットしています。なお、2024.10.4時点でChatGPT4の知識カットオフは2023.10です。

Conversation2.png

🐡APIの呼び出し部分

 Conversationのドキュメントのコードを示しておきます。履歴を含めて問い合わせをする場合は、messagesに対して、FireStoreから取得した履歴を含めて送信するため、トークンの消費が多くなります。

 なお、見ての通りAPIはバックエンドからのメソッド呼び出しになりますので、バックエンド開発が必要となります。モデルは「gpt-4o」、「gpt4-omini」、「o1-preview」、「o1-mini」です。もっともコスパの良い「gpt4-mini」を使用しています。

//※roleは以下以外にもsystemがあり、キャラクタの設定や前提条件を設定できます。 const response = await openai.chat.completions.create({ model: "gpt-4o", messages: [ { "role": "user", "content": [{ "type": "text", "text": "knock knock." }] }, { "role": "assistant", "content": [{ "type": "text", "text": "Who's there?" }] }, { "role": "user", "content": [{ "type": "text", "text": "Orange." }] } ] });

 実際のコードを以下に示します。プロンプト(今回送るメッセージ)+過去のチャット履歴の配列を生成して、OpenAIのAPIを実行します。

// 送信メッセージをfirestoreに保存 await db.collection("chats").doc(chatId).collection("messages").add({ content: prompt, created_at: FieldValue.serverTimestamp(), sender: "user", type: "text", }); //FireStoreから送信メッセージも含む会話履歴を取得 const messagesRef = db .collection("chats") .doc(chatId) .collection("messages"); const snapShot = await messagesRef .orderBy("created_at", "asc") // ※昇順に並び替え .get(); //履歴を含むメッセージ配列を生成 const messages = snapShot.docs.map((doc) => ({ role: doc.data().sender, content: doc.data().content, })); //OpenAI APIを呼び出してAIの回答を生成 const completion = await openai.chat.completions.create({ messages: messages, model: CONVERSATION_MODLE, }); //・・・⇒FireStoreにAIの回答を保存⇒フロントエンドに表示

🐡ImageGeneration

 画像生成機能です。枚数とサイズを指定できます。生成した画像はダウンロード可能です。DALLE-2を使用しているためか、精度が悪いです。タコを指定しても鳥の画像を生成したりします。DALLE-3にすると精度が良くなりますが、画像枚数を指定することができません。DALLE-3は私も良く使用しています。 

 生成した画像はFireStorageに保存しています。ダウンロードも可能です。

image_generation.png

🐡APIの呼び出し部分

 ドキュメントのコードを示します。こちらも見たままです。

const response = await openai.images.generate({ model: "dall-e-3", prompt: "a white siamese cat", n: 1, size: "1024x1024", }); image_url = response.data[0].url;

 実際のコードは以下の通りです。プロンプト(今回送るメッセージ)と画像のURLをFireStoreに保存ししています。画像のURLは、生成した画像をFireStorageにアップロードした時に指定したパスとしています。

 また画像はAIからのレスポンスのバイナリファイルをバッファ形式に変換して使用しています。他のバイナリファイルでもNode.jsで取り扱う場合は同様の処理をします。

// ユーザーメッセージをfirestoreに保存 await db.collection("chats").doc(chatId).collection("messages").add({ content: prompt, created_at: FieldValue.serverTimestamp(), sender: "user", type: "text", }); // openAI APIを呼び出してAIの回答を生成 const response = await openai.images.generate({ model: IMAGE_GENERATION_MODEL, prompt: prompt, n: parseInt(amount), size: size, }); //URL->ダウンロード->バイナリデータに変換->保存パスを設定->ストレージにアップロード const imageDataPromises = response.data.map(async (item) => { if (item.url) { const response = await fetch(item.url); const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const filePath = `${user.uid}/chatRoom/${chatId}`; return await fileUploadToStorage(buffer, filePath, "image/png"); } }); const urls = await Promise.all(imageDataPromises); // AIメッセージをfirestoreに保存 await db.collection("chats").doc(chatId).collection("messages").add({ content: urls, created_at: FieldValue.serverTimestamp(), sender: "assistant", type: "image", });

🐡TextToSpeech

 プロンプトを音声データに変換します。音声ファイルはmp3で生成しており、FireStorageに保存されます。再生とダウンロードができます。

text_to_speech.png

🐡APIの呼び出し部分

 ドキュメントのコードを示します。モデルは”tts-1”のみです。音声は「alloy, echo, fable, onyx, nova, and shimmer」から選択します。

 上記で説明したバッファ形式にしてからファイルを保存しています。

async function main() { const mp3 = await openai.audio.speech.create({ model: "tts-1", voice: "alloy", input: "Today is a wonderful day to build something people love!", }); console.log(speechFile); const buffer = Buffer.from(await mp3.arrayBuffer()); await fs.promises.writeFile(speechFile, buffer); }

 実際のコードは以下の通りです。ImageGenerationとほぼ同じなので説明はしません。

// ユーザーメッセージをfirestoreに保存 await db.collection("chats").doc(chatId).collection("messages").add({ content: prompt, created_at: FieldValue.serverTimestamp(), sender: "user", type: "text", }); // openAI APIを呼び出してAIの回答を生成 const audioResponse = await openai.audio.speech.create({ model: TEXT_TO_SPEECH_MODEL, voice: TEXT_TO_SPEECH_VOICE, input: prompt, response_format: "mp3", }); //バイナリデータに変換->保存パスを設定->ストレージにアップロード const arrayBuffer = await audioResponse.arrayBuffer(); //バイナリデータに変換 const buffer = Buffer.from(arrayBuffer); //バイナリデータをNode.jsで扱える形式に変換 const filePath = `${user.uid}/chatRoom/${chatId}`; const url = await fileUploadToStorage(buffer, filePath, "audio/mpeg"); // AIの回答をfirestoreに保存 await db.collection("chats").doc(chatId).collection("messages").add({ content: url, created_at: FieldValue.serverTimestamp(), sender: "assistant", type: "audio", });

🐡SpeechToText

 音声ファイルを送信して、テキストを得ます。

speech_to_text.png

 音声ファイルのバリデーションは拡張子とMIMIEタイプで行っており、Zod/RHFを使用して以下の通り実装しています。Zod/RHFはNextSHIFT、TimeMaster、SiteDIARYでも使用しています。

const MAX_AUDIO_FILE_SIZE = 20 * 1024 * 1024; // 20MB const ACCEPTED_AUDIO_EXTENTIONS = [ "flac", "mp3", "mp4", "mpeg", "ogg", "wav", "webm", "mpga", "m4a", ]; const ACCEPTED_IMAGE_FORMATS = [ "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp", ]; export const speechToTextSchema = z.object({ file: z .instanceof(File, { message: "ファイルを選択してください" }) //カスタムサイズバリデーション //ファイルサイズ .refine((file) => file.size <= MAX_AUDIO_FILE_SIZE, { message: "20MB以下のファイルを選択してください", }) //ファイル形式 .refine( (file) => { const fileTypeValid = ACCEPTED_AUDIO_FORMATS.includes(file.type); const fileExtentionValid = ACCEPTED_AUDIO_EXTENTIONS.includes( file.name.split(".").pop()! ); return fileTypeValid && fileExtentionValid; }, { message: "対応しているファイル形式を選択してください。" } ), });

🐡APIの呼び出し部分

 以下ドキュメントのコードです。モデルは”whisper-1”のみです。

async function main() { const transcription = await openai.audio.transcriptions.create({ file: fs.createReadStream("/path/to/file/audio.mp3"), model: "whisper-1", }); console.log(transcription.text); }

実際のコードは以下の通りです。まず送信した音声ファイルをFireStorageに保存し、生成したURLをFireStoreに保存します。生成したURLをOpenAIのAPIに指定して、テキストを得ます。テキストをFireStoreに保存します。

//バイナリデータに変換->保存パスを設定->ストレージにアップロード const arrayBuffer = await file.arrayBuffer(); //バイナリデータに変換 const buffer = Buffer.from(arrayBuffer); //バイナリデータをNode.jsで扱える形式に変換 const filePath = `${user.uid}/chatRoom/${chatId}`; const url = await fileUploadToStorage(buffer, filePath, file.type); console.log("url", url); // 音声データのurlをfirestoreに保存 await db.collection("chats").doc(chatId).collection("messages").add({ content: url, created_at: FieldValue.serverTimestamp(), sender: "user", type: "audio", }); // openAI APIを呼び出してAIの回答を生成 const transcription = await openai.audio.transcriptions.create({ file: file, model: SPEECH_TO_TEXT_MODEL, }); console.log("transcription.text", transcription.text); const aiResponse = transcription.text; // AIの回答をfirestoreに保存 await db.collection("chats").doc(chatId).collection("messages").add({ content: aiResponse, created_at: FieldValue.serverTimestamp(), sender: "assistant", type: "text", });

🐡ImageAnalysis

 送信した画像について、指定したプロンプトの解析をしてくれます。プロンプトがない場合は裏側でデフォルトのプロンプトが設定され大まかな解析をしてくれます。画像は20MBまで複数枚指定可能です。

 履歴を考慮した会話が可能です。ナポレオンも当ててくれました。

image_analisis.png

 ファイルとプロンプトのバリデーションはZod/RHFで行っています。配列形式のカスタマイズバリデーションは書いたことがないため、とても参考になりました。

const ACCEPTED_IMAGE_FORMATS = [ "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp", ]; const ACCEPTED_IMAGE_EXTENSION = ["png", "jpg", "jpeg", "gif", "webp"]; const MAX_IMAGE_FILE_SIZE = 20 * 1024 * 1024; // 20MB export const imageAnalysisSchema = z .object({ prompt: z.string().optional(), files: z .array( z .instanceof(File, { message: "ファイルを選択してください。" }) //ファイルの形式 .refine( (file) => { const fileTypeValid = ACCEPTED_IMAGE_FORMATS.includes(file.type); const fileExtensionValid = ACCEPTED_IMAGE_EXTENSION.includes( file.name.split(".").pop()! ); return fileTypeValid && fileExtensionValid; }, { message: "対応していないファイルタイプです。", } ) ) //最大サイズ .refine( (files) => { const totalFIleSize = files.reduce((acc, file) => acc + file.size, 0); return totalFIleSize <= MAX_IMAGE_FILE_SIZE; }, { message: "20MB以下のファイルを選択してください。", } ) .optional(), }) .refine((data) => data.prompt || (data.files && data.files?.length > 0), { message: "promptまたはfilesのどちらか一方は必須です。", path: ["prompt", "files"], });

🐡APIの呼び出し部分

 使用するモデルはConversationに使用したものと同じです。Conversationと異なる点は、contentに指定するものがテキストだけではなくて、テキストと画像の配列であるという点です。この形式にのっとって、複数の画像とテキストを送付します。

async function main() { const response = await openai.chat.completions.create({ model: "gpt-4o-mini", messages: [ { role: "user", content: [ { type: "text", text: "What’s in this image?" }, { type: "image_url", image_url: { "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", "detail": "low" }, }, ], }, ], }); console.log(response.choices[0]); }

 以下が実際のコードです。プロンプト(テキスト)と画像のURLをFireStoreに保存し、その状態でFireStoreから全チャット履歴を取得してメッセージ配列を生成しています。メッセージ配列をOpenAIのAPIに指定して回答を得たのち、その回答をFireStoreに保存しています。

//URL->ダウンロード->バイナリデータに変換->保存パスを設定->ストレージにアップロード if (files.length > 0) { const imageDataPromises = files.map(async (file) => { const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const filePath = `${"BXFoDjz21qS38oSjO2jf6e5aPRW2"}/chatRoom/${chatId}`; return await fileUploadToStorage(buffer, filePath, file.type); }); urls = await Promise.all(imageDataPromises); console.log("urls", urls); } // ユーザーメッセージをfirestoreに保存 await db .collection("chats") .doc(chatId) .collection("messages") .add({ content: { text: prompt, imageUrl: urls }, created_at: FieldValue.serverTimestamp(), sender: "user", type: "image_analysis", }); //FireStoreからチャット履歴を取得 const messagesRef = db .collection("chats") .doc(chatId) .collection("messages"); const snapShot = await messagesRef .orderBy("created_at", "asc") // ここで昇順に並び替え .get(); //メッセージ配列を生成 const messages: ChatCompletionMessageParam[] = snapShot.docs.map((doc) => { if (doc.data().sender === "user") { //ユーザメッセージ return { role: "user", content: [ //テキスト { type: "text", text: doc.data().content.text }, //画像(複数指定) ...doc.data().content.imageUrl.map((url: string) => ({ type: "image_url" as "image_url", image_url: { url: url, }, })), ], }; } else { return { role: "assistant", content: doc.data().content, }; } }); console.log("messages", messages); // openAI APIを呼び出してAIの回答を生成 const response = await openai.chat.completions.create({ model: CONVERSATION_MODLE, messages: messages, }); console.log(response.choices[0]); const aiResponse = response.choices[0].message.content; // AIのメッセージをfirestoreに保存 await db.collection("chats").doc(chatId).collection("messages").add({ content: aiResponse, created_at: FieldValue.serverTimestamp(), sender: "assistant", type: "text", });

🚀AppHostingでのデプロイ

 今回AppHostingにデプロイしました。

説明文

 課内ミーティングで使ってみますと言っていたものです。製造みえる化アプリ(ProceesVISION)他で使用しているFirebaseHostingはフロントエンドのみデプロイ環境ですが、こちらはバックエンド込みのフルスタックのデプロイが可能です。直近でいうと勤怠管理アプリ(TimeMaster)はこちらで一括デプロイ可能です。

 実際使用してみると裏側でCloudRunのサービスが立ち上がるようになっており、環境変数の設定などはそちらから行うことが可能でした。パフォーマンス的にCloudRunとどう違うのか今一つ調べられませんでしたが、デプロイのしやすさはどちらもどちらといった感じです。

 


🚀FireStoreの技術的なこと

🐡データベースの変更のリアルタイム監視

 別のユーザ操作などによってデータベースが変更されたときにフロントエンドでそれを検知することが可能です。これにより、リアルタイムにフロントエンドの表示を切り替える実装が可能となります。

 例:表示テーブルの自動更新(制服のオーダー状態)、チャットメッセージの自動更新

 

 以下の例では、messagesには常に最新のチャット履歴が保存されます。分かりやすさを優先した説明をするとこのmessagesをhtmlに入れ込むようにすると表示がリアルタイムで変わるようになります。

const [messages, setMessages] = useState<Message[]>([]); useEffect(() => { if (!chatId) return; const q = query( collection(db, "chats", chatId, "messages"), where("sender", "!=", "system"), orderBy("created_at", "asc") ); const unsubscribe = onSnapshot(q, (snapShot) => { const fetchMessages = snapShot.docs.map((doc) => ({ id: doc.id, type: doc.data().type, sender: doc.data().sender, created_at: doc.data().created_at, content: doc.data().content, })); setMessages(fetchMessages); console.log(fetchMessages); }); return () => unsubscribe(); //監視を解除(不要なリソースを解放) }, [chatId]);

🐡フロントエンドからのFireStoreルールを使用したアクセス制限

 FireStoreはコンソール上から、ドキュメントに対してルールを定義することで、アクセス制限を行うことが可能です。

 以下はチャットルームに対してチャットルームの作成者とログインユーザのIDが一致する場合のみチャットルームの読み取りと書き込みができるようにしています。チャット履歴の秘匿性の確保です。

rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { //chatsコレクションに対するセキュリティルール match /chats/{chatId} { allow read: if request.auth != null && request.auth.uid == resource.data.user_id; allow update: if request.auth != null && request.auth.uid == resource.data.user_id; allow create: if request.auth != null && request.auth.uid == request.resource.data.user_id; //messagesサブコレクションに対するセキュリティルール match /messages/{messageId} { allow read: if request.auth != null && request.auth.uid == get(/databases/$(database)/documents/chats/$(chatId)).data.user_id; } } } }

🐡バックエンドでのトークンを使用した制限

 フロントエンドから使用できる点が便利なFireStoreですが、画像のアップロードや複数の同時更新処理などはサーバーサイドでの実行が望ましいです。PCはともかくロースペックな端末での実行も想定されるためです。その場合はバックエンドからのFireStoreのアクセスになるのですが、バックエンドからの実行の場合権限がAdminになります。

 Admin権限下でのアクセス制限ではトークンを使用します。ログイン状態の時にauth.onIdTokenChangedメソッドを使用することで、トークンの発行状態を監視することができます。トークンの有効期限が切れたときも自動で新しいトークンが取得できます。

useEffect(() => { const unsubscribe = auth.onIdTokenChanged(async (user) => { if (user) { setCurrentUser(user); const token = await user.getIdToken(); console.log("token", token); setUserToken(token); } else { setCurrentUser(null); setUserToken(null); } setIsLoading(false); }); return () => unsubscribe(); }, []);

 バックエンドにAPIコールするときにuserTokenを渡します。

await axios.post(`/api${apiUrl}`, apiData, { headers: { Authorization: `Bearer ${userToken}`, }, });

 バックエンドでトークンの有無、トークンをデコードして得られたユーザーIDがチャットルーム作成者のユーザIDと一致するかを確認します。

const headersList = headers(); const authHeader = headersList.get("Authorization"); //トークンが取得されているか? if (!authHeader) { return NextResponse.json( { error: "トークンが添付されていません" }, { status: 401 } ); } //デコード const token = authHeader.replace("Bearer ", ""); const user = await verifyToken(token); if (!user) { return NextResponse.json( { error: "無効なトークンです" }, { status: 401 } ); } //firestoreのデータを操作してよいユーザーか? const hasPermission = await checkUserPermission(user.uid, chatId); if (!hasPermission) { return NextResponse.json( { error: "操作が許可されていないか、リソースが存在しません" }, { status: 403 } ); } //上記をパスした場合は以降の処理を実行する
//別ファイルの関数定義 export async function verifyToken(token: string) { //デコード try { const decodedToken = await getAuth().verifyIdToken(token); return decodedToken; } catch (error) { console.log("IDトークンの検証エラー", error); // throw new Error("無効なトークンです"); return null; } } export async function checkUserPermission(uid: string, chatId: string) { //デコード try { const chatRef = db.collection("chats").doc(chatId); const chatDoc = await chatRef.get(); if (!chatDoc.exists) { return false; } const chatData = chatDoc.data(); // console.log("chatData", chatData); // console.log("checkData?.user_id === uid", chatData?.user_id, uid, chatId); return chatData?.user_id === uid; } catch (error) { console.log("ユーザー権限のチェックエラー", error); throw new Error("ユーザー権限のチェックに失敗しました"); } }

🚀まとめ

 OpenAIAPIの紹介と併せて、FireBaseとAppHostingについて共有しました。FireStoreはJson形式でファイルが保存でき、直感的に見やすいうえ、セキュリティの機能や、トランザクション機能も用意されています。GoogleCloudのサービスでもあるのでkintone以外のBaaSを考える上では筆頭にしても良いと考えます。

 また、リアルタイム監視機能が強力です。

 今制服アプリの開発ではFireStoreとFireStorageを使用し、注文情報のリアルタイム監視も実装しています。内容についてはいずれ共有します。

←ホームに戻る