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(npm install:*)",
"Bash(node debug-search.js:*)",
"Bash(node update-referent.js:*)"
"Bash(node update-referent.js:*)",
"Bash(node:*)",
"Bash(curl:*)"
],
"deny": [],
"ask": []

View File

@@ -51,6 +51,12 @@ export async function GET(
const limit = parseInt(searchParams.get("limit") || "20");
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
if (collection === "users") {
const email = searchParams.get("email");
@@ -141,6 +147,11 @@ export async function GET(
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({
data,
total,

View File

@@ -3,14 +3,6 @@
import { useState, useMemo, useCallback, useRef, useEffect, Fragment } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
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 { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@@ -51,6 +43,13 @@ interface ExtendedMessage extends LibreChatMessage {
[key: string]: unknown;
}
// Type pour les groupes d'utilisateurs
interface UserGroup {
userId: string;
conversations: LibreChatConversation[];
totalMessages: number;
}
// Fetcher générique
async function fetchCollection<T>(
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}`);
return response.json();
const data = await response.json();
console.log("[fetchCollection]", collection, "Results:", data.data?.length, "items");
return data;
}
export function ConversationsTable() {
const [page, setPage] = useState(1);
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 limit = 20;
const usersPerPage = 10; // Nombre de groupes d'utilisateurs par page
const debouncedSearch = useDebounce(searchInput, 250);
// Reset page quand la recherche change
// Reset page et expanded states quand la recherche change
useEffect(() => {
setPage(1);
setExpandedUsers(new Set());
setExpandedConversation(null);
}, [debouncedSearch]);
// Query conversations avec TanStack Query
// Query conversations avec TanStack Query - limite élevée pour groupement client
const {
data: conversationsData,
isLoading: conversationsLoading,
isFetching: conversationsFetching,
} = useQuery({
queryKey: ["conversations", page, limit, debouncedSearch],
queryKey: ["conversations", debouncedSearch],
queryFn: () =>
fetchCollection<LibreChatConversation>("conversations", {
page,
limit,
page: 1,
limit: 1000, // Charger beaucoup pour grouper côté client
search: debouncedSearch,
}),
placeholderData: keepPreviousData,
@@ -108,25 +115,79 @@ export function ConversationsTable() {
// Query messages de la conversation sélectionnée
const { data: messagesData, isLoading: messagesLoading } = useQuery({
queryKey: ["messages", selectedConversationId],
queryKey: ["messages", expandedConversation],
queryFn: () =>
fetchCollection<LibreChatMessage>("messages", {
limit: 500,
filter: JSON.stringify({ conversationId: selectedConversationId }),
filter: JSON.stringify({ conversationId: expandedConversation }),
}),
enabled: !!selectedConversationId,
enabled: !!expandedConversation,
staleTime: 30000,
});
const conversations = conversationsData?.data ?? [];
const total = conversationsData?.total ?? 0;
const totalPages = conversationsData?.totalPages ?? 0;
const users = usersData?.data ?? [];
const messages = messagesData?.data ?? [];
// Map des users pour lookup rapide
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(
(userId: string) => {
const user = userMap.get(userId);
@@ -236,88 +297,91 @@ export function ConversationsTable() {
</div>
) : (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-52">Utilisateur</TableHead>
<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;
{/* Liste des groupes d'utilisateurs */}
<div className="space-y-2">
{paginatedGroups.map((group) => {
const userInfo = getUserInfo(group.userId);
const isUserExpanded = expandedUsers.has(group.userId);
return (
<Fragment key={conv._id}>
<TableRow
className={`cursor-pointer hover:bg-muted/50 transition-colors ${isSelected ? "bg-muted/50" : ""}`}
onClick={() =>
setSelectedConversationId(
isSelected ? null : conv.conversationId
)
}
<div key={group.userId} className="border rounded-lg overflow-hidden">
{/* Header du groupe utilisateur */}
<button
onClick={() => toggleUserExpanded(group.userId)}
className="w-full flex items-center gap-3 p-4 bg-muted/30 hover:bg-muted/50 transition-colors text-left"
>
<TableCell>
<div className="flex items-center gap-2">
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform ${
isSelected ? "rotate-0" : "-rotate-90"
className={`h-5 w-5 text-muted-foreground transition-transform flex-shrink-0 ${
isUserExpanded ? "rotate-0" : "-rotate-90"
}`}
/>
<div className="flex flex-col">
<span className="font-medium truncate max-w-[180px]">
{userInfo.name}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold">{userInfo.name}</span>
{userInfo.email && (
<span className="text-xs text-muted-foreground truncate max-w-[180px]">
{userInfo.email}
<span className="text-sm text-muted-foreground">
({userInfo.email})
</span>
)}
</div>
</div>
</TableCell>
<TableCell>
<span className="truncate block max-w-xs" title={String(conv.title)}>
<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" : ""
}`}
>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform flex-shrink-0 ${
isConvExpanded ? "rotate-0" : "-rotate-90"
}`}
/>
<div className="flex-1 min-w-0">
<span className="truncate block text-sm font-medium">
{String(conv.title) || "Sans titre"}
</span>
</TableCell>
<TableCell>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant="outline" className="text-xs font-mono">
{String(conv.endpoint).slice(0, 10)}
{String(conv.endpoint).slice(0, 8)}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary" className="text-xs">
{msgCount}
{msgCount} msg{msgCount > 1 ? "s" : ""}
</Badge>
</TableCell>
<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>
</div>
</button>
{/* Messages inline sous la row */}
{isSelected && (
<TableRow key={`${conv._id}-messages`}>
<TableCell colSpan={6} className="p-0 border-b">
<div className="bg-slate-50 p-4">
{/* Messages de la conversation */}
{isConvExpanded && (
<div className="bg-slate-50 p-4 pl-14 border-b">
{messagesLoading ? (
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
@@ -379,21 +443,22 @@ export function ConversationsTable() {
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
);
})}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
Page {page} / {totalPages}
Page {page} / {totalPages} ({totalUserGroups} utilisateurs)
</p>
<div className="flex gap-2">
<Button
@@ -421,7 +486,6 @@ export function ConversationsTable() {
)}
</CardContent>
</Card>
</div>
);
}