🚧

Hono x JSXで雑ChatGPTもどきを作る

2024/03/03に公開

もうChat GPTなしの生活は耐えられないが、たまにしか使わないのに定額料金はしんどい、、

じゃあOpenAIのAPIを直接呼ぶアプリを自作すれば安く済ませられるのでは?

でもそれだけのために、SPA x JSON API のような構成を作るのは面倒、、、

Hono x JSXならフロントもバックエンドも簡単に実装して月々のGPT料金を抑えられそう!!

ということでオレオレgptクライアントを実装したのでメモ。
※デプロイはまだできていません。

実装

公式の手順に従ってスキャフォールドを作成。
※環境はcloudflare-workersを選択

https://hono.dev/getting-started/basic#starter

JSXを使えるようにtsconfig.jsonをいじる。

https://hono.dev/guides/jsx#settings

ルーティング & JSXで画面表示

index.tsindex.tsxにリネーム。

共通レイアウトの作成。

Layout.tsx
export const Layout: FC = ({ children }) => (
  <html lang={"ja"}>
    <body>{children}</body>
  </html>
);

共通レイアウト描画用のミドルウェアを定義。

layout.ts
export const LayoutMiddleware = jsxRenderer(Layout);

トップ画面用のレイアウトを定義。

Top.tsx
export const Top: FC = () => (
  <>
    <h1>gpt-web-client</h1>
    <a href={"/chats"}>Chats</a>
  </>
);

ルーティングして画面にHTMLを返す。

index.tsx
const app = new Hono<AppEnv>();

app.use(LayoutMiddleware);

app.get("/", (c) => c.render(<Top />));

Yay!!

DBとテーブルの用意

今回はCloudflare D1 x Drizzle ORMを使う。

下記に従ってD1を用意。(今回はlocal環境のみで使います)

https://developers.cloudflare.com/d1/get-started/

下記に従って、Drizzle ORMをインストール。

https://orm.drizzle.team/docs/get-started-sqlite#cloudflare-d1

wrangler.tomlmigrations_dirを追加。

[[d1_databases]]
binding = "DB" 
database_name = "gpt-web-client"
database_id = "xxxxxx"
migrations_dir = "drizzle/" # <- ここを追加

設定ファイルを追加。

drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
    schema: "./src/schema.ts",
    out: "./drizzle"
})

適当なスキーマ定義を作成。

schema.ts
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

ロジックで適当なデータを取得。

index.tsx
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を定義する。

openai.ts
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を叩くコードを関数にまとめる。

openai-client.ts
export const fetchCompletion = async (
  openai: OpenAI,
  newMessage: string,
  messageHistory?: ChatCompletionMessageParam[],
) =>
  openai.chat.completions.create({
    messages: [
      ...(messageHistory ?? []),
      { role: "user", content: newMessage },
    ],
    model: "xxx",
  });

リクエストハンドラでAPIを叩く関数を呼び出す。

index.tsx
  const completion = await fetchCompletion(
    c.var.openai,
    newMessage,
    messageHistory,
  );

Yay!!

テストを書く

APIテスト?も簡単にかける。

今回はユーザーが質問を投げるとリダイレクトされることをテストする。

切り出したDBアクセスの関数をモック。

index.spec.ts
    mockGetRoom.mockResolvedValue({
      roomId: "test-room-id",
      roomTitle: "Test Room",
      roomCreated: "2021-01-01T00:00:00Z",
      roomUpdated: "2021-01-02T00:00:00Z",
    });

POSTリクエストを送る。

index.spec.ts
    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,
    );

リダイレクトされることをテストする。

index.spec.ts
    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に移行して、目玉機能のファイルベースルーティングを試してみたい。

最後に全体のソースコードはこちら。

https://github.com/yasuaki640/gpt-web-client

以上。

GitHubで編集を提案

Discussion