📱

Server ActionsとuseOptimisticフル活用で作るAIチャット

2023/12/22に公開

タイミーでフロントエンドエンジニアをしているnisshi-です。

本記事はTimee Advent Calendar 2023 シリーズ1の22日目の記事になります。

概要

Server Actionsは、文字通りクライアントサイドからサーバーサイドのロジックが直接実行出来るインターフェースです。
Next.js 14アップデートの発表時には、下記の実装例を紹介して話題になりました。

例の様にuse serverディレクティブを用いたサーバーサイドの処理が良く取り上げられますが、Server Actions自体はクライアントサイドでも実行する事が可能です。

今回はクライアントサイドで、Server ActionsuseOptimisticフック、更にチャット相手にOpenAI(gpt)を選定し、それらを用いた対話的なチャットアプリを作る例をご紹介します。


こんな見た目

チャットを送信するactionsを作る

チャット相手はOpenAIです。
OpenAIにチャットを送る処理をactionsに書きます。

src/chat/index.ts
'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をクライアントコンポーネントから呼び出します。

src/components/chat/index.tsx
'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です。
EnhancedChatCompletionMessageParam
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が更新された後、暫定的に表示されていた値、今回のケースだとsendingtrueに設定されたオブジェクトがマージされて無くなり、最新のリストが表示され直します。

一覧表示

<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 or otherのスタイリングを判別しています。

フォーム入力部分

<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"
}

最後に

タイミーではフロントエンド始め、一緒に働くエンジニアを積極的に募集中です。

もしご興味を持って頂けましたら、下記をご確認頂けますと幸いです。
https://timee.notion.site/timee/Timee-Product-Org-Entrance-Book-b7380eb4f6954e29b2664fe6f5e775f9

それではまた別の記事で!

Discussion