mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-17 12:19:50 +02:00
feat: implement admin AI chat interface with model selection and real-time cost tracking
This commit is contained in:
1 parent
f21f38f805
commit
78cdc608d0
4 files changed
+795
-2
No files matched your search
@@ -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<string, unknown>;
|
||||||
|
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 (
|
||||||
|
<div className="my-1 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] overflow-hidden text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--bg-base)] transition-colors"
|
||||||
|
>
|
||||||
|
<Database className="w-3.5 h-3.5 text-[var(--accent)] shrink-0" />
|
||||||
|
<span className="flex-1 text-[var(--text-secondary)] font-mono truncate">{label}</span>
|
||||||
|
<ChevronDown className={cn("w-3.5 h-3.5 text-[var(--text-muted)] transition-transform", open && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
{open && event.result && (
|
||||||
|
<pre className="px-3 pb-3 pt-1 text-[10px] text-[var(--text-secondary)] overflow-x-auto whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
|
||||||
|
{event.result}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ msg }: { msg: Message }) {
|
||||||
|
const isUser = msg.role === "user";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex gap-3 mb-4", isUser && "flex-row-reverse")}>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className={cn(
|
||||||
|
"w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5",
|
||||||
|
isUser ? "bg-[var(--accent)] text-white" : "bg-[var(--bg-elevated)] border border-[var(--border)]"
|
||||||
|
)}>
|
||||||
|
{isUser
|
||||||
|
? <User className="w-4 h-4" />
|
||||||
|
: <Bot className="w-4 h-4 text-[var(--accent)]" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={cn("flex-1 max-w-[85%]", isUser && "flex flex-col items-end")}>
|
||||||
|
{/* Tool events (assistant only) */}
|
||||||
|
{msg.toolEvents && msg.toolEvents.length > 0 && (
|
||||||
|
<div className="w-full mb-2">
|
||||||
|
{msg.toolEvents.map((ev, i) => (
|
||||||
|
<ToolCallBlock key={i} event={ev} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text bubble */}
|
||||||
|
{msg.content && (
|
||||||
|
<div className={cn(
|
||||||
|
"rounded-2xl px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap break-words",
|
||||||
|
isUser
|
||||||
|
? "bg-[var(--accent)] text-white rounded-tr-md"
|
||||||
|
: "bg-[var(--bg-elevated)] border border-[var(--border)] text-[var(--text-primary)] rounded-tl-md"
|
||||||
|
)}>
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminAiChatPage() {
|
||||||
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_MODEL);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loadingModels, setLoadingModels] = useState(true);
|
||||||
|
const [sessionUsage, setSessionUsage] = useState<SessionUsage>({ promptTokens: 0, completionTokens: 0, costUsd: 0 });
|
||||||
|
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(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<HTMLTextAreaElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-full max-h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4 shrink-0">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||||
|
<Bot className="w-5 h-5 text-[var(--accent)]" />
|
||||||
|
Admin AI Chat
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||||
|
Accès direct à la base de données · Admin uniquement
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage / Cost */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border)] text-xs text-[var(--text-secondary)]">
|
||||||
|
<DollarSign className="w-3.5 h-3.5 text-[var(--accent)]" />
|
||||||
|
<span className="font-mono font-semibold">{formatCost(sessionUsage.costUsd)}</span>
|
||||||
|
<span className="text-[var(--text-muted)]">session</span>
|
||||||
|
{sessionUsage.promptTokens > 0 && (
|
||||||
|
<span className="text-[var(--text-muted)] ml-1">
|
||||||
|
({sessionUsage.promptTokens + sessionUsage.completionTokens} tok)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={clearConversation}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[var(--border)] text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model selector */}
|
||||||
|
<div className="mb-4 shrink-0">
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)]">
|
||||||
|
<span className="text-xs font-medium text-[var(--text-muted)] shrink-0">Modèle</span>
|
||||||
|
{loadingModels ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-[var(--text-muted)]">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
Chargement des modèles…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<select
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={e => setSelectedModel(e.target.value)}
|
||||||
|
className="flex-1 min-w-0 text-sm bg-transparent text-[var(--text-primary)] border-none outline-none cursor-pointer"
|
||||||
|
>
|
||||||
|
{models.map(m => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name} {m.free ? "(Gratuit)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedModelInfo && !selectedModelInfo.free && (
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] shrink-0 font-mono">
|
||||||
|
<span>in: ${(selectedModelInfo.promptPrice * 1_000_000).toFixed(2)}/M</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>out: ${(selectedModelInfo.completionPrice * 1_000_000).toFixed(2)}/M</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedModelInfo?.free && (
|
||||||
|
<span className="text-[10px] text-emerald-500 font-semibold shrink-0">GRATUIT</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0 rounded-xl bg-[var(--bg-base)] border border-[var(--border)] p-4">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center gap-4">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
|
||||||
|
<Bot className="w-8 h-8 text-[var(--accent)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--text-primary)] font-medium mb-1">Assistant Admin CollectFlow</p>
|
||||||
|
<p className="text-sm text-[var(--text-muted)] max-w-md">
|
||||||
|
Posez des questions sur vos données, demandez des analyses, ou explorez la base de données.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 max-w-lg mt-2">
|
||||||
|
{[
|
||||||
|
"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 => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
onClick={() => { setInput(suggestion); textareaRef.current?.focus(); }}
|
||||||
|
className="text-xs text-left px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-base)] text-[var(--text-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<MessageBubble key={i} msg={msg} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{loading && messages[messages.length - 1]?.role !== "assistant" && (
|
||||||
|
<div className="flex gap-3 mb-4">
|
||||||
|
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-[var(--bg-elevated)] border border-[var(--border)]">
|
||||||
|
<Bot className="w-4 h-4 text-[var(--accent)]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 px-4 py-3 rounded-2xl rounded-tl-md bg-[var(--bg-elevated)] border border-[var(--border)]">
|
||||||
|
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:0ms]" />
|
||||||
|
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:150ms]" />
|
||||||
|
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:300ms]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-xl bg-[var(--accent-error-bg)] border border-[var(--accent-error-border,var(--border))] text-sm text-[var(--accent-error)] mb-4">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="mt-3 shrink-0">
|
||||||
|
<div className="flex items-end gap-2 p-2 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)] focus-within:border-[var(--accent)] transition-colors">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Posez une question… (Entrée pour envoyer, Shift+Entrée pour nouvelle ligne)"
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 resize-none bg-transparent text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none py-2 px-2 max-h-32 overflow-y-auto"
|
||||||
|
style={{ minHeight: "40px" }}
|
||||||
|
onInput={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 128) + "px";
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={sendMessage}
|
||||||
|
disabled={!input.trim() || loading}
|
||||||
|
className="w-9 h-9 rounded-lg bg-[var(--accent)] text-white flex items-center justify-center hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
: <Send className="w-4 h-4" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-[var(--text-muted)] mt-1.5 text-center">
|
||||||
|
L'IA peut exécuter des requêtes SELECT sur la base de données · Les modifications ne sont pas autorisées
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getDb } from "@/db";
|
||||||
|
|
||||||
|
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||||
|
|
||||||
|
// Tools available to the admin AI
|
||||||
|
const TOOLS = [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "execute_sql",
|
||||||
|
description:
|
||||||
|
"Execute a READ-ONLY SQL SELECT query on the CollectFlow PostgreSQL database. Use this to answer questions about data.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"A valid PostgreSQL SELECT query. Only SELECT statements are allowed.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_db_schema",
|
||||||
|
description:
|
||||||
|
"Returns the full database schema with table names, columns, and descriptions.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DB_SCHEMA = `
|
||||||
|
## Tables de la base de données CollectFlow
|
||||||
|
|
||||||
|
### ventes_produits
|
||||||
|
Données de ventes produits (historique 12 mois par magasin)
|
||||||
|
- id (serial PK)
|
||||||
|
- codein (varchar) — identifiant produit interne
|
||||||
|
- code_fournisseur (varchar) — code fournisseur
|
||||||
|
- nom_fournisseur (varchar) — nom du fournisseur
|
||||||
|
- libelle1 (varchar) — libellé produit
|
||||||
|
- gtin (varchar) — code-barres
|
||||||
|
- reference (varchar) — référence
|
||||||
|
- colisage (numeric) — quantité par colis
|
||||||
|
- code_gamme (varchar) — classification actuelle (A/B/C/D/Z)
|
||||||
|
- code_gamme_init (varchar) — classification initiale
|
||||||
|
- code3 / libelle3 — sous-catégorie
|
||||||
|
- magasin (varchar) — code magasin
|
||||||
|
- code_magasin (varchar) — code magasin alternatif
|
||||||
|
- annee (smallint), mois (smallint) — période
|
||||||
|
- periode (varchar) — ex: "2024-01"
|
||||||
|
- quantite (numeric) — quantité vendue
|
||||||
|
- montant_mvt (numeric) — CA HT
|
||||||
|
- marge_mvt (numeric) — marge HT
|
||||||
|
- imported_at, updated_at (timestamp)
|
||||||
|
|
||||||
|
### users
|
||||||
|
Utilisateurs de l'application
|
||||||
|
- id (serial PK)
|
||||||
|
- username (varchar unique)
|
||||||
|
- password_hash (text) — NE PAS EXPOSER
|
||||||
|
- role (varchar) — 'admin' ou 'user'
|
||||||
|
- created_at (timestamp)
|
||||||
|
|
||||||
|
### session_snapshots
|
||||||
|
Snapshots de sessions d'arbitrage gamme
|
||||||
|
- id (serial PK)
|
||||||
|
- user_id (int FK → users)
|
||||||
|
- code_fournisseur, nom_fournisseur (varchar)
|
||||||
|
- magasin (varchar)
|
||||||
|
- changes (jsonb) — map codein → {before, after}
|
||||||
|
- summary_json (jsonb) — statistiques résumées
|
||||||
|
- label (text) — nom du snapshot
|
||||||
|
- type (varchar) — 'snapshot' ou 'export'
|
||||||
|
- created_at (timestamp)
|
||||||
|
|
||||||
|
### ai_supplier_context
|
||||||
|
Règles métier IA par fournisseur
|
||||||
|
- code_fournisseur (varchar PK)
|
||||||
|
- context (text) — règles saisies par le manager
|
||||||
|
- updated_at (timestamp)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `Tu es un assistant IA administrateur pour l'application CollectFlow, un outil de gestion de gammes produits et d'analyse des ventes pour des magasins de distribution.
|
||||||
|
|
||||||
|
Tu as accès à la base de données PostgreSQL de l'application via l'outil \`execute_sql\`. Tu peux aussi consulter le schéma avec \`get_db_schema\`.
|
||||||
|
|
||||||
|
**Règles importantes :**
|
||||||
|
- N'expose JAMAIS le champ password_hash de la table users
|
||||||
|
- Utilise uniquement des SELECT (pas de INSERT, UPDATE, DELETE, DROP, etc.)
|
||||||
|
- Limite tes requêtes à 500 lignes maximum par défaut (utilise LIMIT)
|
||||||
|
- Si une requête est trop large, agrège les données
|
||||||
|
|
||||||
|
**Contexte métier :**
|
||||||
|
- Les gammes A/B/C/D/Z classifient les produits : A=pilier, B=rotation, C=performance, D=saisonnier, Z=sortie
|
||||||
|
- Les magasins sont identifiés par leur code (ex: "001", "002")
|
||||||
|
- Les fournisseurs ont un code et un nom
|
||||||
|
- Les ventes sont enregistrées par mois et par magasin
|
||||||
|
|
||||||
|
Réponds toujours en français, de façon claire et structurée.`;
|
||||||
|
|
||||||
|
async function executeSql(query: string): Promise<string> {
|
||||||
|
// Security: only allow SELECT statements
|
||||||
|
const normalizedQuery = query.trim().toUpperCase();
|
||||||
|
if (!normalizedQuery.startsWith("SELECT") && !normalizedQuery.startsWith("WITH")) {
|
||||||
|
return "Erreur: Seules les requêtes SELECT sont autorisées.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block dangerous keywords
|
||||||
|
const blocked = ["INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE", "ALTER", "CREATE", "GRANT", "REVOKE"];
|
||||||
|
for (const kw of blocked) {
|
||||||
|
if (new RegExp(`\\b${kw}\\b`).test(normalizedQuery)) {
|
||||||
|
return `Erreur: Le mot-clé ${kw} n'est pas autorisé.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const result = await db.execute(query as any);
|
||||||
|
const rows = result.rows ?? [];
|
||||||
|
|
||||||
|
if (rows.length === 0) return "Aucun résultat.";
|
||||||
|
|
||||||
|
// Return as compact JSON (max 200 rows)
|
||||||
|
const limited = rows.slice(0, 200);
|
||||||
|
const truncated = rows.length > 200 ? ` (résultats tronqués à 200/${rows.length})` : "";
|
||||||
|
|
||||||
|
return JSON.stringify(limited, null, 2) + truncated;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
return `Erreur SQL: ${msg}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callOpenRouter(
|
||||||
|
messages: object[],
|
||||||
|
model: string,
|
||||||
|
apiKey: string,
|
||||||
|
useTools: boolean,
|
||||||
|
stream: boolean
|
||||||
|
): Promise<Response> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 4096,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (useTools) {
|
||||||
|
body.tools = TOOLS;
|
||||||
|
body.tool_choice = "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
body.stream = true;
|
||||||
|
body.stream_options = { include_usage: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(OPENROUTER_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "https://collectflow.app",
|
||||||
|
"X-Title": "CollectFlow Admin Chat",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
// Auth check
|
||||||
|
const session = await auth();
|
||||||
|
if (!session || (session.user as any)?.role !== "admin") {
|
||||||
|
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messages, model } = await req.json();
|
||||||
|
|
||||||
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return new Response(JSON.stringify({ error: "No OpenRouter API key configured" }), { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build message list with system prompt
|
||||||
|
const fullMessages: object[] = [
|
||||||
|
{ role: "system", content: SYSTEM_PROMPT },
|
||||||
|
...messages,
|
||||||
|
];
|
||||||
|
|
||||||
|
// SSE stream
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
function send(event: object) {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let currentMessages = [...fullMessages];
|
||||||
|
let totalPromptTokens = 0;
|
||||||
|
let totalCompletionTokens = 0;
|
||||||
|
|
||||||
|
// Tool-calling loop (non-streaming for tool phase)
|
||||||
|
for (let round = 0; round < 5; round++) {
|
||||||
|
const res = await callOpenRouter(currentMessages, model, apiKey, true, false);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text();
|
||||||
|
send({ type: "error", message: `OpenRouter error: ${errText}` });
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const usage = data.usage;
|
||||||
|
if (usage) {
|
||||||
|
totalPromptTokens += usage.prompt_tokens ?? 0;
|
||||||
|
totalCompletionTokens += usage.completion_tokens ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const choice = data.choices?.[0];
|
||||||
|
const assistantMessage = choice?.message;
|
||||||
|
|
||||||
|
if (!assistantMessage) break;
|
||||||
|
|
||||||
|
currentMessages.push(assistantMessage);
|
||||||
|
|
||||||
|
const toolCalls = assistantMessage.tool_calls;
|
||||||
|
if (!toolCalls || toolCalls.length === 0) {
|
||||||
|
// No more tool calls — stream final response
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute tool calls
|
||||||
|
const toolResults: object[] = [];
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
const fnName = tc.function?.name;
|
||||||
|
let args: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
args = JSON.parse(tc.function?.arguments ?? "{}");
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
send({ type: "tool_start", name: fnName, args });
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
if (fnName === "execute_sql") {
|
||||||
|
result = await executeSql(args.query as string);
|
||||||
|
} else if (fnName === "get_db_schema") {
|
||||||
|
result = DB_SCHEMA;
|
||||||
|
} else {
|
||||||
|
result = `Outil inconnu: ${fnName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
send({ type: "tool_result", name: fnName, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") });
|
||||||
|
|
||||||
|
toolResults.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: tc.id,
|
||||||
|
content: result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMessages.push(...toolResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final streaming response
|
||||||
|
const finalRes = await callOpenRouter(currentMessages, model, apiKey, false, true);
|
||||||
|
|
||||||
|
if (!finalRes.ok) {
|
||||||
|
const errText = await finalRes.text();
|
||||||
|
send({ type: "error", message: `OpenRouter error: ${errText}` });
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream the response chunks
|
||||||
|
const reader = finalRes.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let finalUsage: { prompt_tokens?: number; completion_tokens?: number } | null = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += textDecoder.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();
|
||||||
|
if (raw === "[DONE]") continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chunk = JSON.parse(raw);
|
||||||
|
const delta = chunk.choices?.[0]?.delta?.content;
|
||||||
|
if (delta) {
|
||||||
|
send({ type: "text", content: delta });
|
||||||
|
}
|
||||||
|
if (chunk.usage) {
|
||||||
|
finalUsage = chunk.usage;
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalUsage) {
|
||||||
|
totalPromptTokens += finalUsage.prompt_tokens ?? 0;
|
||||||
|
totalCompletionTokens += finalUsage.completion_tokens ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
send({
|
||||||
|
type: "usage",
|
||||||
|
prompt_tokens: totalPromptTokens,
|
||||||
|
completion_tokens: totalCompletionTokens,
|
||||||
|
});
|
||||||
|
|
||||||
|
send({ type: "done" });
|
||||||
|
controller.close();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
send({ type: "error", message: msg });
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -23,10 +23,12 @@ export async function GET(req: NextRequest) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// Return simplified list sorted by name
|
// Return simplified list sorted by name
|
||||||
const models = (data.data as Array<{ id: string; name: string; pricing?: { prompt: string } }>)
|
const models = (data.data as Array<{ id: string; name: string; pricing?: { prompt: string; completion: string } }>)
|
||||||
.map((m) => ({
|
.map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
|
promptPrice: parseFloat(m.pricing?.prompt ?? "0"),
|
||||||
|
completionPrice: parseFloat(m.pricing?.completion ?? "0"),
|
||||||
free: parseFloat(m.pricing?.prompt ?? "1") === 0,
|
free: parseFloat(m.pricing?.prompt ?? "1") === 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react";
|
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, MessageSquareCode } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -18,6 +18,7 @@ const NAV_ITEMS = [
|
|||||||
{ icon: ShoppingCart, label: "Commandes auto", href: "/commandes-auto" },
|
{ icon: ShoppingCart, label: "Commandes auto", href: "/commandes-auto" },
|
||||||
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
|
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
|
||||||
{ icon: FileDown, label: "Exports", href: "/exports", adminOnly: true },
|
{ icon: FileDown, label: "Exports", href: "/exports", adminOnly: true },
|
||||||
|
{ icon: MessageSquareCode, label: "AI Chat Admin", href: "/admin/ai-chat", adminOnly: true },
|
||||||
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
|
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user