Gemini 1.5 FlashとNextjsで作る料理アドバイスWebアプリ
課題
料理をしていると悩みを感じる時があります。
- 「同じ料理が多くなってしまう」
- 「栄養が偏ってないか心配になる」
- 「昔作ったレシピが思い出せない」
目標
こんな機能を考えました。
- 料理写真&レシピを管理できる
- 5大栄養素の割合を表示できる
- 栄養バランスからおすすめ料理を提案できる
UX
- 直感的に操作できる
- ボタン操作数が少なくすむ
- AIによって登録作業の手間を省くことができる
結果を先に
「ここをクリック」から料理画像をアップロードします
アップロード済みの画像をクリックするとモーダルが開きます
自分で食材を入力することもできますが、
AI食材分析ボタンを押下することでGeminiが食材を予測して入力してくれます
栄養バランス(AI)のタブを押すと食材と量から栄養バランスとアドバイスを分析します
Webサイト
技術選定
項目 | 対象 |
---|---|
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ですべてのルートへのリクエストを受け取れるように
こちらのパスに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キーを取得して環境変数には指定しておかなくてはなりません。
結論から言うと、画像パスから食材と量を分析するメソッドはこんな感じです。
引数はアップロード済み画像の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;
}
}
リポジトリ
Discussion