conversation clan

This commit is contained in:
Biqoz
2025-11-27 14:23:08 +01:00
parent 73f97919ac
commit e6a9d41ebd
3 changed files with 243 additions and 166 deletions

View File

@@ -10,7 +10,9 @@
"Bash(git push)", "Bash(git push)",
"Bash(npm install:*)", "Bash(npm install:*)",
"Bash(node debug-search.js:*)", "Bash(node debug-search.js:*)",
"Bash(node update-referent.js:*)" "Bash(node update-referent.js:*)",
"Bash(node:*)",
"Bash(curl:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@@ -51,6 +51,12 @@ export async function GET(
const limit = parseInt(searchParams.get("limit") || "20"); const limit = parseInt(searchParams.get("limit") || "20");
const filter = JSON.parse(searchParams.get("filter") || "{}"); const filter = JSON.parse(searchParams.get("filter") || "{}");
// Debug logging pour messages
if (collection === "messages") {
console.log("[API Messages] Filter reçu:", JSON.stringify(filter));
console.log("[API Messages] Raw filter param:", searchParams.get("filter"));
}
// Gestion spéciale pour la collection users avec recherche par email ou id // Gestion spéciale pour la collection users avec recherche par email ou id
if (collection === "users") { if (collection === "users") {
const email = searchParams.get("email"); const email = searchParams.get("email");
@@ -141,6 +147,11 @@ export async function GET(
db.collection(collection).countDocuments(filter), db.collection(collection).countDocuments(filter),
]); ]);
// Debug logging pour messages
if (collection === "messages") {
console.log("[API Messages] Résultats:", data.length, "messages, total:", total);
}
return NextResponse.json({ return NextResponse.json({
data, data,
total, total,

View File

@@ -3,14 +3,6 @@
import { useState, useMemo, useCallback, useRef, useEffect, Fragment } from "react"; import { useState, useMemo, useCallback, useRef, useEffect, Fragment } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -51,6 +43,13 @@ interface ExtendedMessage extends LibreChatMessage {
[key: string]: unknown; [key: string]: unknown;
} }
// Type pour les groupes d'utilisateurs
interface UserGroup {
userId: string;
conversations: LibreChatConversation[];
totalMessages: number;
}
// Fetcher générique // Fetcher générique
async function fetchCollection<T>( async function fetchCollection<T>(
collection: string, collection: string,
@@ -63,36 +62,44 @@ async function fetchCollection<T>(
} }
}); });
const response = await fetch(`/api/collections/${collection}?${searchParams}`); const url = `/api/collections/${collection}?${searchParams}`;
console.log("[fetchCollection]", collection, "URL:", url);
const response = await fetch(url);
if (!response.ok) throw new Error(`Erreur lors du chargement de ${collection}`); if (!response.ok) throw new Error(`Erreur lors du chargement de ${collection}`);
return response.json(); const data = await response.json();
console.log("[fetchCollection]", collection, "Results:", data.data?.length, "items");
return data;
} }
export function ConversationsTable() { export function ConversationsTable() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [searchInput, setSearchInput] = useState(""); const [searchInput, setSearchInput] = useState("");
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null); const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set());
const [expandedConversation, setExpandedConversation] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const limit = 20; const usersPerPage = 10; // Nombre de groupes d'utilisateurs par page
const debouncedSearch = useDebounce(searchInput, 250); const debouncedSearch = useDebounce(searchInput, 250);
// Reset page quand la recherche change // Reset page et expanded states quand la recherche change
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
setExpandedUsers(new Set());
setExpandedConversation(null);
}, [debouncedSearch]); }, [debouncedSearch]);
// Query conversations avec TanStack Query // Query conversations avec TanStack Query - limite élevée pour groupement client
const { const {
data: conversationsData, data: conversationsData,
isLoading: conversationsLoading, isLoading: conversationsLoading,
isFetching: conversationsFetching, isFetching: conversationsFetching,
} = useQuery({ } = useQuery({
queryKey: ["conversations", page, limit, debouncedSearch], queryKey: ["conversations", debouncedSearch],
queryFn: () => queryFn: () =>
fetchCollection<LibreChatConversation>("conversations", { fetchCollection<LibreChatConversation>("conversations", {
page, page: 1,
limit, limit: 1000, // Charger beaucoup pour grouper côté client
search: debouncedSearch, search: debouncedSearch,
}), }),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
@@ -108,25 +115,79 @@ export function ConversationsTable() {
// Query messages de la conversation sélectionnée // Query messages de la conversation sélectionnée
const { data: messagesData, isLoading: messagesLoading } = useQuery({ const { data: messagesData, isLoading: messagesLoading } = useQuery({
queryKey: ["messages", selectedConversationId], queryKey: ["messages", expandedConversation],
queryFn: () => queryFn: () =>
fetchCollection<LibreChatMessage>("messages", { fetchCollection<LibreChatMessage>("messages", {
limit: 500, limit: 500,
filter: JSON.stringify({ conversationId: selectedConversationId }), filter: JSON.stringify({ conversationId: expandedConversation }),
}), }),
enabled: !!selectedConversationId, enabled: !!expandedConversation,
staleTime: 30000, staleTime: 30000,
}); });
const conversations = conversationsData?.data ?? []; const conversations = conversationsData?.data ?? [];
const total = conversationsData?.total ?? 0; const total = conversationsData?.total ?? 0;
const totalPages = conversationsData?.totalPages ?? 0;
const users = usersData?.data ?? []; const users = usersData?.data ?? [];
const messages = messagesData?.data ?? []; const messages = messagesData?.data ?? [];
// Map des users pour lookup rapide // Map des users pour lookup rapide
const userMap = useMemo(() => new Map(users.map((u) => [u._id, u])), [users]); const userMap = useMemo(() => new Map(users.map((u) => [u._id, u])), [users]);
// Grouper les conversations par utilisateur
const groupedByUser = useMemo((): UserGroup[] => {
const groups: Record<string, LibreChatConversation[]> = {};
conversations.forEach((conv) => {
const uId = String(conv.user);
if (!groups[uId]) groups[uId] = [];
groups[uId].push(conv);
});
return Object.entries(groups)
.map(([userId, convs]) => ({
userId,
conversations: convs.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
),
totalMessages: convs.reduce((sum, c) => sum + (c.messages?.length || 0), 0),
}))
.sort((a, b) => {
// Trier par date de dernière conversation
const aDate = new Date(a.conversations[0]?.updatedAt || 0).getTime();
const bDate = new Date(b.conversations[0]?.updatedAt || 0).getTime();
return bDate - aDate;
});
}, [conversations]);
// Pagination sur les groupes d'utilisateurs
const totalUserGroups = groupedByUser.length;
const totalPages = Math.ceil(totalUserGroups / usersPerPage);
const paginatedGroups = groupedByUser.slice(
(page - 1) * usersPerPage,
page * usersPerPage
);
// Toggle user group expansion
const toggleUserExpanded = useCallback((userId: string) => {
setExpandedUsers((prev) => {
const next = new Set(prev);
if (next.has(userId)) {
next.delete(userId);
// Fermer aussi la conversation si elle appartient à cet utilisateur
setExpandedConversation(null);
} else {
next.add(userId);
}
return next;
});
}, []);
// Toggle conversation expansion
const toggleConversationExpanded = useCallback((conversationId: string) => {
console.log("[Conversations] Toggle conversation:", conversationId);
setExpandedConversation((prev) =>
prev === conversationId ? null : conversationId
);
}, []);
const getUserInfo = useCallback( const getUserInfo = useCallback(
(userId: string) => { (userId: string) => {
const user = userMap.get(userId); const user = userMap.get(userId);
@@ -236,164 +297,168 @@ export function ConversationsTable() {
</div> </div>
) : ( ) : (
<> <>
<div className="rounded-md border"> {/* Liste des groupes d'utilisateurs */}
<Table> <div className="space-y-2">
<TableHeader> {paginatedGroups.map((group) => {
<TableRow> const userInfo = getUserInfo(group.userId);
<TableHead className="w-52">Utilisateur</TableHead> const isUserExpanded = expandedUsers.has(group.userId);
<TableHead>Titre</TableHead>
<TableHead className="w-24">Endpoint</TableHead>
<TableHead className="w-20 text-center">Msgs</TableHead>
<TableHead className="w-24">Statut</TableHead>
<TableHead className="w-28">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{conversations.map((conv) => {
const userInfo = getUserInfo(String(conv.user));
const msgCount = conv.messages?.length || 0;
const isSelected = selectedConversationId === conv.conversationId;
return ( return (
<Fragment key={conv._id}> <div key={group.userId} className="border rounded-lg overflow-hidden">
<TableRow {/* Header du groupe utilisateur */}
className={`cursor-pointer hover:bg-muted/50 transition-colors ${isSelected ? "bg-muted/50" : ""}`} <button
onClick={() => onClick={() => toggleUserExpanded(group.userId)}
setSelectedConversationId( className="w-full flex items-center gap-3 p-4 bg-muted/30 hover:bg-muted/50 transition-colors text-left"
isSelected ? null : conv.conversationId >
) <ChevronDown
} className={`h-5 w-5 text-muted-foreground transition-transform flex-shrink-0 ${
> isUserExpanded ? "rotate-0" : "-rotate-90"
<TableCell> }`}
<div className="flex items-center gap-2"> />
<ChevronDown <div className="flex-1 min-w-0">
className={`h-4 w-4 text-muted-foreground transition-transform ${ <div className="flex items-center gap-2 flex-wrap">
isSelected ? "rotate-0" : "-rotate-90" <span className="font-semibold">{userInfo.name}</span>
{userInfo.email && (
<span className="text-sm text-muted-foreground">
({userInfo.email})
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant="secondary">
{group.conversations.length} conversation{group.conversations.length > 1 ? "s" : ""}
</Badge>
<Badge variant="outline" className="text-xs">
{group.totalMessages} msg{group.totalMessages > 1 ? "s" : ""}
</Badge>
</div>
</button>
{/* Liste des conversations de l'utilisateur */}
{isUserExpanded && (
<div className="border-t">
{group.conversations.map((conv) => {
const msgCount = conv.messages?.length || 0;
const isConvExpanded = expandedConversation === conv.conversationId;
return (
<Fragment key={conv._id}>
<button
onClick={() => toggleConversationExpanded(conv.conversationId)}
className={`w-full flex items-center gap-3 p-3 pl-10 hover:bg-muted/30 transition-colors text-left border-b last:border-b-0 ${
isConvExpanded ? "bg-muted/20" : ""
}`} }`}
/> >
<div className="flex flex-col"> <ChevronDown
<span className="font-medium truncate max-w-[180px]"> className={`h-4 w-4 text-muted-foreground transition-transform flex-shrink-0 ${
{userInfo.name} isConvExpanded ? "rotate-0" : "-rotate-90"
</span> }`}
{userInfo.email && ( />
<span className="text-xs text-muted-foreground truncate max-w-[180px]"> <div className="flex-1 min-w-0">
{userInfo.email} <span className="truncate block text-sm font-medium">
{String(conv.title) || "Sans titre"}
</span> </span>
)} </div>
</div> <div className="flex items-center gap-2 flex-shrink-0">
</div> <Badge variant="outline" className="text-xs font-mono">
</TableCell> {String(conv.endpoint).slice(0, 8)}
<TableCell> </Badge>
<span className="truncate block max-w-xs" title={String(conv.title)}> <Badge variant="secondary" className="text-xs">
{String(conv.title) || "Sans titre"} {msgCount} msg{msgCount > 1 ? "s" : ""}
</span> </Badge>
</TableCell> <Badge
<TableCell> variant={conv.isArchived ? "outline" : "default"}
<Badge variant="outline" className="text-xs font-mono"> className="text-xs"
{String(conv.endpoint).slice(0, 10)} >
</Badge> {conv.isArchived ? "Archivée" : "Active"}
</TableCell> </Badge>
<TableCell className="text-center"> <span className="text-xs text-muted-foreground">
<Badge variant="secondary" className="text-xs"> {formatDate(conv.updatedAt)}
{msgCount} </span>
</Badge> </div>
</TableCell> </button>
<TableCell>
<Badge
variant={conv.isArchived ? "outline" : "default"}
className="text-xs"
>
{conv.isArchived ? "Archivée" : "Active"}
</Badge>
</TableCell>
<TableCell>
<span className="text-xs text-muted-foreground">
{formatDate(conv.updatedAt)}
</span>
</TableCell>
</TableRow>
{/* Messages inline sous la row */} {/* Messages de la conversation */}
{isSelected && ( {isConvExpanded && (
<TableRow key={`${conv._id}-messages`}> <div className="bg-slate-50 p-4 pl-14 border-b">
<TableCell colSpan={6} className="p-0 border-b"> {messagesLoading ? (
<div className="bg-slate-50 p-4"> <div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
{messagesLoading ? ( <Loader2 className="h-5 w-5 animate-spin" />
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground"> <span>Chargement des messages...</span>
<Loader2 className="h-5 w-5 animate-spin" /> </div>
<span>Chargement des messages...</span> ) : messages.length === 0 ? (
</div> <p className="text-center py-8 text-muted-foreground">
) : messages.length === 0 ? ( Aucun message dans cette conversation
<p className="text-center py-8 text-muted-foreground"> </p>
Aucun message dans cette conversation ) : (
</p> <div className="space-y-3 max-h-[400px] overflow-y-auto">
) : ( {messages
<div className="space-y-3 max-h-[400px] overflow-y-auto"> .sort(
{messages (a, b) =>
.sort( new Date(a.createdAt).getTime() -
(a, b) => new Date(b.createdAt).getTime()
new Date(a.createdAt).getTime() - )
new Date(b.createdAt).getTime() .map((msg) => {
) const content = getMessageContent(msg);
.map((msg) => { const isUser = msg.isCreatedByUser;
const content = getMessageContent(msg);
const isUser = msg.isCreatedByUser;
return ( return (
<div <div
key={msg._id} key={msg._id}
className={`flex gap-3 p-3 rounded-lg border-l-4 ${ className={`flex gap-3 p-3 rounded-lg border-l-4 ${
isUser isUser
? "bg-blue-50 border-l-blue-500" ? "bg-blue-50 border-l-blue-500"
: "bg-white border-l-gray-300" : "bg-white border-l-gray-300"
}`} }`}
> >
<div className="flex-shrink-0 mt-0.5"> <div className="flex-shrink-0 mt-0.5">
{isUser ? ( {isUser ? (
<User className="h-4 w-4 text-blue-600" /> <User className="h-4 w-4 text-blue-600" />
) : ( ) : (
<Bot className="h-4 w-4 text-gray-500" /> <Bot className="h-4 w-4 text-gray-500" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs font-medium">
{isUser ? "Utilisateur" : "Assistant"}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(msg.createdAt)}
</span>
{msg.tokenCount > 0 && (
<span className="text-xs text-muted-foreground">
({msg.tokenCount} tokens)
</span>
)} )}
</div> </div>
<div className="text-sm whitespace-pre-wrap break-words"> <div className="flex-1 min-w-0">
{content} <div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs font-medium">
{isUser ? "Utilisateur" : "Assistant"}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(msg.createdAt)}
</span>
{msg.tokenCount > 0 && (
<span className="text-xs text-muted-foreground">
({msg.tokenCount} tokens)
</span>
)}
</div>
<div className="text-sm whitespace-pre-wrap break-words">
{content}
</div>
</div> </div>
</div> </div>
</div> );
); })}
})} </div>
</div> )}
)} </div>
</div> )}
</TableCell> </Fragment>
</TableRow> );
)} })}
</Fragment> </div>
); )}
})} </div>
</TableBody> );
</Table> })}
</div> </div>
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between mt-4"> <div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Page {page} / {totalPages} Page {page} / {totalPages} ({totalUserGroups} utilisateurs)
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -421,7 +486,6 @@ export function ConversationsTable() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }