Open14

ChatGPTを使ってサマーウォーズの自動翻訳機能を実現する

あるぱかあるぱか

完成形のイメージは2009年公開の細田守監督作品『サマーウォーズ』で舞台となった仮想世界"OZ"の中で世界中の人々とのコミュニケーションを可能にした「自動翻訳機能」

ChatGPTに聞いたら出来るって言うのでたぶん出来るだろう。

あるぱかあるぱか

いきなり仮想世界を作るわけにはいかないので今回はSupabaseでチャットアプリを作成してそこに自動翻訳機能を追加して世界中の人とコミュニケーションが出来るようにする。

Supabaseでのチャットアプリには下記の記事を参考にする。
https://zenn.dev/chot/articles/ddd2844ad3ae61

あるぱかあるぱか

環境構築

とりあえず環境構築から
このタイミングでVercelでデプロイもしておく

// yarn
yarn create next-app auto-translate-chat-app --typescript

// npm
npx create-next-app auto-translate-chat-app --typescript

supabaseもインストールしてクライアント初期化しておく

// yarn
yarn add @supabase/supabase-js

// npm
npm install @supabase/supabase-js
src/lib/supabase.ts
import { createClient } from "@supabase/supabase-js";
import { Database } from "@/types/supabase.types";

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey);

supabaseはCLIで型定義を自動生成してくれそうな予感がしたので一旦仮で型ファイル作っておく

src/types/supabase.types.ts
export type Database = {};
あるぱかあるぱか

認証機能

認証はどうしようかなー。最初はEmailでやって後からGoogleとかOTPとか追加するか。

認証関連のカスタムhooks
src/hooks/useAuth.ts
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import { Session } from "@supabase/supabase-js";

export const useAuth = () => {
  const [session, setSession] = useState<Session | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const { data: authListener } = supabase.auth.onAuthStateChange((_, session) => {
      setSession(session);
    });

    return () => authListener.subscription.unsubscribe();
  }, []);

  // Email signUp
  const signUp = async (email: string, password: string) => {
    const { data, error } = await supabase.auth.signUp({ email, password });
    if (error) {
      setError(error);
    }
    return data;
  };

  // Email signIn
  const signIn = async (email: string, password: string) => {
    const { data, error } = await supabase.auth.signInWithPassword({ email, password });
    if (error) {
      setError(error);
    }
    return data;
  };

  // Sign out
  const signOut = async () => {
    const { error } = await supabase.auth.signOut();
    if (error) {
      setError(error);
    }
  };

  return { session, error, signUp, signIn, signOut };
};
SignUp コンポーネント

SignInはほとんど一緒なので省略

src/components/SignUp.tsx
import { useState } from "react";
import { useAuth } from "@/hooks/useAuth";

export const SignUp = () => {
  const [value, setValue] = useState({ email: "", password: "" });
  const { signUp, error } = useAuth();

  const { email, password } = value;
  return (
    <>
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setValue((prev) => ({ ...prev, email: e.target.value }))}
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setValue((prev) => ({ ...prev, password: e.target.value }))}
      />
      <button onClick={() => signUp(email, password)}>Sign up</button>
      {error && <p>{error.message}</p>}
    </>
  );
};
SignOut コンポーネント
src/components/SignOut.tsx
import { FC } from "react";
import { useAuth } from "@/hooks/useAuth";

type SignOutProps = {};

export const SignOut: FC<SignOutProps> = () => {
  const { signOut } = useAuth();
  return <button onClick={signOut}>Sign out</button>;
};

supabaseのEmail認証はデフォルトではサインアップしてもサインされない。
メール確認するとサインインされる。良いね。
https://supabase.com/docs/reference/javascript/auth-signup

あるぱかあるぱか

DB設計

もちろんChatGPTに考えてもらう。

結構いい感じに答えてくれてるけど、今回は1つのメッセージに対して複数の言語で翻訳されることを想定してるので追加質問。

良さそう。最後にER図をPlantUMLで確認できるように書き出してもらう。

細かな修正は必要だったけど、一旦こんな感じ。
(DB設計は初心者だしPlantUMLも使い慣れてないから合ってるかはわからん)

これらをsupabaseに登録していく。GUIでめちゃ簡単。
(後から気づくがsupabase CLIでローカルで作ってから本番反映のほうが良かったかも)
(さらにここでChatGPTにpostgreSQLを吐かせてsupabaseで走らせればもっと早かったと思った)

あるぱかあるぱか

supabase CLI

ここでsupabase CLIを触っておく。
Dockerでローカルにsupabaseを立ち上げて開発環境のDBを作ることができそう。

ついでに型生成もしてくれる。
https://supabase.com/docs/reference/javascript/typescript-support#generating-types

やること

  • Docker環境構築
    • MacならDocker Desktopを入れるだけ
    • WindowsならWSL入れてDocker Desktop入れる
  • supabase CLI インストール
  • supabaseにログイン
    • supabase login
    • アクセストークンが要求される
  • 初期化
    • supabase init
  • ローカルのsupabase 起動 / 停止
    • supabase start / supabase stop
    • dockerが立ち上がってる状態で
  • ローカルとリモートの接続
    • supabase link --project-ref [project-id] -p [Database Password]
  • あとはマイグレーションファイル作っていい感じにする
    • ここが一番ややこしかった

詳しくまとめられたQiita記事
https://qiita.com/masakinihirota/items/685f70770d8224ba2fa5

あるぱかあるぱか

User情報を取得

SignUpしたときにUsersテーブルにinsertして登録しておく。

src/hooks/useAuth.tsx
  // Email signUp
  const signUp = async (email: string, password: string) => {
    const { data, error } = await supabase.auth.signUp({ email, password });
    if (error) {
      setError(error);
    }
+   await supabase.from(USERS_TABLE).insert([{ id: data.user.id, name: "guest", language: "ja" }]);
    return data;
  };

User情報を取得できるようにする

src/hooks/useUser.tsx
import { useCallback, useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import { useAuth } from "@/hooks/useAuth";
import { Database } from "@/types/database.types";

type User = Database["public"]["Tables"]["users"]["Row"];

export const useUser = () => {
  const [user, setUser] = useState<User | null>(null);
  const { session } = useAuth();
  const sessionUser = session?.user;

  const fetchUser = useCallback(async () => {
    if (!sessionUser) return;
    const { data, error } = await supabase
      .from("users")
      .select("*")
      .eq("id", sessionUser.id)
      .single();

    if (error) {
      throw error;
    }
    setUser(data);

    return data;
  }, [sessionUser]);

  useEffect(() => {
    try {
      fetchUser();
    } catch (error) {
      console.error(error);
    }
  }, [fetchUser]);

  return { user, fetchUser };
};

ここ気持ちいい〜〜〜
supabase CLIで自動生成した型からこうやって取ってこれるのめっちゃ良き

type User = Database["public"]["Tables"]["users"]["Row"];
あるぱかあるぱか

ローカルからDBマイグレーションをする

実装していてDBのtypoに気づいてしまった…
migrationして修正する。

ローカルのDBを立ち上げて

supabase start

http://localhost:54323/からGUIで修正して

diffコマンドでサーバーとローカルの差分をsupabase/migrationsに出力して

supabase db diff -f [ファイル名]

差分をサーバーにpushで修正完了

supabase db push -p [Database Password]

連携できてたらこれだけでもOKなはず

supabase db push
あるぱかあるぱか

Roomを作成

いろいろ試行錯誤した結果、とりあえずroomsテーブルとuser_roomsテーブルにそれぞれinsertすることにする。
(ホントはsupabaseのトリガー機能を使ってroomsテーブルが作られたときにuser_roomsテーブルにもinsertするfunctionを動かすほうが良い気がしてるが…)

src/hooks/useRooms.ts
// 省略

const createRoom = async (name: Room["name"]) => {
    if (!user || !name) return;
    const { data, error } = await supabase
      .from(ROOM_TABLE)
      .insert({ name, create_user_id: user.id })
      .select();
    if (error) {
      throw error;
    }
    if (!data) return;
    const userRoom = { user_id: user.id, room_id: data[0].id };
    const { error: userRoomError } = await supabase.from(USER_ROOM_TABLE).insert([userRoom]);
    if (userRoomError) {
      throw userRoomError;
    }
    return data;
  };

Room一覧を取得

user_roomsテーブルからuser_idに一致するroomを全て取得する。

src/hooks/useRooms.ts
// 省略

  const fetchUserRooms = async (userId: string) => {
    const { data, error } = await supabase
      .from(USER_ROOM_TABLE)
      .select("id, rooms (id, name, create_user_id)")
      .eq("user_id", userId);

    if (error) {
      throw error;
    }
    return data;
  };
const { data, error } = await supabase
      .from(USER_ROOM_TABLE)
      .select("id, rooms (id, name, create_user_id)")
      .eq("user_id", userId);

selectで"id, rooms (id, name, create_user_id)"を指定することで
外部キー連携しているroom_idからroomsテーブルを参照してid name create_user_idを取ってきている。

あるぱかあるぱか

Room一覧ページ

とりあえず古のuseEffect内でfetchするやり方で取得して表示するまでルーム一覧が表示されるのを確認する。

src/pages/room/index.tsx
// 省略

  useEffect(() => {
    (async () => {
      if (!user?.id) return;
      const data = await fetchUserRooms(user.id);
      if (data) {
        setRooms(data.map((value) => value.rooms) as Rooms); // FIXME: 型安全じゃないのどうにかしたい
      }
    })();
  }, [user?.id]);

// 省略

    <>
      <h1>Rooms</h1>
      <button onClick={() => createRoom("test")}>Create room</button>
      <p>{user?.name}が参加しているROOM</p>
      {rooms.map((room) => (
        <Link href={`/room/${room.id}`} key={room.id}>
          <p>{room.name}</p>
        </Link>
      ))}
    </>
あるぱかあるぱか

Messagesを取得

ここでroom_idと一致するメッセージをすべて取得する

src/hooks/useMessage.ts
// 省略

  const fetchMessages = async (roomId: Message["room_id"]) => {
    const { data, error } = await supabase
      .from("messages")
      .select("*")
      .eq("room_id", roomId)
      .order("created_at", { ascending: true });

    if (error) {
      throw error;
    }
    setMessages(data);
    return data;
  };

Messageを投稿

メッセージの投稿に必要なデータを渡してinsertする。
入力のバリデーション等はまた後で実装するので一旦NonNullableに。

src/hooks/useMessage.ts
// 省略

const postMessage = async (
    roomId: NonNullable<Message["room_id"]>,
    message: NonNullable<Message["message"]>
  ) => {
    const { data, error } = await supabase.from(MESSAGE_TABLE).insert([
      {
        user_id: user?.id,
        room_id: roomId,
        message,
        language: user?.language,
      },
    ]);

    if (error) {
      throw error;
    }
    return data;
  };

Messagesテーブルの監視

supabaseのリアルタイム機能を使って新しくinsertされたデータを監視する。

src/hooks/useMessage.ts
// 省略

  const observeMessages = async (roomId: Message["room_id"]) => {
    try {
      supabase
        .channel("messages:room_id=eq." + roomId)
        .on(
          "postgres_changes", // 固定
          {
            event: "*",
            schema: "public",
            table: MESSAGE_TABLE,
          },
          (payload) => {
            if (payload.eventType === "INSERT") {
              console.log("payload: ", payload);
              setMessages((prev) => [...prev, payload.new as Message]);
            }
          }
        )
        .subscribe();

      return () => supabase.channel("messages:room_id=eq." + roomId).unsubscribe();
    } catch (error) {
      console.error(error);
    }
  };

これらはカスタムhook内でuseEffectに入れておく。

src/hooks/useMessage.ts
// 省略

  useEffect(() => {
    if (!roomId) return;
    fetchMessages();
    observeMessages();
  }, [roomId]);
あるぱかあるぱか

チャットページ

Room一覧ページからroom_idをクエリに持って遷移してくるチャットページ
この辺は後でかなり修正すると思うので雑な実装でおいとく

src/pages/room/[roomId].tsx
const ChatPage: NextPage<ChatPageProps> = () => {
  const [room, setRoom] = useState<Room | null>(null);
  const [value, setValue] = useState<string>("");
  const router = useRouter();
  const { roomId } = router.query;
  const { fetchRoom } = useRooms();
  const { messages, postMessage } = useMessages(roomId as string);

  useEffect(() => {
    if (!roomId) return;
    (async () => {
      const data = await fetchRoom(roomId as string);
      if (!data) return;
      setRoom(data[0]);
    })();
  }, [roomId]);

  const handleSendMessage = () => {
    if (!roomId) return;
    postMessage(roomId as string, value);
    setValue("");
  };

  return (
    <>
      <h2>{room?.name}</h2>
      <p>room ID : {roomId}</p>
      <ul>
        {messages?.map((message) => (
          <li key={message.id}>{message.message}</li>
        ))}
      </ul>

      <form onSubmit={(e) => e.preventDefault()}>
        <input type="text" value={value} onChange={(e) => setValue(e.target.value)} />
        <button onClick={() => handleSendMessage()}>Send</button>
      </form>
    </>
  );
};
あるぱかあるぱか

一旦自動翻訳できるところまで実装できたので
コンポーネント設計も見直しながら本実装に入る

せっかくだしApp routerで