Server ActionsとuseOptimisticフル活用で作るAIチャット
タイミーでフロントエンドエンジニアをしているnisshi-です。
本記事はTimee Advent Calendar 2023 シリーズ1の22日目の記事になります。
概要
Server Actionsは、文字通りクライアントサイドからサーバーサイドのロジックが直接実行出来るインターフェースです。
Next.js 14アップデートの発表時には、下記の実装例を紹介して話題になりました。
例の様にuse server
ディレクティブを用いたサーバーサイドの処理が良く取り上げられますが、Server Actions自体はクライアントサイドでも実行する事が可能です。
今回はクライアントサイドで、Server ActionsとuseOptimisticフック、更にチャット相手にOpenAI(gpt)を選定し、それらを用いた対話的なチャットアプリを作る例をご紹介します。
こんな見た目
チャットを送信するactionsを作る
チャット相手はOpenAIです。
OpenAIにチャットを送る処理をactionsに書きます。
'use server';
import { OpenAI } from 'openai';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
import { ChatCompletionMessageParam } from 'openai/resources/chat';
export async function sendChat(
chatHistories: ChatCompletionMessageParam[]
): Promise<string> {
const chatCompletion = await openai.chat.completions.create({
messages: [...chatHistories],
model: 'gpt-3.5-turbo',
});
const [choice] = chatCompletion.choices;
return choice.message.content;
}
-
use server
でServer Actionsとしてモジュールを定義します。- OpenAIのAPIはクライアントサイドからも呼び出せますが、サーバーサイドのモジュールとすることで、環境変数の参照等の処理がより堅牢かつ簡単に行えます。
- 前後のつながりを保った会話を行う為、引数はこれまで行ったチャット全ての配列です。 この部分は人間同士のチャットアプリの設計と若干考え方が異なるかもしれません。
フォームからactionsを呼び出す
上記actionsをクライアントコンポーネントから呼び出します。
'use client';
import { useState } from 'react';
import { ChatCompletionMessageParam } from 'openai/resources/chat';
import { sendChat } from '../../actions/chat';
import { EnhancedChatCompletionMessageParam, Thread } from './thread';
export function ChatApp() {
const [messages, setMessages] = useState<
EnhancedChatCompletionMessageParam[]
>([
{
role: 'assistant',
content: 'こんにちわ!何でも聞いてください!',
},
]);
const sendMessage = async (message: string) => {
const sentMessage: ChatCompletionMessageParam = {
role: 'user',
content: message,
};
const histories: ChatCompletionMessageParam[] = [...messages, sentMessage];
const content = await sendChat(histories); // Server Actionsをコール
const newMessage: ChatCompletionMessageParam = {
role: 'assistant',
content,
};
setMessages((messages) => [...messages, sentMessage, newMessage]);
};
return <Thread messages={messages} sendMessages={sendMessage} />;
}
- チャットは対話的な機能なので、大部分がクライアントコンポーネントになる設計がベターと考えています。
- フォームの実態は後述の
Thread
コンポーネントに存在しています。- 親コンポーネントになっている上記
ChatApp
は、中央集権的に元となるチャットの配列を管理しています。 -> messagesステート
- 親コンポーネントになっている上記
-
EnhancedChatCompletionMessageParam
は、OpenAIのChatCompletionMessageParam
を拡張したtypeです。
export type EnhancedChatCompletionMessageParam = ChatCompletionMessageParam & {
sending?: boolean; // 後述のoptimistic箇所で利用される、送信中を表す真偽値propを追加
};
formActionとuseOptimisticを使う
前述のThread
コンポーネントを段階的に解説します。
props
export function Thread({
messages,
sendMessages,
}: {
messages: EnhancedChatCompletionMessageParam[];
sendMessages: (message: string) => Promise<void>;
}) {
-
messages : 呼出元で管理されているメッセージ一覧を受け取るprop。そのまま
map
で展開せず、後述のuseOptimistic
に、基盤のstate(チャット一覧)
として渡します。 -
sendMessages :
formAction
内から呼び出します。ユーザーが入力したチャット文字列を伴って親元にイベントをハブリングします。
formActionとuseOptimistic
async function formAction(formData: FormData) {
const sentMessage = formData.get('message') as string;
addOptimisticMessage(sentMessage);
formRef?.current?.reset();
await sendMessages(sentMessage);
}
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(currentState, newMessage) => [
...currentState,
{
content: newMessage,
role: 'user',
sending: true,
},
]
);
formAction
-
addOptimisticMessageに、今入力された文字列を渡します。
- こうする事で、チャット一覧を1つの配列で一元管理しつつ、入力⇄一覧更新がインタラクティブに切り替わるUIを実装できます。
-
ref
で送信後の入力値リセットをします。
useOptimistic
- 第一引数に、大元で管理されているメッセージの一覧を渡します。
- 初期状態や実行中のアクションが存在しない場合は、常に
messages
が表示され続けます。
- 初期状態や実行中のアクションが存在しない場合は、常に
- 第二引数の
updateFn
では、戻り値としてcurrentStateと、アクション実行中に暫定的に表示される値をマージした結果を返却しています。- 大元の
messages
が更新された後、暫定的に表示されていた値、今回のケースだとsending
がtrue
に設定されたオブジェクトがマージされて無くなり、最新のリストが表示され直します。
- 大元の
一覧表示
<div className={styles.chatWindow}>
{optimisticMessages.map((message, index) => (
<React.Fragment key={index}>
<div
className={`${styles.message} ${
message.role === 'user' ? styles.myMessage : styles.otherMessage
}`}
>
{message?.content as string}
</div>
{message.sending && <small>Sending...</small>}
</React.Fragment>
))}
</div>
-
useOptimistic
で定義したoptimisticMessages
を一覧として展開しています。- addOptimisticMessageを起点に、アクション実行中⇄終了時それぞれのタイミングで、インタラクティブに一覧表示を切り替えます。
-
roleプロパティから
self
orother
のスタイリングを判別しています。
フォーム入力部分
<form action={formAction} className={styles.formContainer} ref={formRef}>
<input
type="text"
name="message"
className={styles.inputField}
placeholder="メッセージを入力"
/>
<button type="submit" className={styles.submitButton}>
送信
</button>
</form>
- シンプルに
formAction
を呼び出しています。
余談
このform
はReactで拡張されたform
なので、actionプロパティにformAction
を引き渡せますが、actionに文字列を引き渡せば従来通りのform
として振る舞ってくれます。
以上、チャットアプリの実装解説でした。
→thread.tsxのコード全体も載せますので、気になる方はご確認下さい(長いのでトグル)
thread.tsx全体
'use client';
import styles from './thread.module.css';
import { ChatCompletionMessageParam } from 'openai/resources/chat';
import React from 'react';
import { useOptimistic, useRef } from 'react';
export type EnhancedChatCompletionMessageParam = ChatCompletionMessageParam & {
sending?: boolean;
};
export function Thread({
messages,
sendMessages,
}: {
messages: EnhancedChatCompletionMessageParam[];
sendMessages: (message: string) => Promise<void>;
}) {
const formRef = useRef(null);
async function formAction(formData: FormData) {
const sentMessage = formData.get('message') as string;
addOptimisticMessage(sentMessage);
formRef?.current?.reset();
await sendMessages(sentMessage);
}
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(currentState, newMessage) => [
...currentState,
{
content: newMessage,
role: 'user',
sending: true,
},
]
);
return (
<div>
<h2>チャット</h2>
<div className={styles.chatWindow}>
{optimisticMessages.map((message, index) => (
<React.Fragment key={index}>
<div
className={`${styles.message} ${
message.role === 'user' ? styles.myMessage : styles.otherMessage
}`}
>
{message?.content as string}
</div>
{message.sending && <small>Sending...</small>}
</React.Fragment>
))}
</div>
<form action={formAction} className={styles.formContainer} ref={formRef}>
<input
type="text"
name="message"
className={styles.inputField}
placeholder="メッセージを入力"
/>
<button type="submit" className={styles.submitButton}>
送信
</button>
</form>
</div>
);
}
作った感想、まとめ
- Server Actionsを用いる事で、
react-hook-form
の様なform操作のサードパーティを使わずとも、ここまで対話的でインタラクティブな機能を作りこめる様になった事は素晴らしいなと感じました。 - 今回はNext.jsを用いて開発をしたのですが、実装にあたってNext.jsのAPIが全く登場していないことに気づきました。
- ReactのAPIだけでここまで作り込める様になりつつあるという点にワクワクしました。
ただ...
Server Actions自体はReact Server Componentsがサポートされている環境でしか動作しないため、現状では選択肢がNext.jsにロックインされている事も事実です。
が、Remixが表明した様に、今後RSCのサポートが普及すれば、今回紹介した様な機能は環境をより選ばずに、ReactだけのAPIで表現出来る様になると言う事でもあると考えています。
ちなみに、、、、、、、、
今回ご紹介した実装例ですが、おそらく執筆時点(2023/12)のReact、Nextのバージョンだとまともに動きません🤯💣🤯
下記をご覧ください。
上記の様な挙動が頻発します。
調べてみるとどうやら既知の問題で、useOptimisticのバグの様でした。
この辺りは、canaryのバージョンを利用している事もあって致し方ないかなと感じています。
本記事の内容を参考に、プロダクトへの機能導入をご検討いただく場合は、慎重にご検討頂く様お願い致します🙇
【参考】今回利用したライブラリのバージョン
{
"next": "^14.0.4",
"react": "18.3.0-canary-3e00e58a6-20231217",
"react-dom": "18.3.0-canary-3e00e58a6-20231217"
}
最後に
タイミーではフロントエンド始め、一緒に働くエンジニアを積極的に募集中です。
もしご興味を持って頂けましたら、下記をご確認頂けますと幸いです。
それではまた別の記事で!
Discussion