implement admin message detail page (#1453)

This commit is contained in:
notmd
2023-02-11 10:27:01 +07:00
committed by GitHub
parent 34bd850021
commit 3f7244cf3b
6 changed files with 270 additions and 6 deletions
@@ -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<MessageEmojis>({ 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 (
<HStack w={["full", "full", "full", "fit-content"]} gap={2}>
<HStack
w={["full", "full", "full", "fit-content"]}
gap={0.5}
alignItems={avartarPosition === "top" ? "start" : "center"}
>
{!inlineAvatar && avatar}
<Box
width={["full", "full", "full", "fit-content"]}
maxWidth={["full", "full", "full", "2xl"]}
p="4"
borderRadius="md"
borderRadius="18px"
bg={message.is_assistant ? backgroundColor : backgroundColor2}
outline={highlight && "2px solid black"}
outlineColor={highlightColor}
@@ -249,6 +275,9 @@ const MessageActions = ({
<MenuItem onClick={() => handleCopy(id)} icon={<Copy />}>
{t("copy_message_id")}
</MenuItem>
<MenuItem as="a" href={ROUTES.ADMIN_MESSAGE_DETAIL(message.id)} target="_blank" icon={<Shield />}>
View in admin area
</MenuItem>
<MenuItem as="a" href={`/admin/manage_user/${message.user_id}`} target="_blank" icon={<User />}>
{t("view_user")}
</MenuItem>
@@ -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 (
<Fragment key={child.id}>
<Box position="relative" className="box2">
<ConnectionCurve></ConnectionCurve>
<Box paddingLeft={`32px`} position="relative" className="box3">
{hasSibling && !isLastChild && (
<Box
height={`calc(100% - 26px)`}
position="absolute"
width="2px"
bg="gray.300"
left={`${left}px`}
top="26px"
></Box>
)}
<Box pt={`${messagePaddingTop}px`} position="relative" className="box4">
{hasChildren && depth < maxDepth && <Connection className="connection1"></Connection>}
<MessageTableEntry
avartarProps={{
mt: `${avartarMarginTop}px`,
}}
avartarPosition="top"
highlight={child.id === messageId}
message={child}
></MessageTableEntry>
</Box>
{depth < maxDepth && renderChildren(child.children, depth + 1)}
</Box>
</Box>
</Fragment>
);
});
};
return (
<>
<Box position="relative">
<Box height="full" position="absolute" width="2px" bg={connectionColor} left={`${left}px`}></Box>
<MessageTableEntry
message={tree}
avartarPosition="top"
highlight={tree.id === messageId}
avartarProps={{
size: "sm",
}}
></MessageTableEntry>
</Box>
{renderChildren(tree.children)}
</>
);
};
const Connection = ({ className, isSibling = false }: { isSibling?: boolean; className?: string }) => {
const top = isSibling ? `26px` : `32px`;
return (
<Box
height={`calc(100% - ${top})`}
position="absolute"
width="2px"
bg="gray.300"
left={`${left}px`}
top={top}
className={className}
></Box>
);
};
const height = avatarSize / 2 + avartarMarginTop + messagePaddingTop;
const width = avatarSize / 2 + 10;
const ConnectionCurve = () => {
return (
<Box
position="absolute"
height={`${height}px`}
width={`${width}px`}
left={`${left}px `}
borderBottomWidth="2px"
borderBottomLeftRadius="10px"
borderLeftStyle="solid"
borderLeftWidth="2px"
borderColor={connectionColor}
className="curve"
></Box>
);
};
+7
View File
@@ -189,6 +189,13 @@ export class OasstApiClient {
return this.get<Message>(`/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
*/
+68
View File
@@ -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 (
<>
<Head>
<title>Open Assistant</title>
</Head>
<AdminArea>
{isLoading && <CircularProgress isIndeterminate></CircularProgress>}
{error && "Unable to load message tree"}
{data &&
(data.tree === null ? (
"Unable to build tree"
) : (
<Grid gap="6">
<Card>
<CardHeader fontWeight="bold" fontSize="xl" pb="0">
Message Detail
</CardHeader>
<CardBody>
<JsonCard>{data.message}</JsonCard>
</CardBody>
</Card>
<Card>
<CardHeader fontWeight="bold" fontSize="xl" pb="0">
Tree {data.tree.id}
</CardHeader>
<CardBody>
<MessageTree tree={data.tree} messageId={data.message?.id}></MessageTree>
</CardBody>
</Card>
</Grid>
))}
</AdminArea>
</>
);
};
MessageDetail.getLayout = getAdminLayout;
export default MessageDetail;
export const getServerSideProps: GetServerSideProps = async ({ locale = "en" }) => {
return {
props: {
...(await serverSideTranslations(locale, ["common", "labelling", "message"])),
},
};
};
@@ -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<string, MessageWithChildren> = {};
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;
};
+5 -1
View File
@@ -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[];
};