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) <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-08 12:53:55 +02:00
parent 98a2f9d5a1
commit 460f7d334c
13 changed files with 1103 additions and 39 deletions

View File

@ -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;

View File

@ -12,11 +12,20 @@ model User {
email String @unique
password String? // Optionnel pour les utilisateurs Google
name String
googleId String? @unique // ID Google pour SSO
stripeId String @unique
googleId String? @unique
stripeId String? @unique // Optionnel : Stripe peut être désactivé
subscription String?
resetToken String? // Token pour la réinitialisation du mot de passe
resetTokenExpiry DateTime? // Date d'expiration du token
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[]

View File

@ -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<string, string> = {
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');
}
/**

View File

@ -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<RecipeStreamEvent, void, void> {
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 };
}

View File

@ -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<RecipeData> => {
async (
ingredients: string,
hint?: string,
preferences?: unknown
): Promise<RecipeData> => {
// 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

View File

@ -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;

View File

@ -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<string, unknown> = {};
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;

View File

@ -57,7 +57,11 @@ declare module 'fastify' {
// ai plugin
openai: OpenAI;
transcribeAudio: (audioInput: AudioInput) => Promise<string>;
generateRecipe: (ingredients: string, prompt?: string) => Promise<RecipeData>;
generateRecipe: (
ingredients: string,
prompt?: string,
preferences?: unknown
) => Promise<RecipeData>;
saveAudioFile: (file: MultipartFile) => Promise<AudioSaveResult>;
// google-auth plugin

View File

@ -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 {

View File

@ -79,8 +79,120 @@ export const deleteRecipe = async (id: string): Promise<boolean> => {
}
};
// -----------------------------------------------------------------------------
// 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<StreamEvent, void, void> {
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,

View File

@ -1,4 +1,5 @@
import { apiService } from "./base";
import type { User, UserPreferences } from "./auth";
const userService = {
getCurrentUser: async (): Promise<User> => {
@ -11,6 +12,14 @@ const userService = {
return response.user;
},
updatePreferences: async (data: Partial<UserPreferences>): Promise<UserPreferences> => {
const response = await apiService.put<{ preferences: UserPreferences }>(
'users/preferences',
data
);
return response.preferences;
},
changePassword: async (data: { currentPassword: string; newPassword: string }): Promise<void> => {
return apiService.put('users/change-password', data);
},

View File

@ -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() {
<div className="flex-1">
<Tabs defaultValue="profile">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="profile">
<User className="mr-2 h-4 w-4" />
Profil
</TabsTrigger>
<TabsTrigger value="cuisine">
<ChefHat className="mr-2 h-4 w-4" />
Cuisine
</TabsTrigger>
<TabsTrigger value="email">
<Mail className="mr-2 h-4 w-4" />
Email
</TabsTrigger>
<TabsTrigger value="security">
<Lock className="mr-2 h-4 w-4" />
Mot de passe
Sécurité
</TabsTrigger>
</TabsList>
@ -374,6 +454,138 @@ export default function Profile() {
</Card>
</TabsContent>
<TabsContent value="cuisine" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Préférences culinaires</CardTitle>
<CardDescription>
Ces informations sont transmises au chef IA pour personnaliser chaque recette
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handlePrefsSubmit} className="space-y-6">
{/* Régime */}
<div className="space-y-2">
<Label htmlFor="diet">Régime alimentaire</Label>
<select
id="diet"
value={prefsForm.dietaryPreference}
onChange={(e) =>
setPrefsForm({ ...prefsForm, dietaryPreference: e.target.value })
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="none">Aucun (omnivore)</option>
<option value="vegetarian">Végétarien</option>
<option value="vegan">Végan</option>
<option value="pescatarian">Pescétarien</option>
</select>
</div>
{/* Allergies */}
<div className="space-y-2">
<Label htmlFor="allergies">Allergies et intolérances</Label>
<Input
id="allergies"
placeholder="arachides, gluten, lactose..."
value={prefsForm.allergies}
onChange={(e) =>
setPrefsForm({ ...prefsForm, allergies: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
Séparées par des virgules. Le chef les évitera impérativement.
</p>
</div>
{/* Temps max */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="maxTime">Temps max (minutes)</Label>
<Input
id="maxTime"
type="number"
min="5"
max="600"
placeholder="60"
value={prefsForm.maxCookingTime}
onChange={(e) =>
setPrefsForm({ ...prefsForm, maxCookingTime: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="servings">Portions par défaut</Label>
<Input
id="servings"
type="number"
min="1"
max="20"
placeholder="4"
value={prefsForm.servingsDefault}
onChange={(e) =>
setPrefsForm({ ...prefsForm, servingsDefault: e.target.value })
}
/>
</div>
</div>
{/* Cuisine préférée */}
<div className="space-y-2">
<Label htmlFor="cuisine">Inspiration culinaire</Label>
<Input
id="cuisine"
placeholder="française, italienne, asiatique, libanaise..."
value={prefsForm.cuisinePreference}
onChange={(e) =>
setPrefsForm({ ...prefsForm, cuisinePreference: e.target.value })
}
/>
</div>
{/* Équipement */}
<div className="space-y-3">
<Label>Équipement disponible</Label>
<div className="grid grid-cols-2 gap-3">
{EQUIPMENT_OPTIONS.map((opt) => (
<div key={opt.key} className="flex items-center space-x-2">
<Checkbox
id={`eq-${opt.key}`}
checked={prefsForm.equipment.includes(opt.key)}
onCheckedChange={() => toggleEquipment(opt.key)}
/>
<label
htmlFor={`eq-${opt.key}`}
className="text-sm font-medium leading-none cursor-pointer"
>
{opt.label}
</label>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Le chef n'utilisera que des techniques compatibles avec ton équipement.
</p>
</div>
<Button type="submit" disabled={saving}>
{saving ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer les préférences
</>
)}
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="email" className="mt-6">
<Card>
<CardHeader>

View File

@ -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<string>("")
const [liveTranscription, setLiveTranscription] = useState("")
const [liveTitle, setLiveTitle] = useState("")
const [liveDescription, setLiveDescription] = useState("")
const [liveImageUrl, setLiveImageUrl] = useState<string | null>(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<string, string> = {
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"
>
{/* Image en cours de génération ou finale */}
<div className="relative w-48 h-48 mb-6">
{liveImageUrl ? (
<motion.img
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
src={liveImageUrl}
alt={liveTitle || "Recette"}
className="w-48 h-48 object-cover rounded-2xl shadow-lg"
/>
) : (
<CookingLoader />
<h2 className="mt-6 text-xl font-medium text-center">Préparation en cours...</h2>
<p className="text-sm text-muted-foreground text-center mt-2 max-w-xs">
Notre chef IA mijote quelque chose de délicieux avec vos ingrédients
)}
</div>
{/* Titre live (apparaît dès que le stream le révèle) */}
<AnimatePresence mode="wait">
{liveTitle ? (
<motion.h2
key="title"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-2xl font-semibold text-center max-w-xs"
>
{liveTitle}
</motion.h2>
) : (
<motion.h2
key="default"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-xl font-medium text-center"
>
{stepLabels[progressStep] || "Préparation en cours…"}
</motion.h2>
)}
</AnimatePresence>
{/* Description live */}
<AnimatePresence>
{liveDescription && (
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-sm text-muted-foreground text-center mt-3 max-w-sm italic"
>
« {liveDescription} »
</motion.p>
)}
</AnimatePresence>
{/* Transcription live (quand pas encore de titre) */}
<AnimatePresence>
{liveTranscription && !liveTitle && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-xs text-muted-foreground text-center mt-4 max-w-xs"
>
<span className="font-medium">Ingrédients détectés : </span>
{liveTranscription}
</motion.p>
)}
</AnimatePresence>
{/* Libellé d'étape quand un titre est déjà affiché */}
{liveTitle && progressStep && (
<p className="text-xs text-muted-foreground mt-4 animate-pulse">
{stepLabels[progressStep] || "En cours…"}
</p>
)}
</motion.div>
) : isRecording ? (
<motion.div