🧑‍🍳

Gemini 1.5 FlashとNextjsで作る料理アドバイスWebアプリ

2025/02/14に公開

課題

料理をしていると悩みを感じる時があります。

  • 「同じ料理が多くなってしまう」
  • 「栄養が偏ってないか心配になる」
  • 「昔作ったレシピが思い出せない」

目標

こんな機能を考えました。

  • 料理写真&レシピを管理できる
  • 5大栄養素の割合を表示できる
  • 栄養バランスからおすすめ料理を提案できる

UX

  • 直感的に操作できる
  • ボタン操作数が少なくすむ
  • AIによって登録作業の手間を省くことができる

結果を先に

「ここをクリック」から料理画像をアップロードします

アップロード済みの画像をクリックするとモーダルが開きます

自分で食材を入力することもできますが、
AI食材分析ボタンを押下することでGeminiが食材を予測して入力してくれます

栄養バランス(AI)のタブを押すと食材と量から栄養バランスとアドバイスを分析します

Webサイト

https://recipii.vercel.app/

技術選定

項目 対象
Javascriptランタイム bun 1.2.2
フロントエンドフレームワーク nextjs 15.1.5
CSSフレームワーク tailwind 3.4.1
コンポーネント shadcn
バックエンドフレームワーク hono
ORM drizzle
DB neon
webホスティング vercel
AI(Webデザイン) v0
食材予測、栄養予測 Gemini 1.5 Flash
画像ファイル置き場 AWS S3

アーキテクチャ

フロントエンドはNextjsとし、APIRouteをHonoで作ります。
Honoは標準でRPCとZodを扱えて、型安全にフロントエンド↔️バックエンドとのやり取りができるため採用しました。
ファイル置き場は大きな容量のファイルでも署名付きでアップロードでき、低コストなS3
を採用しました。DBについては無料で使いやすいNeonを採用しました。

Nextjs+honoのサーバー側

まずはAPIRouteですべてのルートへのリクエストを受け取れるように
https://nextjs-ja-translation-docs.vercel.app/docs/api-routes/dynamic-api-routes#オプショナルにすべてのapiルートをキャッチ

こちらのパスにroute.tsファイルを作成します。

/src/app/api/[[...route]]/route.ts

最後にGETなどでexportしてあげればOKです。
※POSTを作った場合はPOSTのexportも必要

import { Hono } from 'hono';
import { handle } from 'hono/vercel';

const api = new Hono()
  .get(
    '/api/test',
    async (c) => {
      return c.json({hello:"world"});
    },
  )

export type AppType = typeof api;

// 返したいメソッド分こちらを定義する
export const GET = handle(api);

Nextjs+honoのクライアント側

import { type AppType } from '@/app/api/[[...route]]/route';
import { hc } from 'hono/client';
//デプロイ時はURLを適切に変える
export const client = hc<AppType>('http://localhost:3000');

export const getTest = async () => {
  const res = await client.api.test.$get();
  const data = await res.json();
  return data
};

料理画像をアップロードする

署名付URLでS3にアップロードするには、URL発行することと、発行されたURLを
使って実際にアップロードする2段階の手順が必要となります。

APIRouteに「S3クライアントにて署名付URLを発行するAPI」を定義します。
また、S3クライアントオブジェクトは、複数のAPIパスで共通して利用したいものなので
middlewareでS3クライアントを定義してContextから渡ってきたオブジェクトを利用できるようにします。

import {
  PutObjectCommand,
  PutObjectCommandInput,
  S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

・・・
export const s3Client = new S3Client({
  region: 'ap-northeast-1',
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
  },
});

type MiddlewareEnv = {
  Variables: {
    s3Client: S3Client;
  };
};
const middleware = createMiddleware<MiddlewareEnv>(
  async (c: Context, next: Next) => {
    c.set('s3Client', s3Client);
    await next();
  },
);

const api = new Hono()
  .basePath('/api')
  .use(middleware)
  .post(
    '/food/s3put',
    zValidator(
      'json',
      z.object({
        name: z.string(), // 画像のファイル名
        type: z.string(), // 画像のContent-type
        yyyymmdd: z.string(), //保存するS3のディレクトリ
      }),
    ),
    async (c) => {
      const client = c.var.s3Client;
      const { yyyymmdd, name, type } = c.req.valid('json');
      // yyyymmdd/name にアップロードするように署名付URLを発行します
      const params: PutObjectCommandInput = {
        Bucket: 'バケット名',
        Key: yyyymmdd + '/' + name,
        ContentType: type,
      };
      const command = new PutObjectCommand(params);
      const url = await getSignedUrl(client, command, { expiresIn: 3600 });
      return c.json(url);
    },
  )
・・・

クライアントはこんな感じで、
署名付URLを発行できたらアップロードするためのFetchを実行します。

import { type AppType } from '@/app/api/[[...route]]/route';
import { hc } from 'hono/client';
export const client = hc<AppType>('http://localhost:3000');
export const putFood = async (file: File) => {
  const res = await client.api.food.s3put.$post({
    json: {
      name: file.name,
      type: file.type,
      yyyymmdd:"20250213",
    },
  });
  const url = await res.json();

  await fetch(url || '', {
    method: 'PUT',
    body: file,
    headers: {
      'Content-Type': file.type,
    },
  });
};

CORS

署名付URLへのリクエストをする場合、S3のドメイン、Webアプリのドメインと異なるクロスオリジンとなるため以下の設定が必要になります。
AWSマネジメントコンソールから対象のS3バケットについて以下のCORS設定を追加します。

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
    "AllowedOrigins": ["http://localhost:3000"],
    "ExposeHeaders": []
  }
]

当然Hono側もCORSの対応をしなければならないので、

import { cors } from 'hono/cors';
・・・
const api = new Hono()
  .basePath('/api')
  .use(
    cors({
      origin: ['http://localhost:3000'], 
      allowHeaders: ['X-Custom-Header', 'Upgrade-Insecure-Requests'],
      allowMethods: ['POST', 'GET', 'PATCH', 'DELETE', 'OPTIONS'],
      exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
      maxAge: 600,
      credentials: true,
    }),
  )
  .use(middleware)
・・・

を追加します。

Neon(postgres)に料理情報を登録する

Neon新規登録し、プロジェクトを作成して
Dashboard > Connectボタン
から接続文字列を確認できるため控えておきます。
※環境変数DATABASE_URLに接続文字列を指定しておきます。

drizzleからDBを操作できるようにするため、まずはconfigの設定が必要です。
まず以下drizzle.config.tsをプロジェクトのルートに作成します。

import * as dotenv from 'dotenv';
import { defineConfig } from 'drizzle-kit';

dotenv.config();

export default defineConfig({
  schema: './src/lib/schema.ts',
  out: './neon/drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL || '',
  },
  verbose: true,
  strict: true,
});

package.jsonには以下のように記述しておきます。

・・・
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "drizzle:introspect": "drizzle-kit introspect --config=./drizzle.config.ts"
  },
・・・

こちらによってbun run drizzle:introspectすることで
neonに作ったテーブル定義をdrizzleのスキーマとしてローカルに連携することができます。
例えば料理情報を保存しておくテーブルが以下のように作成してある場合

CREATE TABLE food (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    yyyymmdd VARCHAR(8)
);
CREATE UNIQUE INDEX food_pkey ON food (id);

bun run drizzle:introspectすることで
/neon/drizzle/scheme.tsが以下のように出力されます

export const food = pgTable('food', {
  id: serial().primaryKey().notNull(),
  name: varchar({ length: 255 }).notNull(),
  yyyymmdd: varchar({ length: 8 }),
});

ここまでで準備ができたため、foodテーブルにInsertするコードは以下のように記述できます。


import { food } from '@/lib/scheme';
import { cors } from 'hono/cors';
import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';

const sql = neon(process.env.DATABASE_URL!);
export const dbClient = drizzle({ client: sql });

type MiddlewareEnv = {
  Variables: {
    s3Client: S3Client;
    dbClient: NeonHttpDatabase<Record<string, never>> & {
      $client: NeonQueryFunction<false, false>;
    };
  };
};

const middleware = createMiddleware<MiddlewareEnv>(
  async (c: Context, next: Next) => {
    c.set('s3Client', s3Client);
    c.set('dbClient', dbClient);
    await next();
  },
);

const api = new Hono()
  .basePath('/api')
  .use(
    cors({
      origin: ['https://recipii.vercel.app'],
      allowHeaders: ['X-Custom-Header', 'Upgrade-Insecure-Requests'],
      allowMethods: ['POST', 'GET', 'PATCH', 'DELETE', 'OPTIONS'],
      exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
      maxAge: 600,
      credentials: true,
    }),
  )
  .use(middleware)
  /**
   * foodの情報をDBに登録する
   */
  .post(
    '/food',
    zValidator(
      'json',
      z.object({
        name: z.string(),
        yyyymmdd: z.string(),
      }),
    ),
    async (c) => {
      const client = c.var.dbClient;
      const { name, yyyymmdd } = c.req.valid('json');
      const res = await client
        .insert(food)
        .values({ name, yyyymmdd })
        .returning();
      return c.json(res);
    },
  )
・・・

Geminiを使って画像パスから食材と量を分析する

Gaminiの登録もわかりやすい形になっているため省略しますが、
最低限APIキーを取得して環境変数には指定しておかなくてはなりません。

https://aistudio.google.com/apikey

結論から言うと、画像パスから食材と量を分析するメソッドはこんな感じです。
引数はアップロード済み画像のURLとするので署名付きURLなどを発行できるようにしておいてください。
↓のgenerateContentでは画像とプロンプトを指定して
const result = await model.generateContent([prompt, imagePart]);
このように食材と量を分析するように命令しています。

import { getGeminiModel } from '@/lib/gemini';
import { readImageFileBuffer } from '@/lib/picture';

// 画像を分析する関数
export async function analyzeImage(imagePath: string) {
  try {
    const model = getGeminiModel();
    const { data: base64, mimeType } = await readImageFileBuffer(imagePath);

    const prompt = `こちらに表示されている画像の中の料理で使用している食材と量をカンマ区切りで書き出してください。
    例えば出力は「食材A|Aの量g,食材B|Bの量g」だけでお願いします。
    個数などの場合でも必ずグラム(g)表記に置き換えてください。
    例えば1個⇒10gなどです。
    推測で構いません。料理じゃなかった場合は「判断できません」のみで問題ありません。`;
    const imagePart = {
      inlineData: {
        data: base64,
        mimeType: mimeType,
      },
    };

    const result = await model.generateContent([prompt, imagePart]);
    const response = await result.response;
    const items = response.text().trim().split(',');
    if (items[0] === '判断できません') {
      return [{ name: '', quantity: '' }];
    }
    const ingredients = items.map((a) => {
      return {
        name: a.split('|')[0],
        quantity: a.split('|')[1].replace('g', ''),
      };
    });
    return ingredients;
  } catch (error) {
    console.error(`画像の分析中にエラーが発生しました: ${imagePath}`, error);
    return [];
  }
}

getGeminiModelは、以下のようにAPIキーからgeminiを操作するためのSDKオブジェクトを返すようにします。

import { GoogleGenerativeAI } from '@google/generative-ai';

// 環境変数からAPIキーを取得し、検証する関数
export function getApiKey(): string {
  const apiKey = process.env.NEXT_PUBLIC_GEMINI_API_KEY || '';
  if (!apiKey) {
    console.error('NEXT_PUBLIC_GEMINI_API_KEY 環境変数が設定されていません。');
    process.exit(1);
  }
  return apiKey;
}

// Geminiモデルを初期化する関数
export function getGeminiModel() {
  const apiKey = getApiKey();
  const genAI = new GoogleGenerativeAI(apiKey);
  return genAI.getGenerativeModel({ model: 'gemini-1.5-flash-latest' });
}

readImageFileBufferは画像パスをバッファーに変換するメソッドとします。
画像URLだけでも判定してくれることもありますが、画像をプロンプトしたい場合
バッファーをInputとする方が精度が高かったです。

export async function readImageFileBuffer(
  filePath: string,
): Promise<{ data: string; mimeType: string }> {
  try {
    const res = await fetch(filePath);
    const arrayBuffer = await res.arrayBuffer();
    const base64 = Buffer.from(arrayBuffer).toString('base64');
    const mimeType = res.headers.get('content-type') || '';
    return { data: base64, mimeType };
  } catch (error) {
    console.error(`画像ファイルの読み込みに失敗しました: ${filePath}`, error);
    throw error;
  }
}

リポジトリ

https://github.com/webshoten/recipii

Discussion