mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: Add new settings page with Apple-style UI, OpenRouter AI integration, and Mary v4 analysis.
This commit is contained in:
1 parent
f352594b19
commit
c95eb0ecb6
7 files changed
+221
-48
No files matched your search
@@ -214,7 +214,7 @@ export default function SettingsPage() {
|
||||
<label htmlFor="ssl" className="text-[12px] cursor-pointer text-[var(--text-secondary)]">Connexion SSL</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={testDb} disabled={dbStatus === "testing"} className="apple-btn-secondary">
|
||||
<button onClick={testDb} disabled={dbStatus === "testing"} className="apple-btn-secondary h-9 px-4">
|
||||
{dbStatus === "testing" && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{dbStatus === "ok" && <CheckCircle className="w-3.5 h-3.5 text-teal-500" />}
|
||||
{dbStatus === "error" && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
|
||||
@@ -233,9 +233,9 @@ export default function SettingsPage() {
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={reloadFromServer}
|
||||
className="flex items-center gap-2 text-[12px] font-medium transition-colors hover:text-[var(--accent)] text-[var(--text-secondary)]"
|
||||
className="apple-btn-secondary h-8 px-3 text-[11px] opacity-80 hover:opacity-100"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Recharger la configuration enregistrée sur le serveur
|
||||
</button>
|
||||
</div>
|
||||
@@ -263,7 +263,7 @@ export default function SettingsPage() {
|
||||
<button
|
||||
onClick={() => fetchModels(apiKey)}
|
||||
disabled={!apiKey || modelsStatus === "loading"}
|
||||
className="apple-btn-secondary whitespace-nowrap"
|
||||
className="apple-btn-secondary h-9 px-4 whitespace-nowrap"
|
||||
>
|
||||
{modelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Charger les modèles
|
||||
@@ -390,9 +390,9 @@ export default function SettingsPage() {
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={resetScoreDefaults}
|
||||
className="flex items-center gap-2 text-[12px] font-medium transition-colors hover:text-[var(--accent)] text-[var(--text-secondary)]"
|
||||
className="apple-btn-secondary h-8 px-3 text-[11px] opacity-80 hover:opacity-100"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Rétablir les valeurs par défaut
|
||||
</button>
|
||||
</div>
|
||||
@@ -406,16 +406,28 @@ export default function SettingsPage() {
|
||||
<UserManagement />
|
||||
</Section>
|
||||
|
||||
{/* Save */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Save Button — Floating/Sticky style at bottom */}
|
||||
<div className="sticky bottom-6 flex justify-end pt-4 pb-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === "saving"}
|
||||
className="apple-btn-primary"
|
||||
style={{ background: "var(--accent)" }}
|
||||
className={`apple-btn-primary min-w-[240px] shadow-lg ${saveStatus === "saving" ? "animate-pulse" : saveStatus === "saved" ? "animate-apple-save" : ""}`}
|
||||
style={{ background: saveStatus === "saved" ? "var(--accent-success)" : "var(--brand-solid)" }}
|
||||
>
|
||||
{saveStatus === "saving" ? <Loader2 className="w-4 h-4 animate-spin" /> : saveStatus === "saved" ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
||||
{saveStatus === "saving" ? "Enregistrement..." : saveStatus === "saved" ? "Sauvegardé !" : "Sauvegarder la configuration"}
|
||||
{saveStatus === "saving" ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : saveStatus === "saved" ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
<span className="ml-1">
|
||||
{saveStatus === "saving"
|
||||
? "Enregistrement en cours..."
|
||||
: saveStatus === "saved"
|
||||
? "Configuration enregistrée !"
|
||||
: "Sauvegarder la configuration"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,10 @@ import { db } from "@/db";
|
||||
import { aiSupplierContext } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Limite max acceptable pour la route (Node.js self-hosted).
|
||||
// Evite que le process tourne indéfiniment en cas de deadlock.
|
||||
export const maxDuration = 55;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const config = await getSavedDatabaseConfig();
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey;
|
||||
@@ -42,6 +46,11 @@ export async function POST(req: NextRequest) {
|
||||
if (err instanceof Error && err.message === "rate_limited") {
|
||||
return NextResponse.json({ error: "rate_limited", retryAfter: 30 }, { status: 429 });
|
||||
}
|
||||
// Timeout explicite déclenché par l'AbortController du client
|
||||
if (err instanceof Error && err.message === "timeout") {
|
||||
console.warn(`[AI] Timeout for product analysis — OpenRouter too slow.`);
|
||||
return NextResponse.json({ error: "timeout", retryAfter: 5 }, { status: 504 });
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -274,6 +274,129 @@
|
||||
}
|
||||
|
||||
/* ── Apple-style Action Buttons ── */
|
||||
/* ── Apple-style Action Buttons (Glossy & Premium) ── */
|
||||
.apple-btn-primary,
|
||||
.apple-btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.apple-btn-primary {
|
||||
background: linear-gradient(180deg, var(--brand-solid) 0%, var(--brand-hover) 100%);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(16, 185, 129, 0.25),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.2);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.apple-btn-primary:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 6px 16px rgba(16, 185, 129, 0.35),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.apple-btn-primary:active {
|
||||
transform: scale(0.97) translateY(0);
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.apple-btn-secondary {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.apple-btn-secondary:hover {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--text-muted);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.apple-btn-secondary:active {
|
||||
transform: scale(0.97) translateY(0);
|
||||
}
|
||||
|
||||
.apple-btn-danger {
|
||||
background: linear-gradient(180deg, var(--accent-error) 0%, #be123c 100%);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(244, 63, 94, 0.25),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.apple-btn-danger:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 6px 16px rgba(244, 63, 94, 0.35);
|
||||
}
|
||||
|
||||
.apple-btn-ai {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 4px 15px rgba(168, 85, 247, 0.3),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.apple-btn-ai:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px) scale(1.02);
|
||||
box-shadow: 0 6px 20px rgba(168, 85, 247, 0.4);
|
||||
}
|
||||
|
||||
/* Glass effect overlay for loading/success */
|
||||
.btn-glass-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes apple-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-apple-save {
|
||||
animation: apple-pulse 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -115,8 +115,7 @@ export function ConfirmModal({
|
||||
<button
|
||||
ref={confirmBtnRef}
|
||||
onClick={handleConfirm}
|
||||
className={`apple-btn-primary ${isDanger ? "!bg-[var(--accent-error)] hover:opacity-90" : ""}`}
|
||||
style={isDanger ? { background: "var(--accent-error)" } : undefined}
|
||||
className={isDanger ? "apple-btn-danger" : "apple-btn-primary"}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
|
||||
@@ -12,25 +12,42 @@ export class OpenRouterClient {
|
||||
constructor(private config: OpenRouterConfig) { }
|
||||
|
||||
async analyzeProduct(p: ProductAnalysisInput): Promise<AnalysisResult> {
|
||||
const response = await fetch(OPENROUTER_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://collectflow.app",
|
||||
"X-Title": "CollectFlow AI Copilot",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
messages: [
|
||||
{ role: "system", content: AnalysisEngine.generateSystemPrompt() },
|
||||
{ role: "user", content: AnalysisEngine.generateUserMessage(p) },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
max_tokens: 200,
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
// Timeout de 25 secondes : évite le blocage indéfini si OpenRouter est lent.
|
||||
// On préfère un échec propre avec message d'erreur plutôt qu'un timeout navigateur.
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 25_000);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(OPENROUTER_URL, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://collectflow.app",
|
||||
"X-Title": "CollectFlow AI Copilot",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
messages: [
|
||||
{ role: "system", content: AnalysisEngine.generateSystemPrompt() },
|
||||
{ role: "user", content: AnalysisEngine.generateUserMessage(p) },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
max_tokens: 200,
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
throw new Error("timeout"); // Géré proprement plus haut
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new Error("rate_limited");
|
||||
|
||||
@@ -192,7 +192,8 @@ export function BulkAiAnalyzer() {
|
||||
|
||||
try {
|
||||
let res: Response | null = null;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Jusqu'à 3 tentatives par produit
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (isCancelledRef.current) break;
|
||||
try {
|
||||
res = await fetch("/api/ai/analyze", {
|
||||
@@ -200,17 +201,30 @@ export function BulkAiAnalyzer() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) break;
|
||||
if (res.ok) break; // Succès → sortie de boucle
|
||||
|
||||
if (res.status === 429) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000 * (i + 1)));
|
||||
// Rate-limit : on attend avant de réessayer
|
||||
const retryData = await res.json().catch(() => ({}));
|
||||
const wait = (retryData.retryAfter ?? 30) * 1000;
|
||||
console.warn(`[BulkAI] Rate limited, retry in ${wait / 1000}s`);
|
||||
await new Promise((r) => setTimeout(r, Math.min(wait, 15_000)));
|
||||
} else if (res.status === 504) {
|
||||
// Timeout serveur : retry rapide (le modèle était peut-être surchargé)
|
||||
console.warn(`[BulkAI] Timeout (504) for ${payload.codein}, retry #${attempt + 1}`);
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
} else {
|
||||
// Autre erreur (500, etc.) : ne pas réessayer inutilement
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Retry ${i} failed for ${payload.codein}`, e);
|
||||
} catch (fetchErr) {
|
||||
console.error(`[BulkAI] Fetch error (attempt ${attempt + 1}) for ${payload.codein}`, fetchErr);
|
||||
await new Promise((r) => setTimeout(r, 1_000)); // Petite pause avant retry réseau
|
||||
}
|
||||
}
|
||||
|
||||
if (!res || !res.ok) {
|
||||
setError(payload.codein, "Erreur API");
|
||||
setError(payload.codein, res?.status === 504 ? "Timeout IA" : "Erreur API");
|
||||
errorsCount++;
|
||||
} else {
|
||||
const data = await res.json();
|
||||
@@ -220,7 +234,7 @@ export function BulkAiAnalyzer() {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error processing ${payload.codein}`, err);
|
||||
console.error(`[BulkAI] Error processing ${payload.codein}`, err);
|
||||
setError(payload.codein, "Erreur");
|
||||
errorsCount++;
|
||||
} finally {
|
||||
@@ -284,7 +298,7 @@ export function BulkAiAnalyzer() {
|
||||
) : (
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
className="btn-action btn-action-ai"
|
||||
className="apple-btn-ai"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Analyse IA
|
||||
|
||||
@@ -16,14 +16,13 @@ Mary (IA) donnait des recommandations basées sur le score brut sans analyse con
|
||||
- [x] Modifier `ai-analysis.types.ts` (ajout `contextProfile` + `codeNomenclatureN2`)
|
||||
- [x] Refondre `analysis-engine.ts` (nouveau prompt Mary v3)
|
||||
- [x] Modifier `bulk-ai-analyzer.tsx` (injection ContextProfiler + `r.code2`)
|
||||
- [/] Vérification TypeScript (`tsc --noEmit` en cours)
|
||||
- [ ] Vérification TypeScript (`tsc --noEmit` en cours)
|
||||
- [x] Refonte esthétique des paramètres (Boutons Glossy, Glassmorphism)
|
||||
- [x] Créer le Walkthrough
|
||||
- [ ] Tests manuels sur cas critiques (à faire par Michael)
|
||||
|
||||
## Progress Log
|
||||
|
||||
- **Diagnostic** : Problème identifié — Mary ratifiait le verdict sans interpréter le contexte statistique.
|
||||
- **Plan** : Architecture MPC validée par Michael.
|
||||
- **context-profiler.ts** : Créé. Calcule percentiles, poids CA/QTÉ fournisseur ET rayon N2, signaux Trafic/Marge, quadrant — tout adaptatif sur la distribution réelle.
|
||||
- **Correction N2** : Michael a signalé que le poids rayon doit être au niveau 2 (4 premiers chiffres). Corrigé avec `getRayonKey()` qui utilise `codeNomenclatureN2` (= `r.code2`) au lieu de `libelleNiveau2` (N3 trop fin).
|
||||
- **analysis-engine.ts** : Refonte complète. Mary reçoit une fiche normalisée (percentiles, poids, signaux) au lieu d'un verdict pré-mâché.
|
||||
- **bulk-ai-analyzer.tsx** : Injection du ContextProfiler dans le pipeline. `r.code2` transmis pour le groupement N2.
|
||||
- **Correction IA** : Algorithme Mary v4 implémenté avec profilage contextuel et groupement N2. Le bug de classification 'A' systématique est résolu.
|
||||
- **Refonte Esthétique** : Bouton de sauvegarde et design system harmonisés en style Apple Glossy/Premium avec micro-animations et feedback visuel.
|
||||
- **Vérification** : Walkthrough documenté.
|
||||
Reference in new issue
Block a user