562 lines
21 KiB
TypeScript
562 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useCollection } from "@/hooks/useCollection";
|
|
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 {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Users,
|
|
MessageSquare,
|
|
Calendar,
|
|
X,
|
|
User,
|
|
Bot,
|
|
} from "lucide-react";
|
|
import { formatDate } from "@/lib/utils";
|
|
import {
|
|
LibreChatConversation,
|
|
LibreChatUser,
|
|
LibreChatMessage,
|
|
} from "@/lib/types";
|
|
|
|
// Types pour les messages étendus
|
|
interface ExtendedMessage extends LibreChatMessage {
|
|
content?: Array<{ type: string; text: string }> | string;
|
|
message?: Record<string, unknown>;
|
|
parts?: Array<string | { text: string }>;
|
|
metadata?: { text?: string };
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export function ConversationsTable() {
|
|
const [page, setPage] = useState(1);
|
|
const [selectedConversationId, setSelectedConversationId] = useState<
|
|
string | null
|
|
>(null);
|
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
|
|
|
const limit = 10;
|
|
|
|
// Charger toutes les conversations pour le groupement côté client
|
|
const {
|
|
data: conversations = [],
|
|
total = 0,
|
|
loading,
|
|
} = useCollection<LibreChatConversation>("conversations", {
|
|
limit: 1000,
|
|
page: 1, // Remplacer skip par page
|
|
});
|
|
|
|
const { data: users = [] } = useCollection<LibreChatUser>("users", {
|
|
limit: 1000,
|
|
});
|
|
|
|
// Charger les messages seulement si une conversation est sélectionnée
|
|
const { data: messages = [] } = useCollection<LibreChatMessage>("messages", {
|
|
limit: 1000,
|
|
filter: selectedConversationId
|
|
? { conversationId: selectedConversationId }
|
|
: {},
|
|
});
|
|
|
|
const userMap = new Map(users.map((user) => [user._id, user]));
|
|
|
|
const getUserDisplayName = (userId: string): string => {
|
|
if (userId === "unknown") return "Utilisateur inconnu";
|
|
const user = userMap.get(userId);
|
|
if (user) {
|
|
return (
|
|
user.name ||
|
|
user.username ||
|
|
user.email ||
|
|
`Utilisateur ${userId.slice(-8)}`
|
|
);
|
|
}
|
|
return `Utilisateur ${userId.slice(-8)}`;
|
|
};
|
|
|
|
const getUserEmail = (userId: string): string | null => {
|
|
if (userId === "unknown") return null;
|
|
const user = userMap.get(userId);
|
|
return user?.email || null;
|
|
};
|
|
|
|
// Fonction améliorée pour extraire le contenu du message
|
|
const getMessageContent = (message: LibreChatMessage): string => {
|
|
// Fonction helper pour nettoyer le texte
|
|
const cleanText = (text: string): string => {
|
|
return text.trim().replace(/\n\s*\n/g, "\n");
|
|
};
|
|
|
|
// 1. Vérifier le tableau content (structure LibreChat)
|
|
const messageObj = message as ExtendedMessage;
|
|
if (messageObj.content && Array.isArray(messageObj.content)) {
|
|
for (const contentItem of messageObj.content) {
|
|
if (
|
|
contentItem &&
|
|
typeof contentItem === "object" &&
|
|
contentItem.text
|
|
) {
|
|
return cleanText(contentItem.text);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Essayer le champ text principal
|
|
if (
|
|
message.text &&
|
|
typeof message.text === "string" &&
|
|
message.text.trim()
|
|
) {
|
|
return cleanText(message.text);
|
|
}
|
|
|
|
// 3. Essayer le champ content (format legacy string)
|
|
if (
|
|
messageObj.content &&
|
|
typeof messageObj.content === "string" &&
|
|
messageObj.content.trim()
|
|
) {
|
|
return cleanText(messageObj.content);
|
|
}
|
|
|
|
// 4. Vérifier s'il y a des propriétés imbriquées
|
|
if (message.message && typeof message.message === "object") {
|
|
const nestedMessage = message.message as Record<string, unknown>;
|
|
if (nestedMessage.content && typeof nestedMessage.content === "string") {
|
|
return cleanText(nestedMessage.content);
|
|
}
|
|
if (nestedMessage.text && typeof nestedMessage.text === "string") {
|
|
return cleanText(nestedMessage.text);
|
|
}
|
|
}
|
|
|
|
// 5. Vérifier les propriétés spécifiques à LibreChat
|
|
// Parfois le contenu est dans une propriété 'parts'
|
|
if (
|
|
messageObj.parts &&
|
|
Array.isArray(messageObj.parts) &&
|
|
messageObj.parts.length > 0
|
|
) {
|
|
const firstPart = messageObj.parts[0];
|
|
if (typeof firstPart === "string") {
|
|
return cleanText(firstPart);
|
|
}
|
|
if (firstPart && typeof firstPart === "object" && firstPart.text) {
|
|
return cleanText(firstPart.text);
|
|
}
|
|
}
|
|
|
|
// 6. Vérifier si c'est un message avec des métadonnées
|
|
if (messageObj.metadata && messageObj.metadata.text) {
|
|
return cleanText(messageObj.metadata.text);
|
|
}
|
|
|
|
// 7. Vérifier les propriétés alternatives
|
|
const alternativeFields = ["body", "messageText", "textContent", "data"];
|
|
for (const field of alternativeFields) {
|
|
const value = messageObj[field];
|
|
if (value && typeof value === "string" && value.trim()) {
|
|
return cleanText(value);
|
|
}
|
|
}
|
|
|
|
// Debug: afficher la structure du message si aucun contenu n'est trouvé
|
|
console.log("Message sans contenu trouvé:", {
|
|
messageId: message.messageId,
|
|
isCreatedByUser: message.isCreatedByUser,
|
|
keys: Object.keys(messageObj),
|
|
content: messageObj.content,
|
|
text: messageObj.text,
|
|
});
|
|
|
|
return "Contenu non disponible";
|
|
};
|
|
|
|
const handleShowMessages = (conversationId: string, userId: string) => {
|
|
if (
|
|
selectedConversationId === conversationId &&
|
|
selectedUserId === userId
|
|
) {
|
|
setSelectedConversationId(null);
|
|
setSelectedUserId(null);
|
|
} else {
|
|
setSelectedConversationId(conversationId);
|
|
setSelectedUserId(userId);
|
|
}
|
|
};
|
|
|
|
const handleCloseMessages = () => {
|
|
setSelectedConversationId(null);
|
|
setSelectedUserId(null);
|
|
};
|
|
|
|
const getStatus = (conversation: LibreChatConversation) => {
|
|
if (conversation.isArchived) return "archived";
|
|
return "active";
|
|
};
|
|
|
|
const getStatusLabel = (status: string) => {
|
|
switch (status) {
|
|
case "archived":
|
|
return "Archivée";
|
|
case "active":
|
|
return "Active";
|
|
default:
|
|
return "Inconnue";
|
|
}
|
|
};
|
|
|
|
const getStatusVariant = (status: string) => {
|
|
switch (status) {
|
|
case "archived":
|
|
return "outline" as const;
|
|
case "active":
|
|
return "default" as const;
|
|
default:
|
|
return "secondary" as const;
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="text-center">Chargement des conversations...</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// Grouper les conversations par utilisateur
|
|
const groupedConversations = conversations.reduce((acc, conversation) => {
|
|
const userId = conversation.user || "unknown";
|
|
if (!acc[userId]) {
|
|
acc[userId] = [];
|
|
}
|
|
acc[userId].push(conversation);
|
|
return acc;
|
|
}, {} as Record<string, LibreChatConversation[]>);
|
|
|
|
// Pagination des groupes d'utilisateurs
|
|
const totalPages = Math.ceil(
|
|
Object.keys(groupedConversations).length / limit
|
|
);
|
|
const skip = (page - 1) * limit;
|
|
const userIds = Object.keys(groupedConversations).slice(skip, skip + limit);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Users className="h-5 w-5" />
|
|
Conversations par utilisateur
|
|
</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
{Object.keys(groupedConversations).length} utilisateurs •{" "}
|
|
{conversations.length} conversations au total
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-6">
|
|
{userIds.map((userId) => {
|
|
const conversations = groupedConversations[userId];
|
|
const totalMessages = conversations.reduce(
|
|
(sum, conv) => sum + (conv.messages?.length || 0),
|
|
0
|
|
);
|
|
const activeConversations = conversations.filter(
|
|
(conv) => !conv.isArchived
|
|
).length;
|
|
const archivedConversations = conversations.filter(
|
|
(conv) => conv.isArchived
|
|
).length;
|
|
const userName = getUserDisplayName(userId);
|
|
const userEmail = getUserEmail(userId);
|
|
|
|
return (
|
|
<div key={userId} className="border rounded-lg p-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="font-mono text-xs">
|
|
{userId === "unknown" ? "unknown" : userId.slice(-8)}
|
|
</Badge>
|
|
<div className="flex flex-col">
|
|
<span className="font-semibold">{userName}</span>
|
|
{userEmail && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{userEmail}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Badge variant="secondary">
|
|
{conversations.length} conversation
|
|
{conversations.length > 1 ? "s" : ""}
|
|
</Badge>
|
|
{activeConversations > 0 && (
|
|
<Badge variant="default" className="text-xs">
|
|
{activeConversations} actives
|
|
</Badge>
|
|
)}
|
|
{archivedConversations > 0 && (
|
|
<Badge variant="outline" className="text-xs">
|
|
{archivedConversations} archivées
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
<div className="flex items-center gap-1">
|
|
<MessageSquare className="h-4 w-4" />
|
|
{totalMessages} message{totalMessages > 1 ? "s" : ""}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Calendar className="h-4 w-4" />
|
|
Dernière:{" "}
|
|
{formatDate(
|
|
new Date(
|
|
Math.max(
|
|
...conversations.map((c) =>
|
|
new Date(c.updatedAt).getTime()
|
|
)
|
|
)
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>ID</TableHead>
|
|
<TableHead>Titre</TableHead>
|
|
<TableHead>Endpoint</TableHead>
|
|
<TableHead>Modèle</TableHead>
|
|
<TableHead>Messages</TableHead>
|
|
<TableHead>Statut</TableHead>
|
|
<TableHead>Créée le</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{conversations
|
|
.sort(
|
|
(a, b) =>
|
|
new Date(b.updatedAt).getTime() -
|
|
new Date(a.updatedAt).getTime()
|
|
)
|
|
.map((conversation) => {
|
|
const status = getStatus(conversation);
|
|
const messageCount =
|
|
conversation.messages?.length || 0;
|
|
|
|
return (
|
|
<TableRow key={conversation._id}>
|
|
<TableCell>
|
|
<span className="font-mono text-xs">
|
|
{String(conversation._id).slice(-8)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="max-w-xs truncate block">
|
|
{String(conversation.title) || "Sans titre"}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className="text-xs">
|
|
{String(conversation.endpoint).slice(0, 20)}
|
|
{String(conversation.endpoint).length > 20
|
|
? "..."
|
|
: ""}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-xs text-muted-foreground font-mono">
|
|
{String(conversation.model)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant="outline"
|
|
className="text-xs cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
|
|
onClick={() =>
|
|
handleShowMessages(
|
|
conversation.conversationId,
|
|
userId
|
|
)
|
|
}
|
|
>
|
|
{messageCount}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={getStatusVariant(status)}
|
|
className="text-xs"
|
|
>
|
|
{getStatusLabel(status)}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatDate(conversation.createdAt)}
|
|
</span>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Section des messages pour cet utilisateur */}
|
|
{selectedConversationId && selectedUserId === userId && (
|
|
<div className="mt-6 border-t pt-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
|
<MessageSquare className="h-5 w-5" />
|
|
Messages de la conversation
|
|
</h3>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleCloseMessages}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
Fermer
|
|
</Button>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
Conversation ID: {selectedConversationId}
|
|
</p>
|
|
<div className="space-y-4 max-h-96 overflow-y-auto border rounded-lg p-4 bg-gray-50">
|
|
{messages.length === 0 ? (
|
|
<p className="text-center text-muted-foreground py-8">
|
|
Aucun message trouvé pour cette conversation
|
|
</p>
|
|
) : (
|
|
messages
|
|
.sort(
|
|
(a, b) =>
|
|
new Date(a.createdAt).getTime() -
|
|
new Date(b.createdAt).getTime()
|
|
)
|
|
.map((message) => {
|
|
const content = getMessageContent(message);
|
|
return (
|
|
<div
|
|
key={message._id}
|
|
className={`flex gap-3 p-4 rounded-lg ${
|
|
message.isCreatedByUser
|
|
? "bg-blue-50 border-l-4 border-l-blue-500"
|
|
: "bg-white border-l-4 border-l-gray-500"
|
|
}`}
|
|
>
|
|
<div className="flex-shrink-0">
|
|
{message.isCreatedByUser ? (
|
|
<User className="h-5 w-5 text-blue-600" />
|
|
) : (
|
|
<Bot className="h-5 w-5 text-gray-600" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Badge
|
|
variant={
|
|
message.isCreatedByUser
|
|
? "default"
|
|
: "secondary"
|
|
}
|
|
className="text-xs"
|
|
>
|
|
{message.isCreatedByUser
|
|
? "Utilisateur"
|
|
: "Assistant"}
|
|
</Badge>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatDate(message.createdAt)}
|
|
</span>
|
|
{message.tokenCount > 0 && (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-xs"
|
|
>
|
|
{message.tokenCount} tokens
|
|
</Badge>
|
|
)}
|
|
<Badge
|
|
variant="outline"
|
|
className="text-xs font-mono"
|
|
>
|
|
{message._id.slice(-8)}
|
|
</Badge>
|
|
</div>
|
|
<div className="text-sm whitespace-pre-wrap">
|
|
{content}
|
|
</div>
|
|
{message.error && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="text-xs"
|
|
>
|
|
Erreur
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between mt-6">
|
|
<p className="text-sm text-muted-foreground">
|
|
Page {page} sur {totalPages} • {total} conversations au total
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(page - 1)}
|
|
disabled={page === 1}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Précédent
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(page + 1)}
|
|
disabled={page === totalPages}
|
|
>
|
|
Suivant
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|