diff --git a/CHANGELOG.md b/CHANGELOG.md index cb899d5..63d48f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.29.0 — 2026-07-14 15:20 + +### Added +- **Photo recipe import now respects your app language** — previously it defaulted to English (or whatever language was in the photo) instead of matching your locale like other AI generation features. + +### Fixed +- Importing a photo with no recognizable recipe used to open the recipe editor anyway with empty/garbage fields — it now shows a clear error instead. + ## 0.28.3 — 2026-07-14 14:55 ### Fixed diff --git a/apps/web/app/api/v1/ai/import-photo/route.ts b/apps/web/app/api/v1/ai/import-photo/route.ts index 2b417d4..b37e39d 100644 --- a/apps/web/app/api/v1/ai/import-photo/route.ts +++ b/apps/web/app/api/v1/ai/import-photo/route.ts @@ -44,6 +44,10 @@ export async function POST(req: NextRequest) { if (!result.ok) return result.response; const recipe = result.data; + if (!recipe.found) { + return NextResponse.json({ error: "No recipe recognized in photo" }, { status: 422 }); + } + const newRecipeId = crypto.randomUUID(); const now = new Date(); diff --git a/apps/web/components/recipe/ai-generate-dialog.tsx b/apps/web/components/recipe/ai-generate-dialog.tsx index 267ac6e..149ec5a 100644 --- a/apps/web/components/recipe/ai-generate-dialog.tsx +++ b/apps/web/components/recipe/ai-generate-dialog.tsx @@ -158,8 +158,10 @@ export function AiGenerateDialog({ body: JSON.stringify({ imageBase64: photoBase64, mimeType: photoMime }), }); if (!res.ok) { - let msg = t("photoError"); - try { msg = ((await res.json()) as { error?: string }).error ?? msg; } catch { /* non-JSON body */ } + let msg = res.status === 422 ? tRecipe("photoNoRecipeFound") : t("photoError"); + if (res.status !== 422) { + try { msg = ((await res.json()) as { error?: string }).error ?? msg; } catch { /* non-JSON body */ } + } toast.error(msg); return; } diff --git a/apps/web/components/recipe/photo-import-button.tsx b/apps/web/components/recipe/photo-import-button.tsx index 8f7b067..d3db79f 100644 --- a/apps/web/components/recipe/photo-import-button.tsx +++ b/apps/web/components/recipe/photo-import-button.tsx @@ -47,6 +47,7 @@ export function PhotoImportButton() { }); if (!res.ok) { + if (res.status === 422) throw new Error(t("photoNoRecipeFound")); const data = await res.json().catch(() => ({})) as { error?: string }; throw new Error(data.error ?? `Request failed: ${res.status}`); } diff --git a/apps/web/lib/ai/features/import-photo.ts b/apps/web/lib/ai/features/import-photo.ts index 679db5a..36ecde7 100644 --- a/apps/web/lib/ai/features/import-photo.ts +++ b/apps/web/lib/ai/features/import-photo.ts @@ -4,6 +4,7 @@ import { resolveModel, type AiConfig } from "../factory"; import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema"; const ImportedRecipeSchema = z.object({ + found: z.boolean().describe("false if the image does not contain a recognizable recipe (e.g. random photo, no visible ingredients/instructions)"), title: z.string(), description: z.string().optional(), baseServings: z.number().int().min(1).optional(), @@ -17,19 +18,23 @@ const ImportedRecipeSchema = z.object({ export type ImportedRecipe = z.infer; +const LANG: Record = { en: "English", fr: "French" }; + export async function importFromPhoto( imageBase64: string, mimeType: "image/jpeg" | "image/png" | "image/webp", config?: AiConfig, - _locale?: string, + locale?: string, ): Promise { const model = resolveModel(config); + const lang = LANG[locale ?? "en"] ?? "English"; + const langInstruction = lang !== "English" ? ` Write the entire recipe (title, description, ingredient names, step instructions) in ${lang}, regardless of the language used in the image.` : ""; const { object } = await generateObject({ model, schema: ImportedRecipeSchema, system: - "You are a recipe extraction specialist. Extract the complete recipe from the provided image. Be precise with ingredient quantities and cooking instructions.", + `You are a recipe extraction specialist. Extract the complete recipe from the provided image. Be precise with ingredient quantities and cooking instructions. If the image does not contain a recognizable recipe (no visible ingredients or cooking instructions), set found to false and leave the other fields minimal/empty.${langInstruction}`, messages: [ { role: "user", diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 561aaa3..55e72b7 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.28.3"; +export const APP_VERSION = "0.29.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,16 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.29.0", + date: "2026-07-14 15:20", + added: [ + "**Photo recipe import now respects your app language** — previously it defaulted to English (or whatever language was in the photo) instead of matching your locale like other AI generation features.", + ], + fixed: [ + "Importing a photo with no recognizable recipe used to open the recipe editor anyway with empty/garbage fields — it now shows a clear error instead.", + ], + }, { version: "0.28.3", date: "2026-07-14 14:55", diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts index aa9c97b..1a36f3b 100644 --- a/apps/web/lib/openapi.ts +++ b/apps/web/lib/openapi.ts @@ -286,7 +286,7 @@ export function generateOpenApiSpec(): object { })); registry.registerPath({ method: "post", path: "/api/v1/ai/generate-from-idea", summary: "Generate a full recipe from a short title/idea", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ title: z.string().min(1).max(200), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); - registry.registerPath({ method: "post", path: "/api/v1/ai/import-photo", summary: "Import a recipe from a photo using AI vision", description: "Rate-limited: 5 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); + registry.registerPath({ method: "post", path: "/api/v1/ai/import-photo", summary: "Import a recipe from a photo using AI vision", description: "Rate-limited: 5 req/min. Consumes AI quota. Written in the caller's app language, regardless of the language shown in the photo.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 422: { description: "No recipe recognized in the photo", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/ai/substitute", summary: "Suggest ingredient substitutions", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ ingredient: z.string().min(1).max(200), recipeTitle: z.string().max(200).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Substitutions", content: { "application/json": { schema: z.object({ substitutions: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/ai/scale", summary: "Scale a recipe's ingredients to a target serving count", description: "Rate-limited: 20 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ recipeId: z.string(), targetServings: z.number().int().min(1).max(100) }) } }, required: true } }, responses: { 200: { description: "Scaled ingredients", content: { "application/json": { schema: z.object({ ingredients: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/ai/adapt/{id}", summary: "Adapt a recipe to exclude ingredients or meet extra constraints", description: "Consumes AI quota. Creates a new private draft recipe.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ excludeIngredients: z.array(z.string()).default([]), extraConstraints: z.string().max(500).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id and adaptation notes", content: { "application/json": { schema: z.object({ id: z.string(), adaptationNotes: z.string().optional() }) } } }, 400: { description: "Validation error or no constraint provided", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index bdc2b53..48a1daf 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -152,6 +152,7 @@ "cookAction": "Cook", "adaptConstraintPlaceholder": "e.g. Make it vegan, lower the calories, use pantry staples only, gluten-free…", "photoImportFailed": "Failed to import recipe from photo.", + "photoNoRecipeFound": "No recipe could be recognized in that photo. Try a clearer picture of the ingredients or instructions.", "importFromPhoto": "Import from Photo", "urlImportTitle": "Import recipe from URL", "urlImportDescription": "Paste a recipe URL and AI will extract the ingredients and instructions.", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index d736eba..b993ec1 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -152,6 +152,7 @@ "drinksTypeHot": "Chaud", "cookAction": "Cuisiner", "photoImportFailed": "Échec de l'importation de la recette depuis la photo.", + "photoNoRecipeFound": "Aucune recette reconnue sur cette photo. Essayez une image plus nette des ingrédients ou des instructions.", "importFromPhoto": "Importer depuis une photo", "urlImportTitle": "Importer une recette depuis une URL", "urlImportDescription": "Collez l'URL d'une recette et l'IA en extraira les ingrédients et les instructions.", diff --git a/apps/web/package.json b/apps/web/package.json index 23f35f9..5f37df3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.28.3", + "version": "0.29.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 3f05e88..46e390a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.28.3", + "version": "0.29.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",