ChatGPTを使ってサマーウォーズの自動翻訳機能を実現する
完成形のイメージは2009年公開の細田守監督作品『サマーウォーズ』で舞台となった仮想世界"OZ"の中で世界中の人々とのコミュニケーションを可能にした「自動翻訳機能」
ChatGPTに聞いたら出来るって言うのでたぶん出来るだろう。
いきなり仮想世界を作るわけにはいかないので今回はSupabaseでチャットアプリを作成してそこに自動翻訳機能を追加して世界中の人とコミュニケーションが出来るようにする。
Supabaseでのチャットアプリには下記の記事を参考にする。
環境構築
とりあえず環境構築から
このタイミングで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
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で型定義を自動生成してくれそうな予感がしたので一旦仮で型ファイル作っておく
export type Database = {};
認証機能
認証はどうしようかなー。最初はEmailでやって後からGoogleとかOTPとか追加するか。
認証関連のカスタムhooks
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はほとんど一緒なので省略
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 コンポーネント
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認証はデフォルトではサインアップしてもサインされない。
メール確認するとサインインされる。良いね。
DB設計
もちろんChatGPTに考えてもらう。
結構いい感じに答えてくれてるけど、今回は1つのメッセージに対して複数の言語で翻訳されることを想定してるので追加質問。
良さそう。最後にER図をPlantUMLで確認できるように書き出してもらう。
細かな修正は必要だったけど、一旦こんな感じ。
(DB設計は初心者だしPlantUMLも使い慣れてないから合ってるかはわからん)
これらをsupabaseに登録していく。GUIでめちゃ簡単。
(後から気づくがsupabase CLIでローカルで作ってから本番反映のほうが良かったかも)
(さらにここでChatGPTにpostgreSQLを吐かせてsupabaseで走らせればもっと早かったと思った)
roomsテーブルにprivateカラムを作って非公開ルームと公開ルームを管理すると良さそう
supabase CLI
ここでsupabase CLIを触っておく。
Dockerでローカルにsupabaseを立ち上げて開発環境のDBを作ることができそう。
ついでに型生成もしてくれる。
やること
- Docker環境構築
- MacならDocker Desktopを入れるだけ
- WindowsならWSL入れてDocker Desktop入れる
- supabase CLI インストール
brew install supabase/top/supabase
- https://supabase.com/docs/guides/cli
- supabaseにログイン
supabase login
- アクセストークンが要求される
- 初期化
supabase init
- ローカルのsupabase 起動 / 停止
-
supabase start
/supabase stop
- dockerが立ち上がってる状態で
-
- ローカルとリモートの接続
supabase link --project-ref [project-id] -p [Database Password]
- あとはマイグレーションファイル作っていい感じにする
- ここが一番ややこしかった
詳しくまとめられたQiita記事
User情報を取得
SignUpしたときにUsersテーブルにinsertして登録しておく。
// 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情報を取得できるようにする
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を動かすほうが良い気がしてるが…)
// 省略
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を全て取得する。
// 省略
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するやり方で取得して表示するまでルーム一覧が表示されるのを確認する。
// 省略
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
と一致するメッセージをすべて取得する
// 省略
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に。
// 省略
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されたデータを監視する。
// 省略
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に入れておく。
// 省略
useEffect(() => {
if (!roomId) return;
fetchMessages();
observeMessages();
}, [roomId]);
チャットページ
Room一覧ページからroom_id
をクエリに持って遷移してくるチャットページ
この辺は後でかなり修正すると思うので雑な実装でおいとく
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で