アプリ開発制作物

RECIPE AIをNext.js + AI SDKでリプレイスしました

はじめに

「RECIPE AI」は2024年の春合宿にメンバーで作った、冷蔵庫の写真からレシピを生成してくれるウェブアプリケーションです。

このアプリはVueを使って開発しましたが、今回は1年ぶりにこれをNext.jsとAI SDKでリプレイスを行いました。

リプレイスの動機

GPTをサーバーサイドで動かす

  • このアプリは内部的にOpenAIのGPTが使われていますが、現状生成処理はフロントエンド側で行っているため、悪意のあるユーザーにOpenAIのAPIキーがバレて悪用されてしまいます。
  • そのため、Next.jsのServer Actionsを使ってGPTの生成をサーバーサイドで動かします。
  • gpt-4vからgpt-4oへ

  • 以前はgpt-4vというマルチモーダルモデルを使っていましたが、生成に時間がかるという問題がありました。しかし、gpt-4oの登場により生成時間が改善されたため切り替えることにしました。
  • 使用技術

    技術説明
    Next.js(App Router)使用するフレームワークです。
    mantine UIUIライブラリです。コンポーネントやHooksが豊富にあり便利な印象があります。
    package by feature弊社で使われているデザインパターンです。個人的にも気に入っています。
    AI SDK内部でGPTを使うのでVercelが出しているAIライブラリを使います。
    JotaiGPTで生成したデータを管理するために使用します。
    zod今回はGPTのFunction Callingのスキーマ定義に使います。
    Amplify Authログイン機能の実装に挑戦しました。

    ディレクトリ構成

    ディレクトリの構成はほぼ以下の記事と同じような構成となっています。 https://zenn.dev/sonicmoov/articles/e5ce7fb6d42267

    Server ActionsでGPTを呼び出してレシピを生成する

    Blog image

    選択された食材からレシピを生成する部分の実装を紹介します。

    レシピ生成の要点

    GPTに選択した食材の名前の一覧を渡して、以下のレシピ情報を5つ生成させます。

  • タイトル
  • 簡単な説明文(1文字以上16文字以下)
  • 難易度(1〜3段階で2個ずつ、最後の1つは難易度3で作成します。)
  • 調理時間(分単位)
  • カロリー(数値 kcal)
  • 実装

    Next.jsのServer ActionsとAI SDKを使ってレシピ生成をします。Server Actionsを使用する目的としてはAPIキーを隠すためです。私はこのような場合以前まではBFF環境を用意していましたが、Server Actionsを使うとその必要がないので便利だと思いました。

    src/app/actions/completions.tsを作成し、generateRecipeというServer Actionsを実装します。 messagesには食材の一覧と指示を入力します。

    typescript
    'user server';
    import { QueryMessage } from '@/types/QueryMessage';
    import { openai } from '@ai-sdk/openai';
    import { generateText as chatCompletion, tool } from 'ai';
    import { z } from 'zod';
    
    export const generateRecipe = async (foods: string[]) => {
      const messages: QueryMessage = [
        {
          role: 'user',
          content: [
            {
              type: 'text',
              text: `あなたは熟練したシェフです。食材一覧:[${foods.join(',')}]。提供された食材の中で作れるレシピを5つ教えてください。
              誰でも作ることができる一般的な料理のレシピを教えてください。
              食材一覧にない食材もやむを得ない場合は使用することも可能です。
              ただし、架空のレシピは考えないでください。
              各レシピには1文字以上16文字以下のレシピの簡単な説明文、完成までのおおよその時間、難易度、カロリーを付けてください。
              難易度が1のレシピを2個、難易度が2のレシピを2個、難易度が3のレシピを1個教えてください。
              カロリーは数字表記のみで教えてください。最後に'setRecipe'ツールを呼び出して完了です。
              この条件に従わないとあなたに悪いことが起きます。`,
            },
          ],
        },
      ];
    
      const tools = {
        setRecipe: tool({
          parameters: z.object({
            recipeList: z.array(
              z.object({
                title: z.string().describe('レシピ名'),
                time: z.number().describe('レシピの所要調理時間(分)'),
                kcal: z.number().describe('レシピの想定カロリー数'),
                difficulty: z.number().describe('レシピの難易度1-3'),
                catchcopy: z.string().describe('キャッチコピー'),
              }),
            ),
          }),
        }),
      };
    
      const result = await chatCompletion({
        model: openai('gpt-4o'),
        messages,
        tools,
        maxTokens: 1000,
      });
    
      return {
        message: result.response.messages[0].content,
        toolCalls: result.toolCalls,
      };
    };
    

    出力ではレシピ生成の要点で挙げた5つのレシピ情報をjson形式で生成して欲しいので、Function Callingを使います。AI SDKではtoolsで使用できます。toolsはzodスキーマ定義で出力したいパラメータを表現していきます。 最後に、AI SDKのgenerateTextを使って生成を行います。

    生成後のデータをJotaiで管理する

    GPTで生成したデータはページ間で共有したいので、状態管理ライブラリのJotaiを使います。 以下は写真から検出した食材を管理する部分です。 管理する状態ごとにhooksを作成して簡単に呼び出せるようにしています。

    typescript
    import { Recipe } from '@/types/Recipe';
    import { atom, useAtom } from 'jotai';
    
    const recipeStateAtom = atom<Recipe>([]);
    
    export const useRecipeState = () => {
      const [recipes, setRecipes] = useAtom<Recipe>(recipeStateAtom);
      return {
        recipes,
        setRecipes,
      };
    };