428 lines
17 KiB
TypeScript
428 lines
17 KiB
TypeScript
"use client";
|
|
|
|
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";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Search,
|
|
MessageSquare,
|
|
X,
|
|
User,
|
|
Bot,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { formatDate } from "@/lib/utils";
|
|
import {
|
|
LibreChatConversation,
|
|
LibreChatUser,
|
|
LibreChatMessage,
|
|
} from "@/lib/types";
|
|
|
|
// Hook debounce personnalisé
|
|
function useDebounce<T>(value: T, delay: number): T {
|
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
|
return () => clearTimeout(timer);
|
|
}, [value, delay]);
|
|
|
|
return debouncedValue;
|
|
}
|
|
|
|
// Types pour les messages étendus
|
|
interface ExtendedMessage extends LibreChatMessage {
|
|
content?: Array<{ type: string; text: string }> | string;
|
|
parts?: Array<string | { text: string }>;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
// Fetcher générique
|
|
async function fetchCollection<T>(
|
|
collection: string,
|
|
params: Record<string, string | number>
|
|
): Promise<{ data: T[]; total: number; totalPages: number }> {
|
|
const searchParams = new URLSearchParams();
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== "") {
|
|
searchParams.set(key, String(value));
|
|
}
|
|
});
|
|
|
|
const response = await fetch(`/api/collections/${collection}?${searchParams}`);
|
|
if (!response.ok) throw new Error(`Erreur lors du chargement de ${collection}`);
|
|
return response.json();
|
|
}
|
|
|
|
export function ConversationsTable() {
|
|
const [page, setPage] = useState(1);
|
|
const [searchInput, setSearchInput] = useState("");
|
|
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const limit = 20;
|
|
const debouncedSearch = useDebounce(searchInput, 250);
|
|
|
|
// Reset page quand la recherche change
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [debouncedSearch]);
|
|
|
|
// Query conversations avec TanStack Query
|
|
const {
|
|
data: conversationsData,
|
|
isLoading: conversationsLoading,
|
|
isFetching: conversationsFetching,
|
|
} = useQuery({
|
|
queryKey: ["conversations", page, limit, debouncedSearch],
|
|
queryFn: () =>
|
|
fetchCollection<LibreChatConversation>("conversations", {
|
|
page,
|
|
limit,
|
|
search: debouncedSearch,
|
|
}),
|
|
placeholderData: keepPreviousData,
|
|
staleTime: 30000, // 30 secondes
|
|
});
|
|
|
|
// Query users (cache long car ça change rarement)
|
|
const { data: usersData } = useQuery({
|
|
queryKey: ["users", "all"],
|
|
queryFn: () => fetchCollection<LibreChatUser>("users", { limit: 1000 }),
|
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
});
|
|
|
|
// Query messages de la conversation sélectionnée
|
|
const { data: messagesData, isLoading: messagesLoading } = useQuery({
|
|
queryKey: ["messages", selectedConversationId],
|
|
queryFn: () =>
|
|
fetchCollection<LibreChatMessage>("messages", {
|
|
limit: 500,
|
|
filter: JSON.stringify({ conversationId: selectedConversationId }),
|
|
}),
|
|
enabled: !!selectedConversationId,
|
|
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]);
|
|
|
|
const getUserInfo = useCallback(
|
|
(userId: string) => {
|
|
const user = userMap.get(userId);
|
|
return {
|
|
name: user?.name || user?.username || `User ${userId.slice(-6)}`,
|
|
email: user?.email || null,
|
|
};
|
|
},
|
|
[userMap]
|
|
);
|
|
|
|
// Extraction du contenu des messages
|
|
const getMessageContent = useCallback((message: LibreChatMessage): string => {
|
|
const msg = message as ExtendedMessage;
|
|
|
|
if (msg.content && Array.isArray(msg.content)) {
|
|
for (const item of msg.content) {
|
|
if (item?.text) return item.text.trim();
|
|
}
|
|
}
|
|
if (message.text?.trim()) return message.text.trim();
|
|
if (typeof msg.content === "string" && msg.content.trim()) return msg.content.trim();
|
|
if (msg.parts?.[0]) {
|
|
const part = msg.parts[0];
|
|
if (typeof part === "string") return part.trim();
|
|
if (part?.text) return part.text.trim();
|
|
}
|
|
return "Contenu non disponible";
|
|
}, []);
|
|
|
|
const clearSearch = () => {
|
|
setSearchInput("");
|
|
searchInputRef.current?.focus();
|
|
};
|
|
|
|
const isSearching = searchInput !== debouncedSearch || conversationsFetching;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader className="pb-4">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<MessageSquare className="h-5 w-5" />
|
|
Conversations
|
|
<Badge variant="secondary" className="ml-2">
|
|
{total}
|
|
</Badge>
|
|
{conversationsFetching && !conversationsLoading && (
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
)}
|
|
</CardTitle>
|
|
|
|
{/* Barre de recherche */}
|
|
<div className="relative w-full sm:w-96">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
ref={searchInputRef}
|
|
placeholder="Rechercher par nom ou email..."
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
className="pl-10 pr-10"
|
|
/>
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
|
{isSearching ? (
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
) : searchInput ? (
|
|
<button
|
|
onClick={clearSearch}
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
type="button"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{debouncedSearch && (
|
|
<p className="text-sm text-muted-foreground mt-2">
|
|
{total} résultat{total > 1 ? "s" : ""} pour "{debouncedSearch}"
|
|
</p>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
{conversationsLoading ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="h-14 bg-muted animate-pulse rounded-lg" />
|
|
))}
|
|
</div>
|
|
) : conversations.length === 0 ? (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
{debouncedSearch ? (
|
|
<>
|
|
<Search className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
|
<p>Aucun résultat pour "{debouncedSearch}"</p>
|
|
<Button variant="link" onClick={clearSearch} className="mt-2">
|
|
Effacer la recherche
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<p>Aucune conversation</p>
|
|
)}
|
|
</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;
|
|
|
|
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
|
|
)
|
|
}
|
|
>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<ChevronDown
|
|
className={`h-4 w-4 text-muted-foreground transition-transform ${
|
|
isSelected ? "rotate-0" : "-rotate-90"
|
|
}`}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium truncate max-w-[180px]">
|
|
{userInfo.name}
|
|
</span>
|
|
{userInfo.email && (
|
|
<span className="text-xs text-muted-foreground truncate max-w-[180px]">
|
|
{userInfo.email}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="truncate block max-w-xs" title={String(conv.title)}>
|
|
{String(conv.title) || "Sans titre"}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className="text-xs font-mono">
|
|
{String(conv.endpoint).slice(0, 10)}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge variant="secondary" className="text-xs">
|
|
{msgCount}
|
|
</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>
|
|
|
|
{/* 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">
|
|
{messagesLoading ? (
|
|
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin" />
|
|
<span>Chargement des messages...</span>
|
|
</div>
|
|
) : messages.length === 0 ? (
|
|
<p className="text-center py-8 text-muted-foreground">
|
|
Aucun message dans cette conversation
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3 max-h-[400px] overflow-y-auto">
|
|
{messages
|
|
.sort(
|
|
(a, b) =>
|
|
new Date(a.createdAt).getTime() -
|
|
new Date(b.createdAt).getTime()
|
|
)
|
|
.map((msg) => {
|
|
const content = getMessageContent(msg);
|
|
const isUser = msg.isCreatedByUser;
|
|
|
|
return (
|
|
<div
|
|
key={msg._id}
|
|
className={`flex gap-3 p-3 rounded-lg border-l-4 ${
|
|
isUser
|
|
? "bg-blue-50 border-l-blue-500"
|
|
: "bg-white border-l-gray-300"
|
|
}`}
|
|
>
|
|
<div className="flex-shrink-0 mt-0.5">
|
|
{isUser ? (
|
|
<User className="h-4 w-4 text-blue-600" />
|
|
) : (
|
|
<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 className="text-sm whitespace-pre-wrap break-words">
|
|
{content}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between mt-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Page {page} / {totalPages}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Préc.
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={page === totalPages}
|
|
>
|
|
Suiv.
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
</div>
|
|
);
|
|
}
|