feat: amélioration de l'assistant IA logisticien et de la gestion des containers
- **Backend (Cloud Functions)** :
- Mise à jour de `firebase-functions` vers la version `7.2.5`.
- Amélioration de la sécurité et de la flexibilité des clés API Gemini (support des variables d'environnement `.env` et `.env.local`).
- Optimisation de la recherche d'équipements avec une stratégie multi-passes (exacte, par tokens, puis catégorielle/fuzzy).
- Ajout de nouveaux outils pour l'IA : `check_container_availability` et `check_container_availability_batch` pour vérifier la disponibilité des flight-cases.
- Implémentation d'un post-traitement automatique suggérant des containers complets si tous leurs équipements internes sont requis par l'événement.
- Amélioration de la résilience aux erreurs 429/503 de Gemini avec une stratégie d'exponential backoff.
- **Frontend (Flutter)** :
- Mise à jour du service `AiEquipmentAssistantService` pour gérer les métadonnées détaillées des containers (rationale, items manquants/matchings, disponibilité).
- Refonte de l'interface `AiEquipmentAssistantDialog` :
- Affichage enrichi des containers dans le récapitulatif.
- Ajout de la possibilité de sélectionner/désélectionner manuellement les containers (notamment ceux marqués comme "partiels").
- Amélioration visuelle (ombres, bordures, icônes de statut de disponibilité).
- Marquage de l'assistant en mode "BETA".
- **Général** :
- Mise à jour du `.gitignore` pour inclure `functions/.env.local`.
- Correction de typos et amélioration du logging de debug dans le backend.
This commit is contained in:
@@ -45,3 +45,4 @@ app.*.map.json
|
|||||||
# Environment configuration with credentials
|
# Environment configuration with credentials
|
||||||
lib/config/env.dev.dart
|
lib/config/env.dev.dart
|
||||||
functions/.env
|
functions/.env
|
||||||
|
functions/.env.local
|
||||||
@@ -9,8 +9,16 @@ const { GoogleGenerativeAI } = require('@google/generative-ai');
|
|||||||
const admin = require('firebase-admin');
|
const admin = require('firebase-admin');
|
||||||
const logger = require('firebase-functions/logger');
|
const logger = require('firebase-functions/logger');
|
||||||
|
|
||||||
const GEMINI_MODEL = 'gemini-3.1-flash-lite'; //Ne pas changer de versio, celle ci existe
|
const GEMINI_MODEL = 'gemini-3.1-flash-lite'; //Ne pas changer de version, celle ci existe
|
||||||
const GEMINI_API_KEY = 'AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc';
|
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_AI_API_KEY || '';
|
||||||
|
|
||||||
|
// Log de démarrage pour debug
|
||||||
|
if (GEMINI_API_KEY) {
|
||||||
|
const maskedKey = GEMINI_API_KEY.substring(0, 8) + '...' + GEMINI_API_KEY.substring(GEMINI_API_KEY.length - 5);
|
||||||
|
logger.info(`[AI] GEMINI_API_KEY chargée (masquée: ${maskedKey})`);
|
||||||
|
} else {
|
||||||
|
logger.warn('[AI] ⚠️ GEMINI_API_KEY non trouvée ! Vérifiez .env.local ou les variables d\'environnement Firebase');
|
||||||
|
}
|
||||||
const MAX_TOOL_ITERATIONS = 20;
|
const MAX_TOOL_ITERATIONS = 20;
|
||||||
const PAST_EVENTS_LIMIT = 5;
|
const PAST_EVENTS_LIMIT = 5;
|
||||||
const SEARCH_RESULTS_LIMIT = 50; // Augmenté pour permettre de trouver plusieurs unités du même modèle
|
const SEARCH_RESULTS_LIMIT = 50; // Augmenté pour permettre de trouver plusieurs unités du même modèle
|
||||||
@@ -140,6 +148,32 @@ const AI_TOOLS = [
|
|||||||
required: ['equipmentIds'],
|
required: ['equipmentIds'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'check_container_availability',
|
||||||
|
description: 'Vérifie si un container (flight case) et son contenu sont disponibles pour une période donnée.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
containerId: { type: 'string', description: 'ID exact du container à vérifier.' },
|
||||||
|
startDate: { type: 'string', description: 'Date de début ISO-8601.' },
|
||||||
|
endDate: { type: 'string', description: 'Date de fin ISO-8601.' },
|
||||||
|
},
|
||||||
|
required: ['containerId', 'startDate', 'endDate'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'check_container_availability_batch',
|
||||||
|
description: 'Vérifie la disponibilité d une liste de containers pour la même période en un seul appel.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
containerIds: { type: 'array', items: { type: 'string' }, description: 'Liste des IDs de containers (max 50).' },
|
||||||
|
startDate: { type: 'string', description: 'Date de début ISO-8601.' },
|
||||||
|
endDate: { type: 'string', description: 'Date de fin ISO-8601.' },
|
||||||
|
},
|
||||||
|
required: ['containerIds', 'startDate', 'endDate'],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'list_equipment_by_category',
|
name: 'list_equipment_by_category',
|
||||||
description: 'Liste le materiel d\'une categorie ou sous-categorie specifique. Utile si search_equipment ne donne rien a cause d\'une faute de frappe ou pour explorer les alternatives.',
|
description: 'Liste le materiel d\'une categorie ou sous-categorie specifique. Utile si search_equipment ne donne rien a cause d\'une faute de frappe ou pour explorer les alternatives.',
|
||||||
@@ -226,50 +260,96 @@ async function toolSearchEquipment(query) {
|
|||||||
return { query, count: 0, results: [] };
|
return { query, count: 0, results: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalisation agressive pour la recherche (sans accents, sans ponctuation)
|
// Normalisation pour comparer proprement
|
||||||
const normalize = (str) =>
|
const normalize = (str) =>
|
||||||
(str || '')
|
(str || '')
|
||||||
|
.toString()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
.replace(/[^a-z0-9]/g, '');
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
const normalizedQuery = normalize(query);
|
const normalizedQuery = normalize(query);
|
||||||
|
const tokens = extractQueryTokens(query);
|
||||||
|
|
||||||
|
// Charger un snapshot raisonnable une seule fois
|
||||||
const snapshot = await getDb().collection('equipments').limit(500).get();
|
const snapshot = await getDb().collection('equipments').limit(500).get();
|
||||||
|
|
||||||
|
const docs = snapshot.docs.map((doc) => {
|
||||||
|
const data = doc.data();
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
name: String(data.name || doc.id),
|
||||||
|
category: data.category || '',
|
||||||
|
subCategory: data.subCategory || '',
|
||||||
|
brand: data.brand || null,
|
||||||
|
model: data.model || null,
|
||||||
|
status: data.status || '',
|
||||||
|
availableQuantity: data.availableQuantity ?? null,
|
||||||
|
totalQuantity: data.totalQuantity ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
snapshot.docs.forEach((doc) => {
|
// PASS 1 : recherche stricte / exacte (id, nom complet, modele)
|
||||||
const data = doc.data();
|
for (const item of docs) {
|
||||||
const searchableFields = [
|
const candText = normalize([item.id, item.name, item.brand, item.model].join(' '));
|
||||||
data.name,
|
if (
|
||||||
doc.id,
|
candText === normalizedQuery
|
||||||
data.model,
|
|| candText.includes(normalizedQuery)
|
||||||
data.brand,
|
|| normalizedQuery.includes(candText)
|
||||||
data.category,
|
) {
|
||||||
data.subCategory,
|
results.push(item);
|
||||||
];
|
|
||||||
|
|
||||||
const isMatch = searchableFields.some(field => {
|
|
||||||
if (!field) return false;
|
|
||||||
const normalizedField = normalize(String(field));
|
|
||||||
return normalizedField.includes(normalizedQuery) || normalizedQuery.includes(normalizedField);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isMatch) {
|
|
||||||
results.push({
|
|
||||||
id: doc.id,
|
|
||||||
name: data.name || doc.id,
|
|
||||||
category: data.category || '',
|
|
||||||
subCategory: data.subCategory || '',
|
|
||||||
brand: data.brand || null,
|
|
||||||
model: data.model || null,
|
|
||||||
status: data.status || '',
|
|
||||||
availableQuantity: data.availableQuantity ?? null,
|
|
||||||
totalQuantity: data.totalQuantity ?? null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// PASS 2 : recherche par tokens (mots-clés, marque, modèle partiel)
|
||||||
|
if (results.length === 0) {
|
||||||
|
for (const item of docs) {
|
||||||
|
const candText = normalize([item.id, item.name, item.brand, item.model].join(' '));
|
||||||
|
const tokenMatch = tokens.some((t) => candText.includes(t));
|
||||||
|
if (tokenMatch) results.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASS 3 : fallback catégoriel si aucune correspondance directe
|
||||||
|
if (results.length === 0) {
|
||||||
|
// Essayer d'identifier une marque ou categorie depuis les tokens
|
||||||
|
const possibleBrand = tokens.length > 0 ? tokens[0] : null;
|
||||||
|
// Rechercher d'abord par brand
|
||||||
|
if (possibleBrand) {
|
||||||
|
for (const item of docs) {
|
||||||
|
if (item.brand && normalize(String(item.brand)).includes(possibleBrand)) {
|
||||||
|
results.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si toujours rien, appeler list_equipment_by_category pour élargir la recherche
|
||||||
|
if (results.length === 0) {
|
||||||
|
// tenter de deviner une catégorie depuis la query (prend premier token long)
|
||||||
|
const guessedCategory = tokens.find((t) => t.length >= 3) || null;
|
||||||
|
if (guessedCategory) {
|
||||||
|
try {
|
||||||
|
const catRes = await toolListEquipmentByCategory(guessedCategory, null);
|
||||||
|
if (catRes && Array.isArray(catRes.results) && catRes.results.length > 0) {
|
||||||
|
// essayer un fuzzy match dans la categorie
|
||||||
|
const normQuery = normalizedQuery;
|
||||||
|
for (const itm of catRes.results) {
|
||||||
|
const cand = normalize([itm.id, itm.name, itm.brand, itm.model].join(' '));
|
||||||
|
if (cand.includes(normQuery) || tokens.some((t) => cand.includes(t))) {
|
||||||
|
results.push(itm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore fallback errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const limited = results.slice(0, SEARCH_RESULTS_LIMIT);
|
const limited = results.slice(0, SEARCH_RESULTS_LIMIT);
|
||||||
|
|
||||||
@@ -411,6 +491,125 @@ function buildAvailabilityResultForEquipment({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAvailabilityResultForContainer({
|
||||||
|
containerDoc,
|
||||||
|
containerId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
candidateDocs,
|
||||||
|
excludeEventId,
|
||||||
|
}) {
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
for (const eventDoc of candidateDocs) {
|
||||||
|
if (excludeEventId && eventDoc.id === excludeEventId) continue;
|
||||||
|
|
||||||
|
const event = eventDoc.data();
|
||||||
|
if (isCancelledStatus(event.status)) continue;
|
||||||
|
|
||||||
|
const eventStart = toDateSafe(event.StartDateTime);
|
||||||
|
const eventEnd = toDateSafe(event.EndDateTime);
|
||||||
|
if (!eventStart || !eventEnd) continue;
|
||||||
|
|
||||||
|
// Vérifier si le container est assigné à cet événement
|
||||||
|
const assignedContainers = Array.isArray(event.assignedContainers) ? event.assignedContainers : [];
|
||||||
|
const isAssigned = assignedContainers.some((c) => String(c) === String(containerId));
|
||||||
|
|
||||||
|
// Vérifier chevauchement de dates
|
||||||
|
const hasOverlap = start < eventEnd && end > eventStart;
|
||||||
|
if (!hasOverlap) continue;
|
||||||
|
|
||||||
|
if (isAssigned) {
|
||||||
|
conflicts.push({
|
||||||
|
eventId: eventDoc.id,
|
||||||
|
eventName: event.name || event.Name || 'Événement sans nom',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerId: containerId,
|
||||||
|
available: conflicts.length === 0,
|
||||||
|
conflictCount: conflicts.length,
|
||||||
|
conflicts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie la disponibilité d'un container et son contenu sur une période donnée.
|
||||||
|
*/
|
||||||
|
async function toolCheckContainerAvailability(containerId, startDate, endDate, excludeEventId, sharedContext) {
|
||||||
|
if (!containerId || !startDate || !endDate) {
|
||||||
|
return { error: 'containerId, startDate et endDate sont requis.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) return { error: 'Dates invalides.' };
|
||||||
|
|
||||||
|
// Charger le container
|
||||||
|
const containerDocRef = await getDb().collection('containers').doc(containerId).get();
|
||||||
|
if (!containerDocRef.exists) {
|
||||||
|
return { containerId, error: 'Container introuvable.' };
|
||||||
|
}
|
||||||
|
const containerData = containerDocRef.data() || {};
|
||||||
|
const equipmentIds = Array.isArray(containerData.equipmentIds) ? containerData.equipmentIds.map((id) => String(id)) : [];
|
||||||
|
|
||||||
|
const candidateDocs = await loadAvailabilityCandidates(start, sharedContext);
|
||||||
|
|
||||||
|
// Vérifier si le container lui-même est assigné
|
||||||
|
const containerAvailability = buildAvailabilityResultForContainer({
|
||||||
|
containerDoc: containerData,
|
||||||
|
containerId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
candidateDocs,
|
||||||
|
excludeEventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vérifier aussi la disponibilité des équipements internes
|
||||||
|
let membersAvailability = null;
|
||||||
|
if (equipmentIds.length > 0) {
|
||||||
|
const batchRes = await toolCheckAvailabilityBatch(equipmentIds, startDate, endDate, excludeEventId, sharedContext);
|
||||||
|
membersAvailability = Array.isArray(batchRes.results) ? batchRes.results : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si un membre est indisponible ou le container est assigné -> indisponible
|
||||||
|
const anyMemberConflict = Array.isArray(membersAvailability) && membersAvailability.some((m) => m && m.available === false);
|
||||||
|
const finalAvailable = containerAvailability.available && !anyMemberConflict;
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerId,
|
||||||
|
available: finalAvailable,
|
||||||
|
containerConflicts: containerAvailability.conflicts || [],
|
||||||
|
memberAvailability: membersAvailability || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie en batch la disponibilité de plusieurs containers.
|
||||||
|
*/
|
||||||
|
async function toolCheckContainerAvailabilityBatch(containerIds, startDate, endDate, excludeEventId, sharedContext) {
|
||||||
|
if (!Array.isArray(containerIds) || containerIds.length === 0 || !startDate || !endDate) {
|
||||||
|
return { error: 'containerIds (array), startDate et endDate sont requis.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = Array.from(new Set(containerIds.map((c) => String(c || '').trim()).filter(Boolean))).slice(0, MAX_BATCH_AVAILABILITY_ITEMS);
|
||||||
|
if (normalized.length === 0) return { error: 'Aucun containerId valide.' };
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const cid of normalized) {
|
||||||
|
try {
|
||||||
|
const r = await toolCheckContainerAvailability(cid, startDate, endDate, excludeEventId, sharedContext);
|
||||||
|
results.push(r);
|
||||||
|
} catch (e) {
|
||||||
|
results.push({ containerId: cid, error: e?.message || 'Erreur interne' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startDate, endDate, count: results.length, results };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vérifie la disponibilité d'une liste d'équipements pour la même période.
|
* Vérifie la disponibilité d'une liste d'équipements pour la même période.
|
||||||
*/
|
*/
|
||||||
@@ -530,6 +729,20 @@ function normalizeSearchText(value) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne true si le message utilisateur autorise l'utilisation de containers.
|
||||||
|
* Détecte quelques formulations négatives courantes en français/anglais.
|
||||||
|
*/
|
||||||
|
function userAllowsContainers(userMessage) {
|
||||||
|
if (!userMessage || userMessage.toString().trim().length === 0) return true;
|
||||||
|
const txt = userMessage.toString().toLowerCase();
|
||||||
|
const negativePatterns = [
|
||||||
|
'pas de container', 'sans container', 'pas de flight', 'sans flight', 'pas de flight case', 'sans flight case',
|
||||||
|
'ne pas utiliser', 'ne pas prendre', 'do not use', 'no container', 'no flight', 'no flight case'
|
||||||
|
];
|
||||||
|
return !negativePatterns.some((pat) => txt.includes(pat));
|
||||||
|
}
|
||||||
|
|
||||||
function parseDateHintFlexible(value) {
|
function parseDateHintFlexible(value) {
|
||||||
const directDate = toDateSafe(value);
|
const directDate = toDateSafe(value);
|
||||||
if (directDate) {
|
if (directDate) {
|
||||||
@@ -949,6 +1162,24 @@ async function executeTool(toolCall, excludeEventId, sharedContext) {
|
|||||||
case 'search_containers':
|
case 'search_containers':
|
||||||
return await toolSearchContainers(args.equipmentIds, args.query || null);
|
return await toolSearchContainers(args.equipmentIds, args.query || null);
|
||||||
|
|
||||||
|
case 'check_container_availability':
|
||||||
|
return await toolCheckContainerAvailability(
|
||||||
|
args.containerId,
|
||||||
|
args.startDate,
|
||||||
|
args.endDate,
|
||||||
|
excludeEventId,
|
||||||
|
sharedContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'check_container_availability_batch':
|
||||||
|
return await toolCheckContainerAvailabilityBatch(
|
||||||
|
args.containerIds,
|
||||||
|
args.startDate,
|
||||||
|
args.endDate,
|
||||||
|
excludeEventId,
|
||||||
|
sharedContext,
|
||||||
|
);
|
||||||
|
|
||||||
case 'list_equipment_by_category':
|
case 'list_equipment_by_category':
|
||||||
return await toolListEquipmentByCategory(args.category || null, args.subCategory || null);
|
return await toolListEquipmentByCategory(args.category || null, args.subCategory || null);
|
||||||
|
|
||||||
@@ -1132,10 +1363,13 @@ function parseAiResponse(rawText, logDebug, logError) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Envoie un message à Gemini avec une temporisation en cas de Rate Limit (429).
|
* Envoie un message à Gemini avec une stratégie de retry robuste.
|
||||||
|
* Exponential backoff : délais augmentent avec chaque tentative.
|
||||||
|
* 503 (surcharge) = plus d'attente que 429 (quota).
|
||||||
*/
|
*/
|
||||||
async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) {
|
async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) {
|
||||||
const maxRetries = 5;
|
const maxRetries = 8;
|
||||||
|
const baseDelayMs = 5000;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
@@ -1147,15 +1381,26 @@ async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) {
|
|||||||
const isOverloaded = status === 503 || errorMessage.includes('503') || errorMessage.includes('overloaded') || errorMessage.includes('high demand');
|
const isOverloaded = status === 503 || errorMessage.includes('503') || errorMessage.includes('overloaded') || errorMessage.includes('high demand');
|
||||||
|
|
||||||
if ((isRateLimit || isOverloaded) && attempt < maxRetries) {
|
if ((isRateLimit || isOverloaded) && attempt < maxRetries) {
|
||||||
let delayMs = isOverloaded ? 5000 : 30000; // Plus court pour 503, long pour 429
|
// Exponential backoff : 5s * (2 ^ (attempt - 1))
|
||||||
|
// Surcharge (503) : attente plus longue car le modèle est saturé
|
||||||
|
// Quota (429) : attente plus courte car c'est juste une limite de débit
|
||||||
|
const exponentialMultiplier = Math.pow(2, Math.max(0, attempt - 1));
|
||||||
|
let delayMs = isOverloaded
|
||||||
|
? baseDelayMs * exponentialMultiplier * 3 // 15s, 30s, 60s, 120s, etc.
|
||||||
|
: baseDelayMs * exponentialMultiplier; // 5s, 10s, 20s, 40s, etc.
|
||||||
|
|
||||||
|
// Si le serveur indique un délai spécifique, le respecter
|
||||||
const retryMatch = errorMessage.match(/retry in\s+([\d.]+)\s*s/i);
|
const retryMatch = errorMessage.match(/retry in\s+([\d.]+)\s*s/i);
|
||||||
if (retryMatch && !isNaN(parseFloat(retryMatch[1]))) {
|
if (retryMatch && !isNaN(parseFloat(retryMatch[1]))) {
|
||||||
delayMs = Math.ceil(parseFloat(retryMatch[1]) * 1000) + 2000;
|
delayMs = Math.ceil(parseFloat(retryMatch[1]) * 1000) + 2000;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reason = isOverloaded ? 'Modèle surchargé (503)' : 'Quota atteint (429)';
|
const reason = isOverloaded ? 'Modèle surchargé (503 - haute demande)' : 'Quota atteint (429)';
|
||||||
logDebug(`[AI] ${reason}. Temporisation de ${delayMs / 1000}s avant la tentative ${attempt + 1}/${maxRetries}...`);
|
logDebug(
|
||||||
|
`[AI] ${reason}. Exponential backoff : tentative ${attempt}/${maxRetries}, ` +
|
||||||
|
`attente de ${Math.round(delayMs / 1000)}s avant nouvelle tentative...`,
|
||||||
|
{ status, attempt, maxRetries }
|
||||||
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -1180,6 +1425,28 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
logger.error(msg, data);
|
logger.error(msg, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Récupérer la valeur de firebase functions:config si disponible
|
||||||
|
let firebaseConfigKey = null;
|
||||||
|
try {
|
||||||
|
const config = functions.config();
|
||||||
|
firebaseConfigKey = config.gemini?.api_key || config.gemini?.apikey;
|
||||||
|
} catch (e) {
|
||||||
|
// Ignoré
|
||||||
|
}
|
||||||
|
|
||||||
|
// Évaluer la clé dynamiquement au moment de l'appel
|
||||||
|
const dynamicApiKey = process.env.GEMINI_API_KEY || firebaseConfigKey || process.env.GOOGLE_AI_API_KEY;
|
||||||
|
|
||||||
|
if (!dynamicApiKey) {
|
||||||
|
logError('[AI] Configuration manquante : GEMINI_API_KEY / GOOGLE_AI_API_KEY absent');
|
||||||
|
res.status(500).json({
|
||||||
|
assistantMessage: 'La configuration de l\'IA est incomplète côté serveur. Veuillez configurer la clé Gemini avant de réessayer.',
|
||||||
|
proposal: null,
|
||||||
|
debugLogs,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
eventName,
|
eventName,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
@@ -1201,7 +1468,7 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
const genAI = new GoogleGenerativeAI(dynamicApiKey);
|
||||||
const model = genAI.getGenerativeModel({
|
const model = genAI.getGenerativeModel({
|
||||||
model: GEMINI_MODEL,
|
model: GEMINI_MODEL,
|
||||||
systemInstruction: SYSTEM_PROMPT,
|
systemInstruction: SYSTEM_PROMPT,
|
||||||
@@ -1464,6 +1731,87 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Post-traitement : privilégier les containers si ceux-ci contiennent uniquement des équipements
|
||||||
|
// demandés par l'événement (i.e. tous les équipements du container sont nécessaires).
|
||||||
|
try {
|
||||||
|
if (proposal && Array.isArray(proposal.items) && proposal.items.length > 0) {
|
||||||
|
const allowContainers = userAllowsContainers(userMessage);
|
||||||
|
logDebug('[AI] Vérification auto des containers possible (autorisé par l utilisateur) :', { allowContainers });
|
||||||
|
|
||||||
|
if (allowContainers) {
|
||||||
|
const itemIds = proposal.items.map((it) => String(it.equipmentId));
|
||||||
|
if (itemIds.length > 0) {
|
||||||
|
const searchArgs = { equipmentIds: itemIds };
|
||||||
|
const cacheKey = buildToolCacheKey('search_containers', searchArgs);
|
||||||
|
let containersResult = toolResultCache.get(cacheKey);
|
||||||
|
if (!containersResult) {
|
||||||
|
containersResult = await executeTool({ name: 'search_containers', args: searchArgs }, excludeEventId, sharedToolContext);
|
||||||
|
toolResultCache.set(cacheKey, containersResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containersResult && Array.isArray(containersResult.containers) && containersResult.containers.length > 0) {
|
||||||
|
const itemsSet = new Set(itemIds);
|
||||||
|
proposal.containers = proposal.containers || [];
|
||||||
|
|
||||||
|
for (const c of containersResult.containers) {
|
||||||
|
const cEquip = Array.isArray(c.equipmentIds) ? c.equipmentIds.map((id) => String(id)) : [];
|
||||||
|
if (cEquip.length === 0) continue;
|
||||||
|
|
||||||
|
const matching = Array.isArray(c.matchingEquipmentIds) ? c.matchingEquipmentIds.map((id) => String(id)) : cEquip.filter((id) => itemsSet.has(String(id)));
|
||||||
|
const missing = cEquip.filter((id) => !itemsSet.has(String(id)));
|
||||||
|
const isFull = matching.length > 0 && missing.length === 0;
|
||||||
|
|
||||||
|
// Vérifier la disponibilité du container (et de ses membres) pour la période
|
||||||
|
const checkArgs = { containerId: c.id, startDate: startDate, endDate: endDate };
|
||||||
|
const checkKey = buildToolCacheKey('check_container_availability', checkArgs);
|
||||||
|
let containerAvail = toolResultCache.get(checkKey);
|
||||||
|
if (!containerAvail) {
|
||||||
|
try {
|
||||||
|
containerAvail = await executeTool({ name: 'check_container_availability', args: checkArgs }, excludeEventId, sharedToolContext);
|
||||||
|
} catch (err) {
|
||||||
|
containerAvail = { containerId: c.id, available: true, error: err?.message || 'Erreur check' };
|
||||||
|
}
|
||||||
|
toolResultCache.set(checkKey, containerAvail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerEntry = {
|
||||||
|
containerId: String(c.id),
|
||||||
|
rationale: `Conteneur ${c.name || c.id} (matching: ${matching.length}/${cEquip.length})`,
|
||||||
|
equipmentIds: cEquip,
|
||||||
|
matchingEquipmentIds: matching,
|
||||||
|
missingEquipmentIds: missing,
|
||||||
|
matchingCount: matching.length,
|
||||||
|
totalItemCount: cEquip.length,
|
||||||
|
coverageRatio: c.coverageRatio || (matching.length / Math.max(1, itemsSet.size)),
|
||||||
|
partial: !isFull,
|
||||||
|
available: containerAvail && typeof containerAvail.available === 'boolean' ? containerAvail.available : true,
|
||||||
|
availabilityDetail: containerAvail || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Si le container couvre totalement ses éléments -> favoriser et retirer les items individuels
|
||||||
|
if (isFull) {
|
||||||
|
const already = proposal.containers.find((pc) => pc.containerId === c.id);
|
||||||
|
if (!already) {
|
||||||
|
proposal.containers.push(containerEntry);
|
||||||
|
logDebug('[AI] Ajout automatique du container car tous ses éléments sont demandés :', { containerId: c.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const covered = new Set(cEquip.map((id) => String(id)));
|
||||||
|
proposal.items = proposal.items.filter((it) => !covered.has(String(it.equipmentId)));
|
||||||
|
} else if (matching.length > 0) {
|
||||||
|
// Container partiel : proposer à l'utilisateur d'ajouter le container complète
|
||||||
|
proposal.containers.push(containerEntry);
|
||||||
|
logDebug('[AI] Container partiel proposé (non retiré des items) :', { containerId: c.id, matchingCount: matching.length, missingCount: missing.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError('[AI] Erreur pendant l auto-selection des containers :', { message: e?.message || e });
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).json({ assistantMessage, proposal, debugLogs });
|
res.status(200).json({ assistantMessage, proposal, debugLogs });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
* Architecture backend sécurisée avec authentification et permissions
|
* Architecture backend sécurisée avec authentification et permissions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Charger les variables d'environnement depuis .env
|
// Charger les variables d'environnement depuis .env.local (développement)
|
||||||
require('dotenv').config();
|
// ou .env (production Firebase)
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '.env.local') });
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
||||||
|
|
||||||
const { onRequest, onCall } = require("firebase-functions/v2/https");
|
const { onRequest, onCall } = require("firebase-functions/v2/https");
|
||||||
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
||||||
|
|||||||
Generated
+9
-5
@@ -13,7 +13,7 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"envdot": "^0.0.3",
|
"envdot": "^0.0.3",
|
||||||
"firebase-admin": "^12.6.0",
|
"firebase-admin": "^12.6.0",
|
||||||
"firebase-functions": "^7.0.3",
|
"firebase-functions": "^7.2.5",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"nodemailer": "^6.10.1"
|
"nodemailer": "^6.10.1"
|
||||||
},
|
},
|
||||||
@@ -3364,9 +3364,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/firebase-functions": {
|
"node_modules/firebase-functions": {
|
||||||
"version": "7.0.3",
|
"version": "7.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz",
|
||||||
"integrity": "sha512-DiIjIUv0OS4KEAA3jqyIc7ymZKdcmMcaXy7FCCkrDQo/1CVMbDDWMdZIslmsgSjldA2nhau1dTE/6JQI8Urjjw==",
|
"integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3385,7 +3385,8 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@apollo/server": "^5.2.0",
|
"@apollo/server": "^5.2.0",
|
||||||
"@as-integrations/express4": "^1.1.2",
|
"@as-integrations/express4": "^1.1.2",
|
||||||
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0"
|
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0",
|
||||||
|
"graphql": "^16.12.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@apollo/server": {
|
"@apollo/server": {
|
||||||
@@ -3393,6 +3394,9 @@
|
|||||||
},
|
},
|
||||||
"@as-integrations/express4": {
|
"@as-integrations/express4": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"graphql": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"envdot": "^0.0.3",
|
"envdot": "^0.0.3",
|
||||||
"firebase-admin": "^12.6.0",
|
"firebase-admin": "^12.6.0",
|
||||||
"firebase-functions": "^7.0.3",
|
"firebase-functions": "^7.2.5",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"nodemailer": "^6.10.1"
|
"nodemailer": "^6.10.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,6 +38,29 @@ class AiEquipmentProposalItem {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Métadonnées pour un container proposé par l'IA.
|
||||||
|
class AiEquipmentProposalContainer {
|
||||||
|
final String containerId;
|
||||||
|
final String rationale;
|
||||||
|
final List<String> equipmentIds;
|
||||||
|
final List<String> matchingEquipmentIds;
|
||||||
|
final List<String> missingEquipmentIds;
|
||||||
|
final bool partial;
|
||||||
|
final bool? available;
|
||||||
|
final dynamic availabilityDetail;
|
||||||
|
|
||||||
|
const AiEquipmentProposalContainer({
|
||||||
|
required this.containerId,
|
||||||
|
required this.rationale,
|
||||||
|
this.equipmentIds = const [],
|
||||||
|
this.matchingEquipmentIds = const [],
|
||||||
|
this.missingEquipmentIds = const [],
|
||||||
|
this.partial = false,
|
||||||
|
this.available,
|
||||||
|
this.availabilityDetail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Proposition complète retournée par l'IA.
|
/// Proposition complète retournée par l'IA.
|
||||||
class AiEquipmentProposal {
|
class AiEquipmentProposal {
|
||||||
final String summary;
|
final String summary;
|
||||||
@@ -46,14 +69,16 @@ class AiEquipmentProposal {
|
|||||||
/// Équipements individuels prêts à être injectés dans l'état local de l'événement.
|
/// Équipements individuels prêts à être injectés dans l'état local de l'événement.
|
||||||
final List<EventEquipment> asEventEquipment;
|
final List<EventEquipment> asEventEquipment;
|
||||||
|
|
||||||
/// IDs des containers (flight cases) proposés par l'IA.
|
/// Containers (métadonnées) proposés par l'IA.
|
||||||
final List<String> containerIds;
|
final List<AiEquipmentProposalContainer> containers;
|
||||||
|
|
||||||
|
List<String> get containerIds => containers.map((c) => c.containerId).toList();
|
||||||
|
|
||||||
const AiEquipmentProposal({
|
const AiEquipmentProposal({
|
||||||
required this.summary,
|
required this.summary,
|
||||||
required this.items,
|
required this.items,
|
||||||
required this.asEventEquipment,
|
required this.asEventEquipment,
|
||||||
required this.containerIds,
|
required this.containers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +181,7 @@ class AiEquipmentAssistantService {
|
|||||||
|
|
||||||
final proposalItems = <AiEquipmentProposalItem>[];
|
final proposalItems = <AiEquipmentProposalItem>[];
|
||||||
final eventEquipmentList = <EventEquipment>[];
|
final eventEquipmentList = <EventEquipment>[];
|
||||||
final containerIds = <String>[];
|
// legacy containerIds variable removed (we now use containersMeta)
|
||||||
|
|
||||||
final rawItems = rawProposal['items'];
|
final rawItems = rawProposal['items'];
|
||||||
if (rawItems is List) {
|
if (rawItems is List) {
|
||||||
@@ -184,19 +209,64 @@ class AiEquipmentAssistantService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final containersMeta = <AiEquipmentProposalContainer>[];
|
||||||
final rawContainers = rawProposal['containers'];
|
final rawContainers = rawProposal['containers'];
|
||||||
if (rawContainers is List) {
|
if (rawContainers is List) {
|
||||||
for (final rawContainer in rawContainers) {
|
for (final rawContainer in rawContainers) {
|
||||||
|
if (rawContainer is String) {
|
||||||
|
final cid = rawContainer.toString().trim();
|
||||||
|
if (cid.isNotEmpty) {
|
||||||
|
containersMeta.add(AiEquipmentProposalContainer(containerId: cid, rationale: 'Proposition IA'));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (rawContainer is! Map) continue;
|
if (rawContainer is! Map) continue;
|
||||||
final container = Map<String, dynamic>.from(rawContainer);
|
final container = Map<String, dynamic>.from(rawContainer);
|
||||||
final containerId = container['containerId']?.toString().trim() ?? '';
|
final containerId = container['containerId']?.toString().trim() ?? '';
|
||||||
if (containerId.isNotEmpty) {
|
if (containerId.isEmpty) continue;
|
||||||
containerIds.add(containerId);
|
|
||||||
|
final rationale = container['rationale']?.toString().trim() ?? 'Proposition IA';
|
||||||
|
final equipmentIds = <String>[];
|
||||||
|
final matching = <String>[];
|
||||||
|
final missing = <String>[];
|
||||||
|
|
||||||
|
if (container['equipmentIds'] is List) {
|
||||||
|
for (final v in container['equipmentIds']) {
|
||||||
|
final s = v == null ? null : v.toString().trim();
|
||||||
|
if (s != null && s.isNotEmpty) equipmentIds.add(s);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (container['matchingEquipmentIds'] is List) {
|
||||||
|
for (final v in container['matchingEquipmentIds']) {
|
||||||
|
final s = v == null ? null : v.toString().trim();
|
||||||
|
if (s != null && s.isNotEmpty) matching.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (container['missingEquipmentIds'] is List) {
|
||||||
|
for (final v in container['missingEquipmentIds']) {
|
||||||
|
final s = v == null ? null : v.toString().trim();
|
||||||
|
if (s != null && s.isNotEmpty) missing.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final partial = container['partial'] is bool ? container['partial'] as bool : (missing.isNotEmpty);
|
||||||
|
final available = container.containsKey('available') ? (container['available'] is bool ? container['available'] as bool : null) : null;
|
||||||
|
final availabilityDetail = container.containsKey('availabilityDetail') ? container['availabilityDetail'] : null;
|
||||||
|
|
||||||
|
containersMeta.add(AiEquipmentProposalContainer(
|
||||||
|
containerId: containerId,
|
||||||
|
rationale: rationale,
|
||||||
|
equipmentIds: equipmentIds,
|
||||||
|
matchingEquipmentIds: matching,
|
||||||
|
missingEquipmentIds: missing,
|
||||||
|
partial: partial,
|
||||||
|
available: available,
|
||||||
|
availabilityDetail: availabilityDetail,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proposalItems.isEmpty && containerIds.isEmpty) return null;
|
if (proposalItems.isEmpty && containersMeta.isEmpty) return null;
|
||||||
|
|
||||||
return AiEquipmentProposal(
|
return AiEquipmentProposal(
|
||||||
summary: rawProposal['summary']?.toString().trim().isNotEmpty == true
|
summary: rawProposal['summary']?.toString().trim().isNotEmpty == true
|
||||||
@@ -204,7 +274,7 @@ class AiEquipmentAssistantService {
|
|||||||
: 'Proposition matériel générée automatiquement.',
|
: 'Proposition matériel générée automatiquement.',
|
||||||
items: proposalItems,
|
items: proposalItems,
|
||||||
asEventEquipment: eventEquipmentList,
|
asEventEquipment: eventEquipmentList,
|
||||||
containerIds: containerIds,
|
containers: containersMeta,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class _AiEquipmentAssistantDialogState
|
|||||||
late List<EventEquipment> _workingEquipment;
|
late List<EventEquipment> _workingEquipment;
|
||||||
AiEquipmentDocument? _selectedDocument;
|
AiEquipmentDocument? _selectedDocument;
|
||||||
List<String> _sessionLogs = [];
|
List<String> _sessionLogs = [];
|
||||||
|
Set<String> _selectedContainerIds = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -134,6 +135,11 @@ class _AiEquipmentAssistantDialogState
|
|||||||
_workingEquipment = List<EventEquipment>.from(
|
_workingEquipment = List<EventEquipment>.from(
|
||||||
response.proposal!.asEventEquipment,
|
response.proposal!.asEventEquipment,
|
||||||
);
|
);
|
||||||
|
// Préselectionner les containers non partiels
|
||||||
|
_selectedContainerIds = {
|
||||||
|
for (final c in response.proposal!.containers)
|
||||||
|
if (!c.partial) c.containerId
|
||||||
|
};
|
||||||
}
|
}
|
||||||
_sessionLogs.addAll(response.debugLogs);
|
_sessionLogs.addAll(response.debugLogs);
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -267,7 +273,7 @@ class _AiEquipmentAssistantDialogState
|
|||||||
children: [
|
children: [
|
||||||
AppBar(
|
AppBar(
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
title: const Text('Assistant IA Logisticien'),
|
title: const Text('(BETA) Assistant IA Logisticien'),
|
||||||
actions: [
|
actions: [
|
||||||
if (_sessionLogs.isNotEmpty)
|
if (_sessionLogs.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -422,13 +428,13 @@ class _AiEquipmentAssistantDialogState
|
|||||||
bottomLeft: Radius.circular(message.isUser ? 16 : 4),
|
bottomLeft: Radius.circular(message.isUser ? 16 : 4),
|
||||||
bottomRight: Radius.circular(message.isUser ? 4 : 16),
|
bottomRight: Radius.circular(message.isUser ? 4 : 16),
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
blurRadius: 4,
|
blurRadius: 10,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
border:
|
border:
|
||||||
message.isUser ? null : Border.all(color: Colors.grey.shade200),
|
message.isUser ? null : Border.all(color: Colors.grey.shade200),
|
||||||
),
|
),
|
||||||
@@ -488,11 +494,11 @@ class _AiEquipmentAssistantDialogState
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bgColor,
|
color: bgColor,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -500,11 +506,13 @@ class _AiEquipmentAssistantDialogState
|
|||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 18, color: color),
|
Icon(icon, size: 18, color: color),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Expanded(
|
||||||
title.replaceAll(':', '').trim(),
|
child: Text(
|
||||||
style: TextStyle(
|
title.replaceAll(':', '').trim(),
|
||||||
fontWeight: FontWeight.bold,
|
style: TextStyle(
|
||||||
color: color,
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -525,7 +533,10 @@ class _AiEquipmentAssistantDialogState
|
|||||||
if (_latestProposal == null) return;
|
if (_latestProposal == null) return;
|
||||||
|
|
||||||
List<EventEquipment> equipment = List.from(_latestProposal!.asEventEquipment);
|
List<EventEquipment> equipment = List.from(_latestProposal!.asEventEquipment);
|
||||||
List<String> containerIds = List.from(_latestProposal!.containerIds);
|
// Ne renvoyer que les containerIds sélectionnés (par défaut les containers complets)
|
||||||
|
final List<String> containerIds = _selectedContainerIds.isNotEmpty
|
||||||
|
? _selectedContainerIds.toList()
|
||||||
|
: List.from(_latestProposal!.containerIds);
|
||||||
|
|
||||||
if (excludeAlternatives) {
|
if (excludeAlternatives) {
|
||||||
// On utilise la liste des items d'origine pour savoir lesquels exclure
|
// On utilise la liste des items d'origine pour savoir lesquels exclure
|
||||||
@@ -558,9 +569,9 @@ class _AiEquipmentAssistantDialogState
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
constraints: const BoxConstraints(maxHeight: 280),
|
constraints: const BoxConstraints(maxHeight: 280),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.indigo.shade50,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: Colors.indigo.shade200),
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
@@ -576,12 +587,14 @@ class _AiEquipmentAssistantDialogState
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Icons.assignment_turned_in, color: Colors.indigo),
|
const Icon(Icons.assignment_turned_in, color: Colors.indigo),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Text(
|
const Expanded(
|
||||||
'Récapitulatif de la proposition IA',
|
child: Text(
|
||||||
style: TextStyle(
|
'Récapitulatif de la proposition IA',
|
||||||
fontWeight: FontWeight.bold,
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.indigo,
|
fontSize: 16,
|
||||||
|
color: Colors.indigo,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -633,21 +646,58 @@ class _AiEquipmentAssistantDialogState
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
if (proposal.containerIds.isNotEmpty) ...[
|
if (proposal.containers.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text(
|
const Text(
|
||||||
'Fly-cases & Boîtes :',
|
'Fly-cases & Boîtes :',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
...proposal.containerIds.map((id) {
|
...proposal.containers.map((c) {
|
||||||
|
final isPartial = c.partial;
|
||||||
|
final isSelected = _selectedContainerIds.contains(c.containerId);
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 6, left: 4),
|
padding: const EdgeInsets.only(bottom: 6, left: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.inventory_2_outlined, size: 14, color: Colors.indigo),
|
Icon(
|
||||||
|
Icons.inventory_2_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: c.available == false ? Colors.red : Colors.indigo,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(id, style: const TextStyle(fontWeight: FontWeight.w500)),
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text('${c.containerId} ${c.rationale.isNotEmpty ? "- ${c.rationale}" : ""}', style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||||
|
if (c.available == false)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
|
child: Icon(Icons.block, color: Colors.red.shade700, size: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (isPartial) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('Contenu partiel : ${c.matchingEquipmentIds.length}/${c.equipmentIds.length} items utilisés.', style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (isPartial)
|
||||||
|
Checkbox(
|
||||||
|
value: isSelected,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() {
|
||||||
|
if (v == true) _selectedContainerIds.add(c.containerId);
|
||||||
|
else _selectedContainerIds.remove(c.containerId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user