Hono x JSXで雑ChatGPTもどきを作る
もうChat GPTなしの生活は耐えられないが、たまにしか使わないのに定額料金はしんどい、、
↓
じゃあOpenAIのAPIを直接呼ぶアプリを自作すれば安く済ませられるのでは?
↓
でもそれだけのために、SPA x JSON API のような構成を作るのは面倒、、、
↓
Hono x JSXならフロントもバックエンドも簡単に実装して月々のGPT料金を抑えられそう!!
ということでオレオレgptクライアントを実装したのでメモ。
※デプロイはまだできていません。
実装
公式の手順に従ってスキャフォールドを作成。
※環境はcloudflare-workers
を選択
JSXを使えるようにtsconfig.json
をいじる。
ルーティング & JSXで画面表示
index.ts
をindex.tsx
にリネーム。
共通レイアウトの作成。
export const Layout: FC = ({ children }) => (
<html lang={"ja"}>
<body>{children}</body>
</html>
);
共通レイアウト描画用のミドルウェアを定義。
export const LayoutMiddleware = jsxRenderer(Layout);
トップ画面用のレイアウトを定義。
export const Top: FC = () => (
<>
<h1>gpt-web-client</h1>
<a href={"/chats"}>Chats</a>
</>
);
ルーティングして画面にHTMLを返す。
const app = new Hono<AppEnv>();
app.use(LayoutMiddleware);
app.get("/", (c) => c.render(<Top />));
Yay!!
DBとテーブルの用意
今回はCloudflare D1 x Drizzle ORMを使う。
下記に従ってD1を用意。(今回はlocal環境のみで使います)
下記に従って、Drizzle ORMをインストール。
wrangler.toml
にmigrations_dir
を追加。
[[d1_databases]]
binding = "DB"
database_name = "gpt-web-client"
database_id = "xxxxxx"
migrations_dir = "drizzle/" # <- ここを追加
設定ファイルを追加。
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/schema.ts",
out: "./drizzle"
})
適当なスキーマ定義を作成。
export const Rooms = sqliteTable("Rooms", {
roomId: text("roomId").primaryKey(),
roomTitle: text("roomTitle"),
...
マイグレーションファイルを出力。
npx drizzle-kit generate:sqlite --schema=src/schema.ts
ローカルにマイグレーションを実行。
npx wrangler d1 migrations apply gpt-web-client --local
ロジックで適当なデータを取得。
app.get("/chats", async (c) => {
const db = drizzle(c.env.DB);
const rooms = await db
.select()
.from(Rooms)
.orderBy(desc(Rooms.roomUpdated))
.all();
return c.render(<RoomList props={{ rooms }} />);
});
Yay!!
OpenAI APIをつなぎこむ
OpenAI APIクライアントをnewするMiddlewareを定義する。
export const OpenaiMiddleware = createMiddleware<AppEnv>(async (c, next) => {
const client = new OpenAI({
apiKey: c.env.OPENAI_API_KEY,
});
c.set("openai", client);
await next();
});
テストでモックし易いように、APIを叩くコードを関数にまとめる。
export const fetchCompletion = async (
openai: OpenAI,
newMessage: string,
messageHistory?: ChatCompletionMessageParam[],
) =>
openai.chat.completions.create({
messages: [
...(messageHistory ?? []),
{ role: "user", content: newMessage },
],
model: "xxx",
});
リクエストハンドラでAPIを叩く関数を呼び出す。
const completion = await fetchCompletion(
c.var.openai,
newMessage,
messageHistory,
);
Yay!!
テストを書く
APIテスト?も簡単にかける。
今回はユーザーが質問を投げるとリダイレクトされることをテストする。
切り出したDBアクセスの関数をモック。
mockGetRoom.mockResolvedValue({
roomId: "test-room-id",
roomTitle: "Test Room",
roomCreated: "2021-01-01T00:00:00Z",
roomUpdated: "2021-01-02T00:00:00Z",
});
POSTリクエストを送る。
const res = await app.request(
`/chats/test-room-id`,
{
method: "POST",
body: new URLSearchParams({ message: "Hello, World!" }),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: "Basic dGVzdDp0ZXN0",
},
},
MOCK_BINDINGS,
);
リダイレクトされることをテストする。
expect(res.status).toBe(302);
expect(res.headers.get("Location")).toBe(`/chats/test-room-id`);
Yay!!
実装した所感
良かった点
4年前にExpressで簡単なアプリを作ったが、それに比べてHonoの開発者体験は格段に良い。(TSの型サポート、環境構築、デプロイの容易さなど)
また新興のFWだが、Cloudflare D1などエコシステムのサポートが手厚く、簡単なアプリなら特に苦も無く作れてしまった。
更に、JSXフレンドリーな点もポイントが高く、筆者が書いてきたLaravelのBladeのようなテンプレートエンジンよりも、型サポートやReactで慣れている分、JSXの方が書きやすいと感じた。
ちょっと迷った点
index.tsxが肥大化してしまっているので、どう分割するのがベストプラクティスなんだろう?
(公式のサンプルはまとまったルートごとにファイルを分割しているが、Laravelのようにrouteファイルがあって、コントローラーにリクエストハンドラーを書くような形式でやってみたかった)
なんかいい例あったら教えて下さい。
まとめ
バリデーションやエラーハンドリングなどなく、かなり雑だが、Hono x JSXならHTMLを返すアプリを一瞬で実装できてしまった。
今後は実装したアプリをHonoXに移行して、目玉機能のファイルベースルーティングを試してみたい。
最後に全体のソースコードはこちら。
以上。
Discussion