From fff4cd9641a1e15a5edac5882def1f17410fe807 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 06:56:24 +0000 Subject: [PATCH] =?UTF-8?q?chore:=20retire=20la=20section=20Score=20des=20?= =?UTF-8?q?Param=C3=A8tres=20et=20supprime=20la=20page=20Chat=20IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Section « Score Produit » retirée : elle ne faisait que décrire une formule, sans rien paramétrer. - Page /admin/ai-chat et sa route /api/admin/ai-chat supprimées, ainsi que l'entrée de menu et la section « Admin AI Chat — Fournisseur » des Paramètres, qui n'existait que pour la configurer. - Nettoyage de l'état devenu mort dans la page Paramètres : la liste des modèles Google et son chargement n'avaient plus d'interface. Effet de bord notable : tsc passe de 23 à 4 erreurs préexistantes — la page de chat en portait 19 à elle seule (flags de regex exigeant une cible es2018). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675 --- scripts/db-init.js | 25 + src/app/(dashboard)/admin/ai-chat/page.tsx | 528 --------------------- src/app/(dashboard)/settings/page.tsx | 125 +---- src/app/api/admin/ai-chat/route.ts | 491 ------------------- src/components/shared/sidebar.tsx | 3 +- src/db/schema.ts | 50 ++ 6 files changed, 78 insertions(+), 1144 deletions(-) delete mode 100644 src/app/(dashboard)/admin/ai-chat/page.tsx delete mode 100644 src/app/api/admin/ai-chat/route.ts diff --git a/scripts/db-init.js b/scripts/db-init.js index 410c602..3e242e8 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -178,6 +178,31 @@ async function main() { `); console.log("[DB Init] Table api_keys is verified/created."); + // Paramétrage et suivi de la synchronisation nocturne, par fournisseur. + await tempPool.query(` + CREATE TABLE IF NOT EXISTS "sync_fournisseurs" ( + "code_fournisseur" varchar(20) PRIMARY KEY NOT NULL, + "nom_fournisseur" varchar(255), + "actif_sql" boolean NOT NULL DEFAULT true, + "actif_qlik" boolean NOT NULL DEFAULT true, + "desactive_motif" text, + "dernier_sql_at" timestamp, + "dernier_sql_statut" varchar(20), + "dernier_sql_lignes" integer, + "dernier_sql_erreur" text, + "dernier_sql_ms" integer, + "dernier_qlik_at" timestamp, + "dernier_qlik_statut" varchar(20), + "dernier_qlik_codes" integer, + "dernier_qlik_erreur" text, + "dernier_qlik_ms" integer, + "updated_at" timestamp DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS "idx_sync_fou_sql" ON "sync_fournisseurs" ("actif_sql", "dernier_sql_at"); + CREATE INDEX IF NOT EXISTS "idx_sync_fou_qlik" ON "sync_fournisseurs" ("actif_qlik", "dernier_qlik_at"); + `); + console.log("[DB Init] Table sync_fournisseurs is verified/created."); + await tempPool.end(); console.log("[DB Init] Initialization successful. Exiting."); process.exit(0); diff --git a/src/app/(dashboard)/admin/ai-chat/page.tsx b/src/app/(dashboard)/admin/ai-chat/page.tsx deleted file mode 100644 index bae2c42..0000000 --- a/src/app/(dashboard)/admin/ai-chat/page.tsx +++ /dev/null @@ -1,528 +0,0 @@ -"use client"; - -import { useState, useRef, useEffect, useCallback } from "react"; -import { Send, Bot, User, Database, Loader2, AlertCircle, ChevronDown, Trash2, DollarSign } from "lucide-react"; -import { cn } from "@/lib/utils"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; - -// Strip HTML tags and convert common HTML constructs to Markdown equivalents -function htmlToMarkdown(text: string): string { - return text - // Block-level → markdown - .replace(/]*>(.*?)<\/h\1>/gis, (_, level, content) => `${"#".repeat(Number(level))} ${content.replace(/<[^>]+>/g, "")}\n`) - .replace(/]*>(.*?)<\/strong>/gis, "**$1**") - .replace(/]*>(.*?)<\/b>/gis, "**$1**") - .replace(/]*>(.*?)<\/em>/gis, "*$1*") - .replace(/]*>(.*?)<\/i>/gis, "*$1*") - .replace(/]*>(.*?)<\/code>/gis, "`$1`") - .replace(/]*>(.*?)<\/pre>/gis, (_, c) => "```\n" + c.replace(/<[^>]+>/g, "") + "\n```") - .replace(/]*>(.*?)<\/li>/gis, "- $1") - .replace(/]*>|<\/ul>/gis, "") - .replace(/]*>|<\/ol>/gis, "") - .replace(/]*>/gis, "") - .replace(/<\/tr>/gis, "\n") - .replace(/]*>(.*?)<\/th>/gis, "| **$1** ") - .replace(/]*>(.*?)<\/td>/gis, "| $1 ") - .replace(/]*>|<\/table>|]*>|<\/thead>|]*>|<\/tbody>/gis, "") - .replace(//gis, "\n") - .replace(/]*>(.*?)<\/p>/gis, "$1\n\n") - .replace(/]*>(.*?)<\/div>/gis, "$1\n") - .replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, "[$2]($1)") - // Strip any remaining tags - .replace(/<[^>]+>/g, "") - // Decode HTML entities - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/ /g, " ") - // Clean up excessive blank lines - .replace(/\n{3,}/g, "\n\n") - .trim(); -} - -interface ModelInfo { - id: string; - name: string; - free: boolean; - promptPrice: number; // $ per token - completionPrice: number; -} - -interface ToolEvent { - name: string; - args: Record; - result?: string; -} - -interface Message { - role: "user" | "assistant"; - content: string; - toolEvents?: ToolEvent[]; -} - -interface SessionUsage { - promptTokens: number; - completionTokens: number; - costUsd: number; -} - -const DEFAULT_MODEL = "google/gemini-2.0-flash-001"; - -// Compute cost from token counts and model pricing -function computeCost(promptTokens: number, completionTokens: number, model: ModelInfo | null): number { - if (!model) return 0; - return promptTokens * model.promptPrice + completionTokens * model.completionPrice; -} - -function formatCost(usd: number): string { - if (usd === 0) return "Gratuit"; - if (usd < 0.001) return `$${(usd * 1000).toFixed(4)}m`; - return `$${usd.toFixed(6)}`; -} - -function ToolCallBlock({ event }: { event: ToolEvent }) { - const [open, setOpen] = useState(false); - - const label = event.name === "execute_sql" - ? `SQL: ${String(event.args.query ?? "").substring(0, 60)}${String(event.args.query ?? "").length > 60 ? "…" : ""}` - : event.name === "get_db_schema" - ? "Schéma DB" - : event.name; - - return ( -
- - {open && event.result && ( -
-                    {event.result}
-                
- )} -
- ); -} - -function MessageBubble({ msg }: { msg: Message }) { - const isUser = msg.role === "user"; - - return ( -
- {/* Avatar */} -
- {isUser - ? - : - } -
- - {/* Content */} -
- {/* Tool events (assistant only) */} - {msg.toolEvents && msg.toolEvents.length > 0 && ( -
- {msg.toolEvents.map((ev, i) => ( - - ))} -
- )} - - {/* Text bubble */} - {msg.content && ( -
- {isUser ? msg.content : ( -

{children}

, - h1: ({ children }) =>

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - li: ({ children }) =>
  • {children}
  • , - code: ({ inline, children }: { inline?: boolean; children?: React.ReactNode }) => - inline - ? {children} - :
    {children}
    , - strong: ({ children }) => {children}, - em: ({ children }) => {children}, - blockquote: ({ children }) =>
    {children}
    , - table: ({ children }) =>
    {children}
    , - thead: ({ children }) => {children}, - th: ({ children }) => {children}, - td: ({ children }) => {children}, - hr: () =>
    , - a: ({ href, children }) => {children}, - }} - > - {htmlToMarkdown(msg.content)} -
    - )} -
    - )} -
    -
    - ); -} - - -export default function AdminAiChatPage() { - const [models, setModels] = useState([]); - const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL); - const [aiProvider, setAiProviderState] = useState<"openrouter" | "google">("openrouter"); - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [loadingModels, setLoadingModels] = useState(true); - const [sessionUsage, setSessionUsage] = useState({ promptTokens: 0, completionTokens: 0, costUsd: 0 }); - - const messagesEndRef = useRef(null); - const textareaRef = useRef(null); - const abortRef = useRef(null); - - const selectedModelInfo = models.find(m => m.id === selectedModel) ?? null; - - // Fetch config + models - useEffect(() => { - setLoadingModels(true); - fetch("/api/admin/ai-config") - .then(r => r.json()) - .then(async d => { - const provider: "openrouter" | "google" = d.provider ?? "openrouter"; - setAiProviderState(provider); - if (provider === "google") { - const gRes = await fetch("/api/google-ai/models").then(r => r.json()).catch(() => ({ models: [] })); - setModels((gRes.models ?? []).map((m: { id: string; name: string }) => ({ - id: m.id, name: m.name, free: false, promptPrice: 0, completionPrice: 0, - }))); - setSelectedModel(d.googleAiModel ?? "gemini-2.0-flash"); - } else { - const od = await fetch("/api/openrouter/models").then(r => r.json()).catch(() => ({ models: [] })); - if (od.models) setModels(od.models); - if (d.openRouterModel) setSelectedModel(d.openRouterModel); - } - }) - .catch(async () => { - const od = await fetch("/api/openrouter/models").then(r => r.json()).catch(() => ({ models: [] })); - if (od.models) setModels(od.models); - }) - .finally(() => setLoadingModels(false)); - }, []); - - // Auto-scroll - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, loading]); - - const sendMessage = useCallback(async () => { - if (!input.trim() || loading) return; - - const userMsg: Message = { role: "user", content: input.trim() }; - setInput(""); - setError(null); - setMessages(prev => [...prev, userMsg]); - setLoading(true); - - // Build messages to send (excluding tool events, which are local display only) - const apiMessages = [...messages, userMsg].map(m => ({ - role: m.role, - content: m.content, - })); - - const assistantMsg: Message = { role: "assistant", content: "", toolEvents: [] }; - setMessages(prev => [...prev, assistantMsg]); - - abortRef.current = new AbortController(); - - try { - const res = await fetch("/api/admin/ai-chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: apiMessages, model: selectedModel }), - signal: abortRef.current.signal, - }); - - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error || "Erreur serveur"); - } - - const reader = res.body!.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - const pendingToolEvents: ToolEvent[] = []; - let lastToolStart: ToolEvent | null = null; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const raw = line.slice(6).trim(); - try { - const event = JSON.parse(raw); - - if (event.type === "text") { - setMessages(prev => { - const copy = [...prev]; - const last = { ...copy[copy.length - 1] }; - last.content += event.content; - copy[copy.length - 1] = last; - return copy; - }); - } else if (event.type === "tool_start") { - lastToolStart = { name: event.name, args: event.args }; - } else if (event.type === "tool_result") { - if (lastToolStart) { - const ev: ToolEvent = { ...lastToolStart, result: event.result }; - pendingToolEvents.push(ev); - lastToolStart = null; - setMessages(prev => { - const copy = [...prev]; - const last = { ...copy[copy.length - 1] }; - last.toolEvents = [...pendingToolEvents]; - copy[copy.length - 1] = last; - return copy; - }); - } - } else if (event.type === "usage") { - const pt = event.prompt_tokens ?? 0; - const ct = event.completion_tokens ?? 0; - const cost = computeCost(pt, ct, selectedModelInfo); - setSessionUsage(prev => ({ - promptTokens: prev.promptTokens + pt, - completionTokens: prev.completionTokens + ct, - costUsd: prev.costUsd + cost, - })); - } else if (event.type === "error") { - setError(event.message); - } - } catch { /* ignore */ } - } - } - } catch (err) { - if ((err as Error).name !== "AbortError") { - setError(err instanceof Error ? err.message : "Erreur inconnue"); - } - } finally { - setLoading(false); - } - }, [input, loading, messages, selectedModel, selectedModelInfo]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - sendMessage(); - } - }; - - const clearConversation = () => { - setMessages([]); - setSessionUsage({ promptTokens: 0, completionTokens: 0, costUsd: 0 }); - setError(null); - if (loading && abortRef.current) { - abortRef.current.abort(); - setLoading(false); - } - }; - - return ( -
    - {/* Header */} -
    -
    -

    - - Admin AI Chat -

    -

    - Accès direct à la base de données · Admin uniquement -

    -
    - - {/* Usage / Cost */} -
    -
    - - {formatCost(sessionUsage.costUsd)} - session - {sessionUsage.promptTokens > 0 && ( - - ({sessionUsage.promptTokens + sessionUsage.completionTokens} tok) - - )} -
    - - -
    -
    - - {/* Model selector */} -
    -
    - - {aiProvider === "google" ? "Google AI" : "OpenRouter"} - - {loadingModels ? ( -
    - - Chargement des modèles… -
    - ) : ( -
    - - {selectedModelInfo && !selectedModelInfo.free && ( -
    - in: ${(selectedModelInfo.promptPrice * 1_000_000).toFixed(2)}/M - · - out: ${(selectedModelInfo.completionPrice * 1_000_000).toFixed(2)}/M -
    - )} - {selectedModelInfo?.free && ( - GRATUIT - )} -
    - )} -
    -
    - - {/* Messages */} -
    - {messages.length === 0 && ( -
    -
    - -
    -
    -

    Assistant Admin CollectFlow

    -

    - Posez des questions sur vos données, demandez des analyses, ou explorez la base de données. -

    -
    -
    - {[ - "Combien de produits par fournisseur ?", - "Quels sont les 10 produits avec le plus de CA ?", - "Montre-moi les snapshots récents", - "Répartition des gammes A/B/C/D/Z", - ].map(suggestion => ( - - ))} -
    -
    - )} - - {messages.map((msg, i) => ( - - ))} - - {loading && messages[messages.length - 1]?.role !== "assistant" && ( -
    -
    - -
    -
    - - - -
    -
    - )} - - {error && ( -
    - - {error} -
    - )} - -
    -
    - - {/* Input */} -
    -
    -