285 lines
12 KiB
JavaScript
285 lines
12 KiB
JavaScript
/**
|
||
* AI Design Advisor — GPT-4o powered design tips, material recommendations, and DALL-E 3 renders
|
||
* Helps customers feel confident about their wardrobe design choices.
|
||
*/
|
||
import OpenAI from 'openai';
|
||
import crypto from 'crypto';
|
||
|
||
let openaiClient = null;
|
||
|
||
function getOpenAIClient() {
|
||
if (!openaiClient) {
|
||
if (!process.env.OPENAI_API_KEY) {
|
||
throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env');
|
||
}
|
||
openaiClient = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||
}
|
||
return openaiClient;
|
||
}
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* 1. Design Tips (GPT-4o text) */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
const DESIGN_TIP_PROMPT = `Tu es un designer d'intérieur suisse spécialisé dans les armoires sur mesure haut de gamme.
|
||
À partir de la configuration ci-dessous, donne UN SEUL conseil ou commentaire positif en 1-2 phrases maximum.
|
||
Le ton doit être chaleureux, professionnel et encourageant — le client doit se sentir confiant dans son choix.
|
||
|
||
Varie tes réponses : parfois un compliment sur la combinaison, parfois un conseil pratique, parfois une info sur les tendances.
|
||
|
||
Réponds UNIQUEMENT en JSON :
|
||
{ "tip": "…votre conseil en français…", "category": "compliment" | "conseil" | "tendance" }`;
|
||
|
||
/**
|
||
* Generate a design tip for the current configuration
|
||
* @param {object} config - { width, height, depth, columns (array of {type, shelves, drawers, hasRod}), material, installationType }
|
||
* @returns {{ tip: string, category: string }}
|
||
*/
|
||
export async function getDesignTip(config) {
|
||
const client = getOpenAIClient();
|
||
|
||
const configSummary = buildConfigSummary(config);
|
||
|
||
const response = await client.chat.completions.create({
|
||
model: 'gpt-4o',
|
||
temperature: 0.9,
|
||
max_tokens: 200,
|
||
messages: [
|
||
{ role: 'system', content: DESIGN_TIP_PROMPT },
|
||
{ role: 'user', content: configSummary }
|
||
],
|
||
response_format: { type: 'json_object' }
|
||
});
|
||
|
||
const raw = response.choices[0]?.message?.content;
|
||
try {
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
return { tip: raw, category: 'conseil' };
|
||
}
|
||
}
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* 2. Material Recommendations (GPT-4o) */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
const MATERIAL_REC_PROMPT = `Tu es un conseiller en matériaux et couleurs pour armoires sur mesure suisses haut de gamme.
|
||
À partir de la configuration actuelle, des PRIORITÉS UTILISATEUR (échelle 1–5) et de la liste de matériaux disponibles, recommande 3 à 5 finitions.
|
||
|
||
Règles :
|
||
- Les priorités indiquent ce qui compte le PLUS pour le client (5 = très important, 1 = peu important). Tu dois PONDÉRER tes choix en conséquence (ex. : priorité « prix » élevée → privilégier des décors à bon rapport qualité-prix et l'expliquer ; priorité « qualité » élevée → insister sur la noblesse de la finition, la précision du relief, etc.).
|
||
- Si un prix indicatif [CHF] est fourni pour un matériau, utilise-le honnêtement pour le critère prix.
|
||
- Pour CHAQUE recommandation, explique CLAIREMENT POURQUOI tu proposes CE matériau : lien explicite avec les priorités du client (pas seulement « joli »).
|
||
- Remplis les champs criteria avec une phrase courte CHACUN (en français) : ce que ce décor apporte sur ce critère, au regard des priorités.
|
||
|
||
Réponds UNIQUEMENT en JSON valide :
|
||
{
|
||
"recommendations": [
|
||
{
|
||
"finishId": <number>,
|
||
"summary": "2 phrases max : pourquoi ce choix au regard des priorités",
|
||
"criteria": {
|
||
"price": "phrase sur le rapport prix / gamme / accessibilité",
|
||
"quality": "phrase sur la qualité de finition, rendu, sensation",
|
||
"durability": "phrase sur tenue dans le temps, entretien, résistance",
|
||
"aesthetics": "phrase sur l'harmonie couleur/style avec le projet"
|
||
},
|
||
"reason": "une ligne très courte pour infobulle (optionnel, peut résumer summary)"
|
||
}
|
||
]
|
||
}`;
|
||
|
||
function clampPriority(n, fallback = 3) {
|
||
const x = Number(n);
|
||
if (!Number.isFinite(x)) return fallback;
|
||
return Math.min(5, Math.max(1, Math.round(x)));
|
||
}
|
||
|
||
/**
|
||
* @param {object} [p] - { price, quality, durability, aesthetics } each 1–5
|
||
*/
|
||
export function normalizeRecommendationPriorities(p) {
|
||
const d = { price: 3, quality: 3, durability: 3, aesthetics: 3 };
|
||
if (!p || typeof p !== 'object') return { ...d };
|
||
return {
|
||
price: clampPriority(p.price, d.price),
|
||
quality: clampPriority(p.quality, d.quality),
|
||
durability: clampPriority(p.durability, d.durability),
|
||
aesthetics: clampPriority(p.aesthetics, d.aesthetics)
|
||
};
|
||
}
|
||
|
||
function normalizeMaterialRecommendation(rec) {
|
||
if (!rec || rec.finishId == null) return null;
|
||
const criteria = rec.criteria && typeof rec.criteria === 'object' ? rec.criteria : {};
|
||
const summary = (rec.summary || rec.reason || '').trim();
|
||
const reason = (rec.reason || summary).trim();
|
||
return {
|
||
finishId: Number(rec.finishId),
|
||
summary,
|
||
reason,
|
||
criteria: {
|
||
price: String(criteria.price || '').trim(),
|
||
quality: String(criteria.quality || '').trim(),
|
||
durability: String(criteria.durability || '').trim(),
|
||
aesthetics: String(criteria.aesthetics || '').trim()
|
||
}
|
||
};
|
||
}
|
||
|
||
function buildPrioritiesBlock(priorities) {
|
||
const p = normalizeRecommendationPriorities(priorities);
|
||
return `PRIORITÉS UTILISATEUR (1 = peu important, 5 = très important — ajuste tes recommandations et tes explications en conséquence) :
|
||
- Prix / rapport qualité-prix : ${p.price}
|
||
- Qualité perçue & finition : ${p.quality}
|
||
- Durabilité & entretien : ${p.durability}
|
||
- Esthétique & harmonie avec le projet : ${p.aesthetics}`;
|
||
}
|
||
|
||
/**
|
||
* Recommend materials that complement the current configuration
|
||
* @param {object} config - Current wardrobe configuration
|
||
* @param {Array} availableFinishes - [{ id, name, collection, code, price_chf? }]
|
||
* @param {object} [roomAnalysis] - Optional room photo analysis
|
||
* @param {object} [priorities] - { price, quality, durability, aesthetics } each 1–5
|
||
* @returns {{ recommendations: Array<{ finishId, summary, reason, criteria }> }}
|
||
*/
|
||
export async function recommendMaterials(config, availableFinishes, roomAnalysis, priorities) {
|
||
const client = getOpenAIClient();
|
||
|
||
const configSummary = buildConfigSummary(config);
|
||
const priBlock = buildPrioritiesBlock(priorities);
|
||
|
||
// Compact finish list (names + ids + optional price for fair "prix" explanations)
|
||
const finishList = availableFinishes
|
||
.slice(0, 80) // limit to avoid token overflow
|
||
.map((f) => {
|
||
const name = f.name || f.label || '?';
|
||
const col = f.collection || '?';
|
||
const price =
|
||
f.price_chf != null && Number.isFinite(Number(f.price_chf))
|
||
? ` — indicatif ${Number(f.price_chf).toFixed(2)} CHF`
|
||
: '';
|
||
return `${f.id}: ${name} (${col})${price}`;
|
||
})
|
||
.join('\n');
|
||
|
||
let userMessage = `${priBlock}\n\nCONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`;
|
||
|
||
if (roomAnalysis) {
|
||
userMessage += `\n\nCONTEXTE PIÈCE:\n${JSON.stringify(roomAnalysis, null, 1)}`;
|
||
}
|
||
|
||
const response = await client.chat.completions.create({
|
||
model: 'gpt-4o',
|
||
temperature: 0.65,
|
||
max_tokens: 1400,
|
||
messages: [
|
||
{ role: 'system', content: MATERIAL_REC_PROMPT },
|
||
{ role: 'user', content: userMessage }
|
||
],
|
||
response_format: { type: 'json_object' }
|
||
});
|
||
|
||
const raw = response.choices[0]?.message?.content;
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
const rawList = (parsed.recommendations || []).slice(0, 5);
|
||
const recommendations = rawList
|
||
.map(normalizeMaterialRecommendation)
|
||
.filter(Boolean);
|
||
return { recommendations, priorities: normalizeRecommendationPriorities(priorities) };
|
||
} catch {
|
||
return { recommendations: [], priorities: normalizeRecommendationPriorities(priorities) };
|
||
}
|
||
}
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* 3. Photorealistic Render (DALL-E 3) */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
// Simple in-memory render cache (config hash → image URL)
|
||
const renderCache = new Map();
|
||
|
||
/**
|
||
* Generate a photorealistic render of the configured wardrobe using DALL-E 3
|
||
* @param {object} config - Current wardrobe configuration
|
||
* @param {object} [roomContext] - Optional room analysis for context
|
||
* @returns {{ imageUrl: string, cached: boolean }}
|
||
*/
|
||
export async function generateRender(config, roomContext) {
|
||
const client = getOpenAIClient();
|
||
|
||
// Cache by config hash
|
||
const hash = crypto.createHash('md5').update(JSON.stringify(config)).digest('hex');
|
||
if (renderCache.has(hash)) {
|
||
return { imageUrl: renderCache.get(hash), cached: true };
|
||
}
|
||
|
||
const prompt = buildRenderPrompt(config, roomContext);
|
||
|
||
const response = await client.images.generate({
|
||
model: 'dall-e-3',
|
||
prompt,
|
||
n: 1,
|
||
size: '1792x1024',
|
||
quality: 'hd',
|
||
style: 'natural'
|
||
});
|
||
|
||
const imageUrl = response.data[0]?.url;
|
||
|
||
if (imageUrl) {
|
||
renderCache.set(hash, imageUrl);
|
||
// Auto-evict after 30 minutes (DALL-E URLs expire after ~1h)
|
||
setTimeout(() => renderCache.delete(hash), 30 * 60 * 1000);
|
||
}
|
||
|
||
return { imageUrl, cached: false };
|
||
}
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* Helpers */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
function buildConfigSummary(config) {
|
||
const columns = (config.columns || config.modules || [])
|
||
.map((c, i) => ` Colonne ${i + 1}: ${c.type || c.moduleCode || '?'} — ${c.width || '?'}mm, ${c.shelves || 0} étagères, ${c.drawers || 0} tiroirs${c.hasRod ? ', tringle' : ''}`)
|
||
.join('\n');
|
||
|
||
return `Armoire sur mesure SIGNATURE
|
||
Dimensions: ${config.width || '?'}mm × ${config.height || '?'}mm × ${config.depth || '?'}mm
|
||
Installation: ${config.installationType || 'libre'}
|
||
Colonnes:\n${columns}
|
||
Matériau principal: ${config.materialName || config.finishLabel || 'non défini'}
|
||
Portes: ${config.doorType || 'battantes'}`;
|
||
}
|
||
|
||
function buildRenderPrompt(config, roomContext) {
|
||
const cols = (config.columns || config.modules || []);
|
||
const colDesc = cols.map((c, i) => {
|
||
const type = c.type || c.moduleCode || 'standard';
|
||
const parts = [];
|
||
if (c.hasRod) parts.push('a hanging rod');
|
||
if (c.shelves > 0) parts.push(`${c.shelves} shelves`);
|
||
if (c.drawers > 0) parts.push(`${c.drawers} drawers`);
|
||
return `column ${i + 1}: ${type} with ${parts.join(', ') || 'empty space'}`;
|
||
}).join('; ');
|
||
|
||
const widthM = ((config.width || 2400) / 1000).toFixed(1);
|
||
const heightM = ((config.height || 2400) / 1000).toFixed(1);
|
||
|
||
let roomDesc = 'a modern, well-lit bedroom with neutral walls';
|
||
if (roomContext) {
|
||
const walls = roomContext.walls || [];
|
||
const wallColors = walls.filter(w => w.color).map(w => w.color).join(', ');
|
||
roomDesc = `a ${roomContext.roomType || 'bedroom'} with ${wallColors || 'neutral'} walls`;
|
||
}
|
||
|
||
return `Professional interior design photograph of a premium Swiss-made custom wardrobe in ${roomDesc}.
|
||
The wardrobe is ${widthM}m wide and ${heightM}m tall, built-in style, with ${cols.length} columns: ${colDesc}.
|
||
Material: ${config.materialName || 'light oak wood finish'}. Doors are ${config.doorType === 'coulissantes' ? 'sliding' : 'hinged'}, partially open to reveal the interior.
|
||
Style: clean Scandinavian-Swiss aesthetic, warm natural lighting, photorealistic quality. Shot with a wide-angle lens at eye level.
|
||
The wardrobe should look premium, well-crafted, and perfectly integrated into the room.`;
|
||
}
|