"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 */}