From 78cdc608d097a91eb2ee8bb85e0db7819caa3deb Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 10 Apr 2026 13:23:35 +0200 Subject: [PATCH] feat: implement admin AI chat interface with model selection and real-time cost tracking --- src/app/(dashboard)/admin/ai-chat/page.tsx | 437 +++++++++++++++++++++ src/app/api/admin/ai-chat/route.ts | 353 +++++++++++++++++ src/app/api/openrouter/models/route.ts | 4 +- src/components/shared/sidebar.tsx | 3 +- 4 files changed, 795 insertions(+), 2 deletions(-) create mode 100644 src/app/(dashboard)/admin/ai-chat/page.tsx create mode 100644 src/app/api/admin/ai-chat/route.ts diff --git a/src/app/(dashboard)/admin/ai-chat/page.tsx b/src/app/(dashboard)/admin/ai-chat/page.tsx new file mode 100644 index 0000000..823265e --- /dev/null +++ b/src/app/(dashboard)/admin/ai-chat/page.tsx @@ -0,0 +1,437 @@ +"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"; + +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 && ( +
+ {msg.content} +
+ )} +
+
+ ); +} + +export default function AdminAiChatPage() { + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL); + 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 models + useEffect(() => { + setLoadingModels(true); + fetch("/api/openrouter/models") + .then(r => r.json()) + .then(d => { + if (d.models) setModels(d.models); + }) + .catch(() => { }) + .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 */} +
+
+ Modèle + {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 */} +
+
+