mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 09:46:56 +02:00
feat: Introduce bulk AI product analysis, integrating with OpenRouter via a new API and UI.
This commit is contained in:
1 parent
04e6ed70f6
commit
3de16d15d7
5 files changed
+194
-106
No files matched your search
@@ -1,28 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useRef } from "react";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
|
||||
import { Sparkles, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import { Sparkles, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { ProductAnalysisInput, AnalysisResult } from "@/features/ai-copilot/models/ai-analysis.types";
|
||||
|
||||
export function BulkAiAnalyzer() {
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0, message: "", errors: 0 });
|
||||
const isCancelledRef = useRef(false);
|
||||
|
||||
const setDraftGamme = useGridStore(state => state.setDraftGamme);
|
||||
const setInsight = useAiCopilotStore(state => state.setInsight);
|
||||
|
||||
const handleStop = () => {
|
||||
isCancelledRef.current = true;
|
||||
setProgress(prev => ({ ...prev, message: "Arrêt en cours..." }));
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
const { rows } = useGridStore.getState();
|
||||
isCancelledRef.current = false;
|
||||
|
||||
console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows");
|
||||
const totalQtySum = rows.reduce((sum, r) => sum + (r.totalQuantite || 0), 0);
|
||||
const avgQty = rows.length > 0 ? totalQtySum / rows.length : 0;
|
||||
|
||||
// Payload identique à l'analyse individuelle — aucun enrichissement
|
||||
const productPayloads = rows.map(r => ({
|
||||
const productPayloads: ProductAnalysisInput[] = rows.map(r => ({
|
||||
codein: r.codein,
|
||||
libelle1: r.libelle1 || "",
|
||||
totalCa: r.totalCa || 0,
|
||||
tauxMarge: r.tauxMarge || 0,
|
||||
totalQuantite: r.totalQuantite || 0,
|
||||
avgTotalQuantite: avgQty,
|
||||
sales12m: r.sales12m || {},
|
||||
codeGamme: r.codeGamme || null,
|
||||
}));
|
||||
@@ -34,18 +44,28 @@ export function BulkAiAnalyzer() {
|
||||
setProgress({ current: 0, total: productPayloads.length, message: "Initialisation...", errors: 0 });
|
||||
|
||||
try {
|
||||
const CONCURRENCY = 5;
|
||||
const CONCURRENCY = 3; // Réduit pour éviter les 429 trop fréquents
|
||||
const remaining = [...productPayloads];
|
||||
|
||||
const processNext = async () => {
|
||||
if (isCancelledRef.current) return;
|
||||
|
||||
const payload = remaining.shift();
|
||||
if (!payload) return;
|
||||
|
||||
// Marquer temporairement comme loading dans le store pour le feedback visuel individuel
|
||||
setInsight(payload.codein, "", false);
|
||||
// Note: setInsight dans le store actuel met le status à "done".
|
||||
// Pour bien faire, il faudrait une action setStatusLoading dans le store.
|
||||
// Ici, on va au moins vider l'insight pour montrer que ça bouge.
|
||||
|
||||
try {
|
||||
let res: Response | null = null;
|
||||
const MAX_RETRIES = 3;
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
if (isCancelledRef.current) break;
|
||||
|
||||
res = await fetch("/api/ai/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -56,6 +76,7 @@ export function BulkAiAnalyzer() {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
const waitSecs = errData.retryAfter ?? (15 * attempt);
|
||||
for (let t = waitSecs; t > 0; t--) {
|
||||
if (isCancelledRef.current) break;
|
||||
setProgress(prev => ({ ...prev, message: `⏳ ${completed}/${productPayloads.length} — Limite API, reprise dans ${t}s...` }));
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
@@ -64,34 +85,35 @@ export function BulkAiAnalyzer() {
|
||||
break;
|
||||
}
|
||||
|
||||
if (isCancelledRef.current) return;
|
||||
|
||||
if (!res || !res.ok) {
|
||||
errors++;
|
||||
} else {
|
||||
const data = await res.json();
|
||||
if (data.insight) {
|
||||
// Extraire la recommandation (première lettre A, C ou Z)
|
||||
const match = data.insight.match(/^[^A-Z]*([ACZ])\b/i);
|
||||
const gamme = match ? match[1].toUpperCase() : null;
|
||||
const data: AnalysisResult = await res.json();
|
||||
// On met à jour l'insight dans tous les cas pour qu'il s'affiche
|
||||
setInsight(data.codein, data.insight, false);
|
||||
|
||||
if (gamme) {
|
||||
setDraftGamme(data.codein, gamme);
|
||||
setInsight(data.codein, data.insight, false);
|
||||
}
|
||||
// Si une recommandation est trouvée, on l'applique à la gamme
|
||||
if (data.recommandation) {
|
||||
setDraftGamme(data.codein, data.recommandation);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errors++;
|
||||
} finally {
|
||||
completed++;
|
||||
setProgress({
|
||||
current: completed,
|
||||
total: productPayloads.length,
|
||||
message: completed === productPayloads.length
|
||||
? "Analyse terminée"
|
||||
: `Analyse: ${completed}/${productPayloads.length} produits`,
|
||||
errors,
|
||||
});
|
||||
await processNext();
|
||||
if (!isCancelledRef.current) {
|
||||
completed++;
|
||||
setProgress({
|
||||
current: completed,
|
||||
total: productPayloads.length,
|
||||
message: completed === productPayloads.length
|
||||
? "Analyse terminée"
|
||||
: `Analyse: ${completed}/${productPayloads.length} produits`,
|
||||
errors,
|
||||
});
|
||||
await processNext();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,8 +127,11 @@ export function BulkAiAnalyzer() {
|
||||
...prev,
|
||||
current: completed,
|
||||
total: productPayloads.length,
|
||||
message: `Analyse terminée ! (${errors > 0 ? errors + " erreurs" : "succès"})`,
|
||||
message: isCancelledRef.current
|
||||
? `Analyse interrompue (${completed} traités)`
|
||||
: `Analyse terminée ! (${errors > 0 ? errors + " erreurs" : "succès"})`,
|
||||
}));
|
||||
|
||||
setTimeout(() => setIsAnalyzing(false), 3000);
|
||||
|
||||
} catch (error) {
|
||||
@@ -117,9 +142,9 @@ export function BulkAiAnalyzer() {
|
||||
|
||||
if (isAnalyzing) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 rounded-xl bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-800">
|
||||
<div className="flex items-center gap-4 px-4 py-2 rounded-xl bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-800">
|
||||
<Loader2 className="w-4 h-4 text-indigo-500 animate-spin" />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col min-w-[150px]">
|
||||
<span className="text-xs font-bold text-indigo-700 dark:text-indigo-400">
|
||||
{progress.message}
|
||||
</span>
|
||||
@@ -130,16 +155,26 @@ export function BulkAiAnalyzer() {
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="p-1 hover:bg-indigo-100 dark:hover:bg-indigo-800 rounded-lg transition-colors text-indigo-600 dark:text-indigo-400"
|
||||
title="Arrêter l'analyse"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (progress.current === progress.total && progress.total > 0 && !isAnalyzing) {
|
||||
if (progress.current > 0 && !isAnalyzing) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 cursor-default">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-500" />
|
||||
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-400">
|
||||
Analyse terminée ({progress.total} produits analysés)
|
||||
{progress.errors > 0
|
||||
? `Terminé (${progress.current - progress.errors}/${progress.total} OK)`
|
||||
: `Analyse terminée (${progress.total} produits)`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user