From 460f7d334c963f697bb1132da4e03fa70c0285e4 Mon Sep 17 00:00:00 2001 From: ordinarthur Date: Wed, 8 Apr 2026 12:53:55 +0200 Subject: [PATCH] feat: streaming recipe generation + user preferences + parallel image gen Backend: - Prisma: add user preferences (dietaryPreference, allergies, maxCookingTime, equipment, cuisinePreference, servingsDefault) + migration SQL. Also make stripeId nullable so signup works without Stripe. - prompts.ts: buildUserPrompt now takes a BuildPromptOptions with preferences. Injects strong, explicit constraints in the user message (vegan rules, allergy warnings, time limits, equipment availability, cuisine hints). - recipe-generator.ts: new streamRecipe() async generator. Streams OpenAI chat completion with json_schema strict mode, parses the growing buffer to detect 'titre' and 'description' early, yields typed events: { type: 'delta' | 'title' | 'description' | 'complete' } Final event includes the parsed StructuredRecipe + cost log. - recipes.ts route: new POST /recipes/create-stream returning SSE: event: progress { step } event: transcription{ text } event: title { title } <- triggers parallel image gen event: description { description } event: recipe { recipe } event: image { url } event: saved { id } event: done Heartbeat every 15s to prevent proxy timeouts. Image generation is kicked off the moment the title is extracted, running in parallel with the rest of the recipe stream. Legacy POST /recipes/create still works and now also passes user preferences. - users.ts route: GET /profile now returns preferences (equipment deserialized from JSON). New PUT /users/preferences with validation (diet enum, time 5-600, servings 1-20, equipment array -> JSON). - ai.ts plugin: generateRecipe signature extended to accept preferences. Frontend: - api/recipe.ts: createRecipeStream() async generator that consumes SSE via fetch + ReadableStream + TextDecoder (EventSource can't do POST). Parses 'event:' and 'data:' lines, yields typed StreamEvent union. - api/auth.ts: User interface extended with preferences; new UserPreferences type exported. - api/user.ts: updatePreferences() method. - RecipeForm.tsx: handleSubmit now consumes the stream. Live UI displays: 1. Initial cooking loader with step label 2. Transcription appears as soon as it's ready 3. Title fades in the moment it's extracted (before the rest of the recipe finishes generating) 4. Description appears right after 5. Image replaces the loader when ready 6. Small delay before navigating to the saved recipe detail page - Profile.tsx: new 'Cuisine' tab with form for diet, allergies, max time, servings, cuisine preference, and equipment checkboxes (8 options). UX improvement: perceived latency is dramatically reduced. Instead of waiting 40s staring at a spinner, the user sees the title ~3-4s in and can start reading, while the image finishes generating in parallel. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migration.sql | 41 ++++ backend/prisma/schema.prisma | 33 ++- backend/src/ai/prompts.ts | 88 ++++++- backend/src/ai/recipe-generator.ts | 151 +++++++++++- backend/src/plugins/ai.ts | 8 +- backend/src/routes/recipes.ts | 214 ++++++++++++++++- backend/src/routes/users.ts | 109 ++++++++- backend/src/types/fastify.d.ts | 6 +- frontend/src/api/auth.ts | 15 ++ frontend/src/api/recipe.ts | 112 +++++++++ frontend/src/api/user.ts | 9 + frontend/src/pages/Profile.tsx | 218 +++++++++++++++++- frontend/src/pages/Recipes/RecipeForm.tsx | 138 ++++++++++- 13 files changed, 1103 insertions(+), 39 deletions(-) create mode 100644 backend/prisma/migrations/20260408_add_user_preferences/migration.sql diff --git a/backend/prisma/migrations/20260408_add_user_preferences/migration.sql b/backend/prisma/migrations/20260408_add_user_preferences/migration.sql new file mode 100644 index 0000000..accbe2c --- /dev/null +++ b/backend/prisma/migrations/20260408_add_user_preferences/migration.sql @@ -0,0 +1,41 @@ +-- Add cooking preferences to User and make stripeId optional +-- SQLite requires rewriting the table for nullability changes. + +PRAGMA foreign_keys=OFF; + +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "password" TEXT, + "name" TEXT NOT NULL, + "googleId" TEXT, + "stripeId" TEXT, + "subscription" TEXT, + "resetToken" TEXT, + "resetTokenExpiry" DATETIME, + "dietaryPreference" TEXT, + "allergies" TEXT, + "maxCookingTime" INTEGER, + "equipment" TEXT, + "cuisinePreference" TEXT, + "servingsDefault" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +INSERT INTO "new_User" ( + "id", "email", "password", "name", "googleId", "stripeId", + "subscription", "resetToken", "resetTokenExpiry", "createdAt", "updatedAt" +) SELECT + "id", "email", "password", "name", "googleId", "stripeId", + "subscription", "resetToken", "resetTokenExpiry", "createdAt", "updatedAt" +FROM "User"; + +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; + +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +CREATE UNIQUE INDEX "User_googleId_key" ON "User"("googleId"); +CREATE UNIQUE INDEX "User_stripeId_key" ON "User"("stripeId"); + +PRAGMA foreign_keys=ON; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4c6fd8e..d1d25a7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -8,18 +8,27 @@ datasource db { } model User { - id String @id @default(uuid()) - email String @unique - password String? // Optionnel pour les utilisateurs Google - name String - googleId String? @unique // ID Google pour SSO - stripeId String @unique - subscription String? - resetToken String? // Token pour la réinitialisation du mot de passe - resetTokenExpiry DateTime? // Date d'expiration du token - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - recipes Recipe[] + id String @id @default(uuid()) + email String @unique + password String? // Optionnel pour les utilisateurs Google + name String + googleId String? @unique + stripeId String? @unique // Optionnel : Stripe peut être désactivé + subscription String? + resetToken String? + resetTokenExpiry DateTime? + + // Préférences culinaires injectées dans le prompt IA + dietaryPreference String? // 'vegetarian' | 'vegan' | 'pescatarian' | 'none' + allergies String? // Liste séparée par des virgules : "arachides,gluten" + maxCookingTime Int? // Temps total max (prépa + cuisson) en minutes + equipment String? // JSON array : '["four","plaque","micro-ondes"]' + cuisinePreference String? // "française", "italienne", "asiatique"... ou null + servingsDefault Int? // Nombre de portions par défaut + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + recipes Recipe[] } model Recipe { diff --git a/backend/src/ai/prompts.ts b/backend/src/ai/prompts.ts index b5f9b45..e984c72 100644 --- a/backend/src/ai/prompts.ts +++ b/backend/src/ai/prompts.ts @@ -175,14 +175,92 @@ export const RECIPE_JSON_SCHEMA = { }, } as const; +export interface UserPreferences { + dietaryPreference?: string | null; // 'vegetarian' | 'vegan' | 'pescatarian' | 'none' + allergies?: string | null; // "arachides,gluten" + maxCookingTime?: number | null; // minutes (prépa + cuisson) + equipment?: string[] | null; + cuisinePreference?: string | null; + servingsDefault?: number | null; +} + +export interface BuildPromptOptions { + transcription: string; + hint?: string; + preferences?: UserPreferences | null; +} + +const DIET_LABELS: Record = { + vegetarian: 'végétarien (aucune viande ni poisson, mais œufs/lait/fromage autorisés)', + vegan: 'végan strict (aucun produit d\'origine animale, y compris œufs, lait, miel)', + pescatarian: 'pescétarien (poisson autorisé, pas de viande)', +}; + /** * Construit le prompt utilisateur à partir des ingrédients transcrits - * et d'éventuelles préférences (à étendre dans une phase B). + * et des préférences culinaires de l'utilisateur. */ -export function buildUserPrompt(transcription: string, hint?: string): string { - const cleanTranscription = transcription.trim(); - const base = `Voici les ingrédients que j'ai sous la main : ${cleanTranscription}`; - return hint ? `${base}\n\nContrainte additionnelle : ${hint}` : base; +export function buildUserPrompt(options: BuildPromptOptions): string { + const cleanTranscription = options.transcription.trim(); + const parts: string[] = [ + `Voici les ingrédients que j'ai sous la main : ${cleanTranscription}`, + ]; + + const prefs = options.preferences; + if (prefs) { + const prefLines: string[] = []; + + if (prefs.dietaryPreference && prefs.dietaryPreference !== 'none') { + const label = DIET_LABELS[prefs.dietaryPreference] ?? prefs.dietaryPreference; + prefLines.push(`- Régime à respecter IMPÉRATIVEMENT : ${label}. Ne propose aucun ingrédient interdit par ce régime, même en substitution implicite.`); + } + + if (prefs.allergies && prefs.allergies.trim()) { + const list = prefs.allergies + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (list.length > 0) { + prefLines.push( + `- Allergies STRICTES (mettre en jeu la santé de la personne) : ${list.join(', ')}. N'utilise JAMAIS ces ingrédients ni leurs dérivés (ex: si "arachides" → pas d'huile d'arachide, pas de beurre de cacahuète).` + ); + } + } + + if (prefs.maxCookingTime && prefs.maxCookingTime > 0) { + prefLines.push( + `- Temps total disponible : ${prefs.maxCookingTime} minutes maximum (préparation + cuisson cumulées). Ajuste la complexité en conséquence.` + ); + } + + if (prefs.equipment && prefs.equipment.length > 0) { + prefLines.push( + `- Équipement disponible : ${prefs.equipment.join(', ')}. N'utilise aucune technique qui nécessiterait un équipement non listé.` + ); + } + + if (prefs.cuisinePreference) { + prefLines.push( + `- Inspiration culinaire préférée : ${prefs.cuisinePreference}. Reste dans cette famille si possible, quitte à jouer une variation créative.` + ); + } + + if (prefs.servingsDefault && prefs.servingsDefault > 0) { + prefLines.push( + `- Nombre de portions souhaité : ${prefs.servingsDefault}.` + ); + } + + if (prefLines.length > 0) { + parts.push(`\nMes contraintes :\n${prefLines.join('\n')}`); + } + } + + if (options.hint) { + parts.push(`\nContrainte additionnelle : ${options.hint}`); + } + + return parts.join('\n'); } /** diff --git a/backend/src/ai/recipe-generator.ts b/backend/src/ai/recipe-generator.ts index 1384d37..1906ec7 100644 --- a/backend/src/ai/recipe-generator.ts +++ b/backend/src/ai/recipe-generator.ts @@ -1,6 +1,11 @@ import type { OpenAI } from 'openai'; import type { FastifyBaseLogger } from 'fastify'; -import { SYSTEM_PROMPT, RECIPE_JSON_SCHEMA, buildUserPrompt } from './prompts'; +import { + SYSTEM_PROMPT, + RECIPE_JSON_SCHEMA, + buildUserPrompt, + type UserPreferences, +} from './prompts'; import { computeTextCost, type CallLog } from './cost'; export interface RecipeIngredient { @@ -34,6 +39,7 @@ export interface GenerateRecipeOptions { transcription: string; hint?: string; userId?: string; + preferences?: UserPreferences | null; } export interface GenerateRecipeResult { @@ -55,16 +61,21 @@ export async function generateRecipe( model, messages: [ { role: 'system', content: SYSTEM_PROMPT }, - { role: 'user', content: buildUserPrompt(options.transcription, options.hint) }, + { + role: 'user', + content: buildUserPrompt({ + transcription: options.transcription, + hint: options.hint, + preferences: options.preferences, + }), + }, ], response_format: { type: 'json_schema', json_schema: RECIPE_JSON_SCHEMA, }, - // Léger : on veut de la créativité mais sans halluciner les ingrédients temperature: 0.85, top_p: 0.95, - // Le modèle a tout ce qu'il faut pour répondre court ; cap de sécurité max_tokens: 2000, }); @@ -121,3 +132,135 @@ export async function generateRecipe( return { recipe, log }; } + +// --------------------------------------------------------------------------- +// Streaming version +// --------------------------------------------------------------------------- + +export type RecipeStreamEvent = + | { type: 'delta'; content: string } + | { type: 'title'; title: string } + | { type: 'description'; description: string } + | { type: 'complete'; recipe: StructuredRecipe; log: CallLog }; + +/** + * Streame la génération de recette token par token. + * + * Émet des événements typés au fil de la génération : + * - `delta` : chaque morceau de texte brut venu d'OpenAI (pour affichage + * ou debug ; le frontend peut l'ignorer) + * - `title` : le titre dès qu'il est extractible (permet de lancer la + * génération d'image en parallèle) + * - `description` : la description courte dès qu'elle est complète + * - `complete` : la recette parsée au complet + métadonnées de coût + */ +export async function* streamRecipe( + openai: OpenAI, + logger: FastifyBaseLogger, + options: GenerateRecipeOptions +): AsyncGenerator { + const start = Date.now(); + const model = DEFAULT_MODEL; + + const stream = await openai.chat.completions.create({ + model, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { + role: 'user', + content: buildUserPrompt({ + transcription: options.transcription, + hint: options.hint, + preferences: options.preferences, + }), + }, + ], + response_format: { + type: 'json_schema', + json_schema: RECIPE_JSON_SCHEMA, + }, + temperature: 0.85, + top_p: 0.95, + max_tokens: 2000, + stream: true, + // Demander l'usage dans la réponse streamée (important : sans ça les + // tokens.usage est null à la fin du stream) + stream_options: { include_usage: true }, + }); + + let buffer = ''; + let titleEmitted = false; + let descriptionEmitted = false; + let finalUsage: { prompt_tokens?: number; completion_tokens?: number; prompt_tokens_details?: { cached_tokens?: number } } | null = null; + + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta?.content ?? ''; + + // Le dernier chunk contient l'usage mais pas de delta + if (chunk.usage) { + finalUsage = chunk.usage; + } + + if (!delta) continue; + + buffer += delta; + yield { type: 'delta', content: delta }; + + // Détection précoce du titre via regex sur le buffer + if (!titleEmitted) { + const m = buffer.match(/"titre"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); + if (m) { + titleEmitted = true; + yield { type: 'title', title: m[1] }; + } + } + + // Détection de la description (vient typiquement après le titre) + if (titleEmitted && !descriptionEmitted) { + const m = buffer.match(/"description"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); + if (m) { + descriptionEmitted = true; + yield { type: 'description', description: m[1] }; + } + } + } + + // Parsing final de la recette complète + let recipe: StructuredRecipe; + try { + recipe = JSON.parse(buffer) as StructuredRecipe; + } catch (err) { + throw new Error( + `Parse JSON du stream impossible : ${(err as Error).message}` + ); + } + + if (!recipe.titre || !recipe.ingredients?.length || !recipe.etapes?.length) { + throw new Error('Recette générée incomplète'); + } + + const cachedTokens = finalUsage?.prompt_tokens_details?.cached_tokens ?? 0; + const cost = computeTextCost(model, { + promptTokens: finalUsage?.prompt_tokens, + cachedTokens, + completionTokens: finalUsage?.completion_tokens, + }); + + const log: CallLog = { + userId: options.userId, + operation: 'recipe_generation', + model, + durationMs: Date.now() - start, + costUsd: cost, + usage: { + promptTokens: finalUsage?.prompt_tokens, + cachedTokens, + completionTokens: finalUsage?.completion_tokens, + }, + cacheHit: cachedTokens > 0, + }; + + logger.info(log, 'openai_recipe_streamed'); + + yield { type: 'complete', recipe, log }; +} diff --git a/backend/src/plugins/ai.ts b/backend/src/plugins/ai.ts index e5bc596..1f024fb 100644 --- a/backend/src/plugins/ai.ts +++ b/backend/src/plugins/ai.ts @@ -11,6 +11,7 @@ import { downloadToTemp, } from '../ai/transcriber'; import { generateRecipe as runGenerateRecipe, type StructuredRecipe } from '../ai/recipe-generator'; +import type { UserPreferences } from '../ai/prompts'; import { generateRecipeImage as runGenerateImage } from '../ai/image-generator'; export default fp(async function aiPlugin(fastify: FastifyInstance) { @@ -52,11 +53,16 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) { // le pipeline structuré + parallélisation image/texte. fastify.decorate( 'generateRecipe', - async (ingredients: string, hint?: string): Promise => { + async ( + ingredients: string, + hint?: string, + preferences?: unknown + ): Promise => { // 1. Génération du texte (séquentiel obligatoire : on a besoin du titre) const { recipe } = await runGenerateRecipe(openai, fastify.log, { transcription: ingredients, hint, + preferences: preferences as UserPreferences | null | undefined, }); // 2. Génération de l'image en parallèle de la sérialisation diff --git a/backend/src/routes/recipes.ts b/backend/src/routes/recipes.ts index 055bbcb..7805f2d 100644 --- a/backend/src/routes/recipes.ts +++ b/backend/src/routes/recipes.ts @@ -1,7 +1,52 @@ import * as fs from 'node:fs'; -import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from 'fastify'; import multipart from '@fastify/multipart'; import { deleteFile } from '../utils/storage'; +import { streamRecipe, type StructuredRecipe } from '../ai/recipe-generator'; +import { generateRecipeImage } from '../ai/image-generator'; +import type { UserPreferences } from '../ai/prompts'; + +type SSESender = (event: string, data: unknown) => void; + +function setupSSE(reply: FastifyReply): SSESender { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', // désactive le buffering nginx si en prod + }); + return (event, data) => { + reply.raw.write(`event: ${event}\n`); + reply.raw.write(`data: ${JSON.stringify(data)}\n\n`); + }; +} + +function parseUserPreferences(user: { + dietaryPreference: string | null; + allergies: string | null; + maxCookingTime: number | null; + equipment: string | null; + cuisinePreference: string | null; + servingsDefault: number | null; +}): UserPreferences { + let equipmentArr: string[] | null = null; + if (user.equipment) { + try { + const parsed = JSON.parse(user.equipment); + if (Array.isArray(parsed)) equipmentArr = parsed; + } catch { + /* ignore malformed */ + } + } + return { + dietaryPreference: user.dietaryPreference, + allergies: user.allergies, + maxCookingTime: user.maxCookingTime, + equipment: equipmentArr, + cuisinePreference: user.cuisinePreference, + servingsDefault: user.servingsDefault, + }; +} const FREE_PLAN_LIMIT = 5; @@ -47,7 +92,8 @@ const recipesRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { const recipeData = await fastify.generateRecipe( transcription, - 'Crée une recette délicieuse et détaillée' + undefined, + parseUserPreferences(user) ); const ingredientsString = Array.isArray(recipeData.ingredients) @@ -196,6 +242,170 @@ const recipesRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { return reply.code(500).send({ error: 'Erreur lors de la suppression de la recette' }); } }); + + // ------------------------------------------------------------------------- + // POST /create-stream — version streaming (SSE) + // ------------------------------------------------------------------------- + // Flux d'événements émis : + // progress { step } — étape courante du pipeline + // transcription { text } — texte transcrit + // title { title } — titre extrait, image lancée en parallèle + // description { description } — description courte + // delta { content } — delta brut (optionnel, pour debug) + // recipe { recipe } — recette structurée complète + // image { url } — URL finale de l'image + // saved { id } — id de la recette persistée + // error { message } — erreur terminale + // done — fin du flux + // + // La route reste un POST multipart (audio) mais la réponse est un stream SSE. + fastify.post('/create-stream', async (request, reply) => { + const data = await request.file(); + if (!data) { + return reply.code(400).send({ error: 'Fichier audio requis' }); + } + + const [user, recipeCount] = await Promise.all([ + fastify.prisma.user.findUnique({ where: { id: request.user.id } }), + fastify.prisma.recipe.count({ where: { userId: request.user.id } }), + ]); + + if (!user) { + return reply.code(404).send({ error: 'Utilisateur non trouvé' }); + } + + const isFree = !user.subscription || user.subscription === 'free'; + if (isFree && recipeCount >= FREE_PLAN_LIMIT) { + return reply.code(403).send({ + error: `Limite de ${FREE_PLAN_LIMIT} recettes atteinte pour le plan gratuit.`, + }); + } + + const preferences = parseUserPreferences(user); + const send = setupSSE(reply); + + // Heartbeat toutes les 15s pour que les proxies ne ferment pas la connexion + const heartbeat = setInterval(() => { + reply.raw.write(`: heartbeat\n\n`); + }, 15000); + + const cleanup = () => clearInterval(heartbeat); + request.raw.on('close', cleanup); + + try { + // --- 1. Sauvegarde audio --- + send('progress', { step: 'saving_audio' }); + const audioResult = await fastify.saveAudioFile(data); + const audioUrl = audioResult.url || audioResult.localPath || null; + const audioStoragePath = audioResult.path ?? null; + + // --- 2. Transcription --- + send('progress', { step: 'transcribing' }); + const transcription = await fastify.transcribeAudio(audioResult); + send('transcription', { text: transcription }); + + // --- 3. Génération recette en streaming --- + send('progress', { step: 'generating_recipe' }); + + let imagePromise: Promise<{ url: string | null }> | null = null; + let imageStartedAt: number | null = null; + let finalRecipe: StructuredRecipe | null = null; + + const iterator = streamRecipe(fastify.openai, fastify.log, { + transcription, + preferences, + userId: user.id, + }); + + for await (const event of iterator) { + if (event.type === 'title') { + send('title', { title: event.title }); + // Lance la génération d'image en parallèle dès qu'on a le titre + if (!imagePromise) { + imageStartedAt = Date.now(); + send('progress', { step: 'generating_image' }); + imagePromise = generateRecipeImage(fastify.openai, fastify.log, { + title: event.title, + userId: user.id, + }); + } + } else if (event.type === 'description') { + send('description', { description: event.description }); + } else if (event.type === 'delta') { + // Envoyé seulement pour ceux qui veulent l'afficher en brut + // On peut commenter pour réduire la bande passante + send('delta', { content: event.content }); + } else if (event.type === 'complete') { + finalRecipe = event.recipe; + send('recipe', { recipe: event.recipe }); + } + } + + if (!finalRecipe) { + throw new Error('Recette non générée'); + } + + // --- 4. Attente image (peut être déjà prête si génération plus rapide que le stream) --- + send('progress', { step: 'finalizing' }); + const imageResult = imagePromise + ? await imagePromise + : { url: null }; + const imageUrl = imageResult.url; + if (imageStartedAt !== null) { + fastify.log.info( + { parallelImageMs: Date.now() - imageStartedAt }, + 'parallel_image_duration' + ); + } + send('image', { url: imageUrl }); + + // --- 5. Persistance --- + const ingredientsString = finalRecipe.ingredients + .map((i) => `${i.quantite} ${i.nom}${i.notes ? ` (${i.notes})` : ''}`) + .join('\n'); + const stepsString = finalRecipe.etapes + .sort((a, b) => a.numero - b.numero) + .map((e) => `${e.numero}. ${e.instruction}`) + .join('\n'); + const tipsString = [ + ...finalRecipe.conseils, + `Inspiration : ${finalRecipe.origine_inspiration}`, + finalRecipe.accord_boisson ? `Accord boisson : ${finalRecipe.accord_boisson}` : null, + ] + .filter(Boolean) + .join('\n'); + + const saved = await fastify.prisma.recipe.create({ + data: { + title: finalRecipe.titre, + ingredients: ingredientsString, + userPrompt: transcription, + generatedRecipe: JSON.stringify(finalRecipe), + imageUrl, + preparationTime: finalRecipe.temps_preparation, + cookingTime: finalRecipe.temps_cuisson, + servings: finalRecipe.portions, + difficulty: finalRecipe.difficulte, + steps: stepsString, + tips: tipsString, + audioUrl: audioStoragePath || audioUrl, + userId: user.id, + }, + }); + + send('saved', { id: saved.id }); + send('done', {}); + } catch (err) { + fastify.log.error(err, 'create-stream failed'); + send('error', { message: (err as Error).message }); + } finally { + cleanup(); + reply.raw.end(); + } + + // On a déjà géré la réponse via reply.raw + return reply; + }); }; export default recipesRoutes; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 2cac9c4..21b80c9 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -29,6 +29,15 @@ interface DeleteAccountBody { password?: string; } +interface UpdatePreferencesBody { + dietaryPreference?: string | null; + allergies?: string | null; + maxCookingTime?: number | null; + equipment?: string[] | null; + cuisinePreference?: string | null; + servingsDefault?: number | null; +} + const usersRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { const authenticateUser = async (request: FastifyRequest, reply: FastifyReply) => { try { @@ -49,6 +58,12 @@ const usersRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { name: true, subscription: true, createdAt: true, + dietaryPreference: true, + allergies: true, + maxCookingTime: true, + equipment: true, + cuisinePreference: true, + servingsDefault: true, }, }); @@ -56,7 +71,23 @@ const usersRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { return reply.code(404).send({ error: 'Utilisateur non trouvé' }); } - return { user }; + // Désérialise le JSON equipment avant de renvoyer au client + let equipmentArr: string[] = []; + if (user.equipment) { + try { + const parsed = JSON.parse(user.equipment); + if (Array.isArray(parsed)) equipmentArr = parsed; + } catch { + /* ignore */ + } + } + + return { + user: { + ...user, + equipment: equipmentArr, + }, + }; } catch (error) { fastify.log.error(error); return reply.code(500).send({ error: 'Erreur lors de la récupération du profil' }); @@ -328,6 +359,82 @@ const usersRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { } } ); + + // --- PUT /preferences --- + fastify.put<{ Body: UpdatePreferencesBody }>( + '/preferences', + { preHandler: authenticateUser }, + async (request, reply) => { + try { + const body = request.body ?? {}; + + // Validation simple : on accepte null pour "reset" + const allowedDiets = ['none', 'vegetarian', 'vegan', 'pescatarian', null]; + if (body.dietaryPreference !== undefined && !allowedDiets.includes(body.dietaryPreference)) { + return reply.code(400).send({ error: 'Régime invalide' }); + } + + if ( + body.maxCookingTime !== undefined && + body.maxCookingTime !== null && + (body.maxCookingTime < 5 || body.maxCookingTime > 600) + ) { + return reply.code(400).send({ error: 'Temps de cuisson invalide (5-600 min)' }); + } + + if ( + body.servingsDefault !== undefined && + body.servingsDefault !== null && + (body.servingsDefault < 1 || body.servingsDefault > 20) + ) { + return reply.code(400).send({ error: 'Nombre de portions invalide (1-20)' }); + } + + const updateData: Record = {}; + if (body.dietaryPreference !== undefined) updateData.dietaryPreference = body.dietaryPreference; + if (body.allergies !== undefined) updateData.allergies = body.allergies; + if (body.maxCookingTime !== undefined) updateData.maxCookingTime = body.maxCookingTime; + if (body.cuisinePreference !== undefined) updateData.cuisinePreference = body.cuisinePreference; + if (body.servingsDefault !== undefined) updateData.servingsDefault = body.servingsDefault; + if (body.equipment !== undefined) { + updateData.equipment = body.equipment ? JSON.stringify(body.equipment) : null; + } + + const updated = await fastify.prisma.user.update({ + where: { id: request.user.id }, + data: updateData, + select: { + dietaryPreference: true, + allergies: true, + maxCookingTime: true, + equipment: true, + cuisinePreference: true, + servingsDefault: true, + }, + }); + + let equipmentArr: string[] = []; + if (updated.equipment) { + try { + const parsed = JSON.parse(updated.equipment); + if (Array.isArray(parsed)) equipmentArr = parsed; + } catch { + /* ignore */ + } + } + + return { + preferences: { + ...updated, + equipment: equipmentArr, + }, + }; + } catch (error) { + fastify.log.error(error); + return reply.code(500).send({ error: 'Erreur lors de la mise à jour des préférences' }); + } + } + ); }; export default usersRoutes; diff --git a/backend/src/types/fastify.d.ts b/backend/src/types/fastify.d.ts index 51f8d8e..ea95b03 100644 --- a/backend/src/types/fastify.d.ts +++ b/backend/src/types/fastify.d.ts @@ -57,7 +57,11 @@ declare module 'fastify' { // ai plugin openai: OpenAI; transcribeAudio: (audioInput: AudioInput) => Promise; - generateRecipe: (ingredients: string, prompt?: string) => Promise; + generateRecipe: ( + ingredients: string, + prompt?: string, + preferences?: unknown + ) => Promise; saveAudioFile: (file: MultipartFile) => Promise; // google-auth plugin diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 8527019..f11f438 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -5,6 +5,21 @@ export interface User { email: string; name: string | null; subscription: string; + dietaryPreference?: string | null; + allergies?: string | null; + maxCookingTime?: number | null; + equipment?: string[]; + cuisinePreference?: string | null; + servingsDefault?: number | null; +} + +export interface UserPreferences { + dietaryPreference: string | null; + allergies: string | null; + maxCookingTime: number | null; + equipment: string[]; + cuisinePreference: string | null; + servingsDefault: number | null; } export interface AuthResponse { diff --git a/frontend/src/api/recipe.ts b/frontend/src/api/recipe.ts index b08dd43..fdbaef2 100644 --- a/frontend/src/api/recipe.ts +++ b/frontend/src/api/recipe.ts @@ -79,8 +79,120 @@ export const deleteRecipe = async (id: string): Promise => { } }; +// ----------------------------------------------------------------------------- +// Streaming (SSE via fetch + ReadableStream) +// ----------------------------------------------------------------------------- + +export type StreamEvent = + | { type: 'progress'; step: string } + | { type: 'transcription'; text: string } + | { type: 'title'; title: string } + | { type: 'description'; description: string } + | { type: 'delta'; content: string } + | { type: 'recipe'; recipe: StructuredRecipe } + | { type: 'image'; url: string | null } + | { type: 'saved'; id: string } + | { type: 'error'; message: string } + | { type: 'done' }; + +export interface StructuredRecipe { + titre: string; + description: string; + origine_inspiration: string; + ingredients: Array<{ + nom: string; + quantite: string; + notes: string | null; + complement: boolean; + }>; + etapes: Array<{ + numero: number; + instruction: string; + duree_minutes: number | null; + }>; + temps_preparation: number; + temps_cuisson: number; + portions: number; + difficulte: 'facile' | 'moyen' | 'difficile'; + conseils: string[]; + accord_boisson: string | null; +} + +const API_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'; + +/** + * Lance une création de recette en streaming SSE. + * Retourne un AsyncIterable d'événements que le composant peut consommer + * avec `for await`. + */ +export async function* createRecipeStream( + audioFile: File +): AsyncGenerator { + const token = localStorage.getItem('token'); + const formData = new FormData(); + formData.append('file', audioFile); + + const response = await fetch(`${API_URL}/recipes/create-stream`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData, + }); + + if (!response.ok) { + const errText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errText}`); + } + + if (!response.body) { + throw new Error('Pas de body dans la réponse'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Les événements SSE sont séparés par une ligne vide (\n\n) + let sepIndex: number; + while ((sepIndex = buffer.indexOf('\n\n')) !== -1) { + const rawEvent = buffer.slice(0, sepIndex); + buffer = buffer.slice(sepIndex + 2); + + // Parse les lignes event: et data: + let eventName = 'message'; + let dataLine = ''; + for (const line of rawEvent.split('\n')) { + if (line.startsWith('event: ')) eventName = line.slice(7).trim(); + else if (line.startsWith('data: ')) dataLine += line.slice(6); + // Ignore les heartbeats (lignes commençant par ':') + } + + if (!dataLine) continue; + + try { + const data = JSON.parse(dataLine); + yield { type: eventName, ...data } as StreamEvent; + } catch (err) { + console.warn('Event SSE non parsable:', rawEvent, err); + } + } + } + } finally { + reader.releaseLock(); + } +} + +// ----------------------------------------------------------------------------- + export const recipeService = { createRecipe, + createRecipeStream, getRecipes, getRecipeById, deleteRecipe, diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 52e3d50..d9289e1 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -1,4 +1,5 @@ import { apiService } from "./base"; +import type { User, UserPreferences } from "./auth"; const userService = { getCurrentUser: async (): Promise => { @@ -11,6 +12,14 @@ const userService = { return response.user; }, + updatePreferences: async (data: Partial): Promise => { + const response = await apiService.put<{ preferences: UserPreferences }>( + 'users/preferences', + data + ); + return response.preferences; + }, + changePassword: async (data: { currentPassword: string; newPassword: string }): Promise => { return apiService.put('users/change-password', data); }, diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index c61dd70..31f9694 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -8,7 +8,8 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Separator } from "@/components/ui/separator"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { AlertCircle, Save, User, Lock, LogOut, Trash2, Mail } from "lucide-react"; +import { AlertCircle, Save, User, Lock, LogOut, Trash2, Mail, ChefHat } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; import { recipeService, Recipe } from "@/api/recipe"; import userService from "@/api/user"; @@ -20,8 +21,25 @@ interface User { name: string; subscription?: string; createdAt: string; + dietaryPreference?: string | null; + allergies?: string | null; + maxCookingTime?: number | null; + equipment?: string[]; + cuisinePreference?: string | null; + servingsDefault?: number | null; } +const EQUIPMENT_OPTIONS = [ + { key: "plaque", label: "Plaque / gazinière" }, + { key: "four", label: "Four" }, + { key: "micro-ondes", label: "Micro-ondes" }, + { key: "mixeur", label: "Mixeur / blender" }, + { key: "robot", label: "Robot pâtissier" }, + { key: "friteuse", label: "Friteuse à air" }, + { key: "barbecue", label: "Barbecue / plancha" }, + { key: "cuiseur-vapeur", label: "Cuiseur vapeur" }, +]; + // Service utilisateur export default function Profile() { @@ -51,6 +69,23 @@ export default function Profile() { password: "" }); + // Préférences culinaires + const [prefsForm, setPrefsForm] = useState<{ + dietaryPreference: string; + allergies: string; + maxCookingTime: string; + equipment: string[]; + cuisinePreference: string; + servingsDefault: string; + }>({ + dietaryPreference: "none", + allergies: "", + maxCookingTime: "", + equipment: [], + cuisinePreference: "", + servingsDefault: "", + }); + useEffect(() => { const fetchUserData = async () => { try { @@ -62,6 +97,14 @@ export default function Profile() { setProfileForm({ name: userData.name || "" }); + setPrefsForm({ + dietaryPreference: userData.dietaryPreference || "none", + allergies: userData.allergies || "", + maxCookingTime: userData.maxCookingTime ? String(userData.maxCookingTime) : "", + equipment: userData.equipment || [], + cuisinePreference: userData.cuisinePreference || "", + servingsDefault: userData.servingsDefault ? String(userData.servingsDefault) : "", + }); // Récupérer les recettes de l'utilisateur const recipes = await recipeService.getRecipes(); @@ -116,6 +159,39 @@ export default function Profile() { } }; + const handlePrefsSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setSuccess(""); + + try { + setSaving(true); + await userService.updatePreferences({ + dietaryPreference: prefsForm.dietaryPreference === "none" ? null : prefsForm.dietaryPreference, + allergies: prefsForm.allergies.trim() || null, + maxCookingTime: prefsForm.maxCookingTime ? parseInt(prefsForm.maxCookingTime, 10) : null, + equipment: prefsForm.equipment.length > 0 ? prefsForm.equipment : null, + cuisinePreference: prefsForm.cuisinePreference.trim() || null, + servingsDefault: prefsForm.servingsDefault ? parseInt(prefsForm.servingsDefault, 10) : null, + }); + setSuccess("Préférences culinaires mises à jour"); + } catch (err) { + console.error("Erreur lors de la mise à jour des préférences:", err); + setError("Impossible de mettre à jour les préférences"); + } finally { + setSaving(false); + } + }; + + const toggleEquipment = (key: string) => { + setPrefsForm((prev) => ({ + ...prev, + equipment: prev.equipment.includes(key) + ? prev.equipment.filter((e) => e !== key) + : [...prev.equipment, key], + })); + }; + const handlePasswordSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); @@ -321,18 +397,22 @@ export default function Profile() {
- + Profil + + + Cuisine + Email - Mot de passe + Sécurité @@ -374,6 +454,138 @@ export default function Profile() { + + + + Préférences culinaires + + Ces informations sont transmises au chef IA pour personnaliser chaque recette + + + +
+ {/* Régime */} +
+ + +
+ + {/* Allergies */} +
+ + + setPrefsForm({ ...prefsForm, allergies: e.target.value }) + } + /> +

+ Séparées par des virgules. Le chef les évitera impérativement. +

+
+ + {/* Temps max */} +
+
+ + + setPrefsForm({ ...prefsForm, maxCookingTime: e.target.value }) + } + /> +
+
+ + + setPrefsForm({ ...prefsForm, servingsDefault: e.target.value }) + } + /> +
+
+ + {/* Cuisine préférée */} +
+ + + setPrefsForm({ ...prefsForm, cuisinePreference: e.target.value }) + } + /> +
+ + {/* Équipement */} +
+ +
+ {EQUIPMENT_OPTIONS.map((opt) => ( +
+ toggleEquipment(opt.key)} + /> + +
+ ))} +
+

+ Le chef n'utilisera que des techniques compatibles avec ton équipement. +

+
+ + +
+
+
+
+ diff --git a/frontend/src/pages/Recipes/RecipeForm.tsx b/frontend/src/pages/Recipes/RecipeForm.tsx index 2d43202..6808d40 100644 --- a/frontend/src/pages/Recipes/RecipeForm.tsx +++ b/frontend/src/pages/Recipes/RecipeForm.tsx @@ -35,6 +35,13 @@ export default function RecipeForm() { const [recordingTime, setRecordingTime] = useState(0) const [showTips, setShowTips] = useState(false) + // Live streaming state + const [progressStep, setProgressStep] = useState("") + const [liveTranscription, setLiveTranscription] = useState("") + const [liveTitle, setLiveTitle] = useState("") + const [liveDescription, setLiveDescription] = useState("") + const [liveImageUrl, setLiveImageUrl] = useState(null) + // Update audioFile when recording is available useEffect(() => { if (currentRecording) { @@ -91,7 +98,7 @@ export default function RecipeForm() { setError("") } - // Soumettre le formulaire + // Soumettre le formulaire — utilise le streaming SSE const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -103,19 +110,63 @@ export default function RecipeForm() { setLoading(true) setError("") setRecordingStatus("processing") + setProgressStep("") + setLiveTranscription("") + setLiveTitle("") + setLiveDescription("") + setLiveImageUrl(null) try { - const recipe = await recipeService.createRecipe(audioFile) - navigate(`/recipes/${recipe.id}`) + let savedId: string | null = null + + for await (const event of recipeService.createRecipeStream(audioFile)) { + switch (event.type) { + case "progress": + setProgressStep(event.step) + break + case "transcription": + setLiveTranscription(event.text) + break + case "title": + setLiveTitle(event.title) + break + case "description": + setLiveDescription(event.description) + break + case "image": + setLiveImageUrl(event.url) + break + case "saved": + savedId = event.id + break + case "error": + throw new Error(event.message) + case "done": + break + } + } + + if (savedId) { + // Petit délai pour que l'utilisateur voie l'image finale avant de partir + setTimeout(() => navigate(`/recipes/${savedId}`), 800) + } } catch (err) { console.error("Erreur lors de la création de la recette:", err) setError(err instanceof Error ? err.message : "Une erreur est survenue lors de la création de la recette") setRecordingStatus("idle") - } finally { setLoading(false) } } + // Libellés des étapes + const stepLabels: Record = { + saving_audio: "Envoi de ton enregistrement…", + transcribing: "Je t'écoute attentivement…", + generating_recipe: "Antoine invente ta recette…", + generating_image: "Préparation de la photo du plat…", + finalizing: "Derniers préparatifs…", + } + // Format recording time const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60) @@ -199,13 +250,80 @@ export default function RecipeForm() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="flex-1 flex flex-col items-center justify-center" + className="flex-1 flex flex-col items-center justify-center px-4" > - -

Préparation en cours...

-

- Notre chef IA mijote quelque chose de délicieux avec vos ingrédients -

+ {/* Image en cours de génération ou finale */} +
+ {liveImageUrl ? ( + + ) : ( + + )} +
+ + {/* Titre live (apparaît dès que le stream le révèle) */} + + {liveTitle ? ( + + {liveTitle} + + ) : ( + + {stepLabels[progressStep] || "Préparation en cours…"} + + )} + + + {/* Description live */} + + {liveDescription && ( + + « {liveDescription} » + + )} + + + {/* Transcription live (quand pas encore de titre) */} + + {liveTranscription && !liveTitle && ( + + Ingrédients détectés : + {liveTranscription} + + )} + + + {/* Libellé d'étape quand un titre est déjà affiché */} + {liveTitle && progressStep && ( +

+ {stepLabels[progressStep] || "En cours…"} +

+ )} ) : isRecording ? (