diff --git a/website/src/components/Messages/MessageTableEntry.tsx b/website/src/components/Messages/MessageTableEntry.tsx index d9686786..b8ff9ef9 100644 --- a/website/src/components/Messages/MessageTableEntry.tsx +++ b/website/src/components/Messages/MessageTableEntry.tsx @@ -1,5 +1,6 @@ import { Avatar, + AvatarProps, Box, HStack, Menu, @@ -15,7 +16,18 @@ import { useToast, } from "@chakra-ui/react"; import { boolean } from "boolean"; -import { ClipboardList, Copy, Flag, Link, MessageSquare, MoreHorizontal, Slash, Trash, User } from "lucide-react"; +import { + ClipboardList, + Copy, + Flag, + Link, + MessageSquare, + MoreHorizontal, + Shield, + Slash, + Trash, + User, +} from "lucide-react"; import { useRouter } from "next/router"; import { useTranslation } from "next-i18next"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -24,6 +36,7 @@ import { MessageEmojiButton } from "src/components/Messages/MessageEmojiButton"; import { ReportPopup } from "src/components/Messages/ReportPopup"; import { useHasAnyRole } from "src/hooks/auth/useHasAnyRole"; import { del, post, put } from "src/lib/api"; +import { ROUTES } from "src/lib/routes"; import { colors } from "src/styles/Theme/colors"; import { Message, MessageEmojis } from "src/types/Conversation"; import { emojiIcons, isKnownEmoji } from "src/types/Emoji"; @@ -34,9 +47,17 @@ interface MessageTableEntryProps { message: Message; enabled?: boolean; highlight?: boolean; + avartarPosition?: "middle" | "top"; + avartarProps?: AvatarProps; } -export function MessageTableEntry({ message, enabled, highlight }: MessageTableEntryProps) { +export function MessageTableEntry({ + message, + enabled, + highlight, + avartarPosition = "middle", + avartarProps, +}: MessageTableEntryProps) { const router = useRouter(); const [emojiState, setEmojis] = useState({ emojis: {}, user_emojis: [] }); useEffect(() => { @@ -68,9 +89,10 @@ export function MessageTableEntry({ message, enabled, highlight }: MessageTableE mr={inlineAvatar ? 2 : 0} name={`${boolean(message.is_assistant) ? "Assistant" : "User"}`} src={`${boolean(message.is_assistant) ? "/images/logos/logo.png" : "/images/temp-avatars/av1.jpg"}`} + {...avartarProps} /> ), - [borderColor, inlineAvatar, message.is_assistant] + [avartarProps, borderColor, inlineAvatar, message.is_assistant] ); const highlightColor = useColorModeValue(colors.light.active, colors.dark.active); @@ -86,13 +108,17 @@ export function MessageTableEntry({ message, enabled, highlight }: MessageTableE }; return ( - + {!inlineAvatar && avatar} handleCopy(id)} icon={}> {t("copy_message_id")} + }> + View in admin area + }> {t("view_user")} diff --git a/website/src/components/Messages/MessageTree.tsx b/website/src/components/Messages/MessageTree.tsx new file mode 100644 index 00000000..639e1330 --- /dev/null +++ b/website/src/components/Messages/MessageTree.tsx @@ -0,0 +1,104 @@ +import { Box } from "@chakra-ui/react"; +import { Fragment } from "react"; +import { MessageWithChildren } from "src/types/Conversation"; + +import { MessageTableEntry } from "./MessageTableEntry"; + +const connectionColor = "gray.300"; +const messagePaddingTop = 16; +const avatarSize = 32; +const avartarMarginTop = 6; +const maxDepth = 100; // this only used for debug UI in mobile +const left = avatarSize / 2 - 1; + +export const MessageTree = ({ tree, messageId }: { tree: MessageWithChildren; messageId?: string }) => { + const renderChildren = (children: MessageWithChildren[], depth = 1) => { + const hasSibling = children.length > 1; + return children.map((child, idx) => { + const hasChildren = child.children.length > 0; + const isLastChild = idx === children.length - 1; + return ( + + + + + {hasSibling && !isLastChild && ( + + )} + + {hasChildren && depth < maxDepth && } + + + {depth < maxDepth && renderChildren(child.children, depth + 1)} + + + + ); + }); + }; + + return ( + <> + + + + + {renderChildren(tree.children)} + + ); +}; + +const Connection = ({ className, isSibling = false }: { isSibling?: boolean; className?: string }) => { + const top = isSibling ? `26px` : `32px`; + return ( + + ); +}; + +const height = avatarSize / 2 + avartarMarginTop + messagePaddingTop; +const width = avatarSize / 2 + 10; +const ConnectionCurve = () => { + return ( + + ); +}; diff --git a/website/src/lib/oasst_api_client.ts b/website/src/lib/oasst_api_client.ts index a3607323..aec9b2e8 100644 --- a/website/src/lib/oasst_api_client.ts +++ b/website/src/lib/oasst_api_client.ts @@ -189,6 +189,13 @@ export class OasstApiClient { return this.get(`/api/v1/messages/${message_id}?username=${user.id}&auth_method=${user.auth_method}`); } + async fetch_message_tree(message_id: string) { + return this.get<{ + id: string; + messages: Message[]; + }>(`/api/v1/messages/${message_id}/tree`); + } + /** * Delete a message by its id */ diff --git a/website/src/pages/admin/messages/[id].tsx b/website/src/pages/admin/messages/[id].tsx new file mode 100644 index 00000000..8c725d07 --- /dev/null +++ b/website/src/pages/admin/messages/[id].tsx @@ -0,0 +1,68 @@ +import { Card, CardBody, CardHeader, CircularProgress, Grid } from "@chakra-ui/react"; +import { GetServerSideProps } from "next"; +import Head from "next/head"; +import { useRouter } from "next/router"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { AdminArea } from "src/components/AdminArea"; +import { JsonCard } from "src/components/JsonCard"; +import { getAdminLayout } from "src/components/Layout"; +import { MessageTree } from "src/components/Messages/MessageTree"; +import { get } from "src/lib/api"; +import { Message, MessageWithChildren } from "src/types/Conversation"; +import useSWRImmutable from "swr/immutable"; + +const MessageDetail = () => { + const router = useRouter(); + const messageId = router.query.id; + const { data, isLoading, error } = useSWRImmutable<{ + tree: MessageWithChildren | null; + message?: Message; + }>(`/api/admin/messages/${messageId}/tree`, get); + + return ( + <> + + Open Assistant + + + {isLoading && } + {error && "Unable to load message tree"} + {data && + (data.tree === null ? ( + "Unable to build tree" + ) : ( + + + + Message Detail + + + {data.message} + + + + + Tree {data.tree.id} + + + + + + + ))} + + + ); +}; + +MessageDetail.getLayout = getAdminLayout; + +export default MessageDetail; + +export const getServerSideProps: GetServerSideProps = async ({ locale = "en" }) => { + return { + props: { + ...(await serverSideTranslations(locale, ["common", "labelling", "message"])), + }, + }; +}; diff --git a/website/src/pages/api/admin/messages/[id]/tree.ts b/website/src/pages/api/admin/messages/[id]/tree.ts new file mode 100644 index 00000000..0688dab8 --- /dev/null +++ b/website/src/pages/api/admin/messages/[id]/tree.ts @@ -0,0 +1,52 @@ +import { withAnyRole } from "src/lib/auth"; +import { createApiClient } from "src/lib/oasst_client_factory"; +import { Message, MessageWithChildren } from "src/types/Conversation"; + +export default withAnyRole(["admin", "moderator"], async (req, res, token) => { + const client = await createApiClient(token); + const messageId = req.query.id as string; + const response = await client.fetch_message_tree(messageId); + + if (!response) { + return res.json({ tree: null }); + } + + const tree = buildTree(response.messages); + + return res.json({ tree, message: response.messages.find((m) => m.id === messageId) }); +}); + +// https://medium.com/@lizhuohang.selina/building-a-hierarchical-tree-from-a-flat-list-an-easy-to-understand-solution-visualisation-19cb24bdfa33 +const buildTree = (messages: Message[]): MessageWithChildren | null => { + const map: Record = {}; + const tree = []; + + // Build a hash table and map items to objects + messages.forEach(function (item) { + const id = item.id; + if (!map[id]) { + map[id] = { ...item, children: [] }; + } + }); + + // Loop over hash table + let mappedElem: MessageWithChildren; + for (const id in map) { + if (map[id]) { + mappedElem = map[id]; + + // If the element is not at the root level, add it to its parent array of children. Note this will continue till we have only root level elements left + if (mappedElem.parent_id) { + const parentId = mappedElem.parent_id; + map[parentId].children.push(mappedElem); + } + + // If the element is at the root level, directly push to the tree + else { + tree.push(mappedElem); + } + } + } + + return tree.shift() || null; +}; diff --git a/website/src/types/Conversation.ts b/website/src/types/Conversation.ts index 57a9efbb..f5841f27 100644 --- a/website/src/types/Conversation.ts +++ b/website/src/types/Conversation.ts @@ -16,7 +16,7 @@ export interface Message extends MessageEmojis { is_assistant: boolean; lang: string; created_date: string; // iso date string - parent_id: string; + parent_id: string | null; frontend_message_id?: string; user_id: string; user_is_author: boolean | null; @@ -40,3 +40,7 @@ export type FetchUserMessagesCursorResponse = { items: Message[]; order: "asc" | "desc"; }; + +export type MessageWithChildren = Message & { + children: MessageWithChildren[]; +};