Merge branch 'main' into 911_sigin_captcha

This commit is contained in:
notmd
2023-01-29 23:34:04 +07:00
155 changed files with 6347 additions and 1905 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ export const EmptyState = (props: EmptyStateProps) => {
<Box data-cy={props["data-cy"]} bg={backgroundColor} p="10" borderRadius="xl" shadow="base">
<Box display="flex" flexDirection="column" alignItems="center" gap="8" fontSize="lg">
<props.icon size="30" color="DarkOrange" />
<Text>{props.text}</Text>
<Text data-cy="cy-no-tasks">{props.text}</Text>
<NextLink href="/dashboard">
<Text color="blue.500">Go back to the dashboard</Text>
</NextLink>
-116
View File
@@ -1,116 +0,0 @@
import {
Box,
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Popover,
PopoverAnchor,
PopoverTrigger,
Tooltip,
useColorModeValue,
useDisclosure,
} from "@chakra-ui/react";
import { AlertCircle } from "lucide-react";
import { useState } from "react";
import { get, post } from "src/lib/api";
import { colors } from "src/styles/Theme/colors";
import { Message } from "src/types/Conversation";
import useSWRImmutable from "swr/immutable";
import useSWRMutation from "swr/mutation";
import { LabelInputGroup } from "./Survey/LabelInputGroup";
interface Label {
name: string;
display_text: string;
help_text: string;
}
interface FlaggableElementProps {
children: React.ReactNode;
message: Message;
}
interface ValidLabelsResponse {
valid_labels: Label[];
}
export const FlaggableElement = (props: FlaggableElementProps) => {
const { data: response } = useSWRImmutable<ValidLabelsResponse>("/api/valid_labels", get);
const { isOpen, onOpen, onClose } = useDisclosure();
const { valid_labels } = response || { valid_labels: [] };
const [values, setValues] = useState<number[]>([]);
const submittable =
values.some((value) => {
return value !== null;
}) &&
values.length === valid_labels.length &&
valid_labels.length > 0;
const { trigger } = useSWRMutation("/api/set_label", post, {
onSuccess: onClose,
onError: onClose,
});
const submitResponse = () => {
const label_map: Map<string, number> = new Map();
console.assert(valid_labels.length === values.length);
values.forEach((value, idx) => {
if (value !== null) {
label_map.set(valid_labels[idx].name, value);
}
});
trigger({
message_id: props.message.id,
label_map: Object.fromEntries(label_map),
text: props.message.text,
});
};
return (
<Popover isOpen={isOpen} onOpen={onOpen} onClose={onClose} closeOnBlur={false} isLazy lazyBehavior="keepMounted">
<Box display="flex" alignItems="center" flexDirection={["column", "row"]} gap="2">
<PopoverAnchor>{props.children}</PopoverAnchor>
<Tooltip label="Report" bg="red.500" aria-label="A tooltip">
<Box>
<PopoverTrigger>
<Box as="button" display="flex" alignItems="center" justifyContent="center" borderRadius="full" p="1">
<AlertCircle size="20" className="text-red-400" aria-hidden="true" />
</Box>
</PopoverTrigger>
</Box>
</Tooltip>
</Box>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Select one or more labels that apply.</ModalHeader>
<ModalCloseButton />
<ModalBody>
<LabelInputGroup simple labelIDs={valid_labels.map(({ name }) => name)} onChange={setValues} />
</ModalBody>
<ModalFooter>
<Button
isDisabled={!submittable}
onClick={submitResponse}
className={`bg-indigo-600 text-${useColorModeValue(
colors.light.text,
colors.dark.text
)} hover:bg-indigo-700`}
>
Report
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Popover>
);
};
@@ -1,4 +1,4 @@
import { Box, Center, Progress, Text, useColorModeValue } from "@chakra-ui/react";
import { Box, Center, Progress, Text } from "@chakra-ui/react";
export const LoadingScreen = ({ text = "Loading..." } = {}) => {
return (
+2 -20
View File
@@ -1,26 +1,8 @@
import { Box, forwardRef, Grid, useColorMode } from "@chakra-ui/react";
import { Box, forwardRef, useColorMode } from "@chakra-ui/react";
import { useMemo } from "react";
import { Message } from "src/types/Conversation";
import { FlaggableElement } from "./FlaggableElement";
interface MessagesProps {
messages: Message[];
}
export const Messages = ({ messages }: MessagesProps) => {
const items = messages.map((messageProps: Message, i: number) => {
return (
<FlaggableElement message={messageProps} key={i + messageProps.id}>
<MessageView {...messageProps} />
</FlaggableElement>
);
});
// Maybe also show a legend of the colors?
return <Grid gap={2}>{items}</Grid>;
};
export const MessageView = forwardRef<Message, "div">((message: Message, ref) => {
export const MessageView = forwardRef<Partial<Message>, "div">((message: Partial<Message>, ref) => {
const { colorMode } = useColorMode();
const bgColor = useMemo(() => {
@@ -0,0 +1,32 @@
import { Button, Flex } from "@chakra-ui/react";
import { useTranslation } from "next-i18next";
import { getTypeSafei18nKey } from "src/lib/i18n";
interface LabelFlagGroupProps {
values: number[];
labelNames: string[];
isEditable?: boolean;
onChange: (values: number[]) => void;
}
export const LabelFlagGroup = ({ values, labelNames, isEditable = true, onChange }: LabelFlagGroupProps) => {
const { t } = useTranslation("labelling");
return (
<Flex wrap="wrap" gap="4">
{labelNames.map((name, idx) => (
<Button
key={name}
onClick={() => {
const newValues = values.slice();
newValues[idx] = newValues[idx] ? 0 : 1;
onChange(newValues);
}}
isDisabled={!isEditable}
colorScheme={values[idx] === 1 ? "blue" : undefined}
>
{t(getTypeSafei18nKey(name))}
</Button>
))}
</Flex>
);
};
@@ -0,0 +1,84 @@
import { Text, VStack } from "@chakra-ui/react";
import { Label } from "src/types/Tasks";
import { LabelLikertGroup } from "../Survey/LabelLikertGroup";
import { LabelFlagGroup } from "./LabelFlagGroup";
import { LabelYesNoGroup } from "./LabelYesNoGroup";
export interface LabelInputInstructions {
yesNoInstruction: string;
flagInstruction: string;
likertInstruction: string;
}
interface LabelInputGroupProps {
values: number[];
labels: Label[];
requiredLabels?: string[];
isEditable?: boolean;
instructions: LabelInputInstructions;
onChange: (values: number[]) => void;
}
export const LabelInputGroup = ({
labels,
values,
requiredLabels,
isEditable,
instructions,
onChange,
}: LabelInputGroupProps) => {
const yesNoIndexes = labels.map((label, idx) => (label.widget === "yes_no" ? idx : null)).filter((v) => v !== null);
const flagIndexes = labels.map((label, idx) => (label.widget === "flag" ? idx : null)).filter((v) => v !== null);
const likertIndexes = labels.map((label, idx) => (label.widget === "likert" ? idx : null)).filter((v) => v !== null);
return (
<VStack alignItems="stretch" spacing={6}>
{yesNoIndexes.length > 0 && (
<VStack alignItems="stretch" spacing={2}>
<Text>{instructions.yesNoInstruction}</Text>
<LabelYesNoGroup
values={yesNoIndexes.map((idx) => values[idx])}
labelNames={yesNoIndexes.map((idx) => labels[idx].name)}
isEditable={isEditable}
requiredLabels={requiredLabels}
onChange={(yesNoValues) => {
const newValues = values.slice();
yesNoIndexes.forEach((idx, yesNoIndex) => (newValues[idx] = yesNoValues[yesNoIndex]));
onChange(newValues);
}}
/>
</VStack>
)}
{flagIndexes.length > 0 && (
<VStack alignItems="stretch" spacing={2}>
<Text>{instructions.flagInstruction}</Text>
<LabelFlagGroup
values={flagIndexes.map((idx) => values[idx])}
labelNames={flagIndexes.map((idx) => labels[idx].name)}
isEditable={isEditable}
onChange={(flagValues) => {
const newValues = values.slice();
flagIndexes.forEach((idx, flagIndex) => (newValues[idx] = flagValues[flagIndex]));
onChange(newValues);
}}
/>
</VStack>
)}
{likertIndexes.length > 0 && (
<VStack alignItems="stretch" spacing={2}>
<Text>{instructions.likertInstruction}</Text>
<LabelLikertGroup
labelIDs={likertIndexes.map((idx) => labels[idx].name)}
isEditable={isEditable}
onChange={(likertValues) => {
const newValues = values.slice();
likertIndexes.forEach((idx, likertIndex) => (newValues[idx] = likertValues[likertIndex]));
onChange(newValues);
}}
/>
</VStack>
)}
</VStack>
);
};
@@ -0,0 +1,84 @@
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/react";
import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react";
import { LabelInputGroup } from "src/components/Messages/LabelInputGroup";
import { get, post } from "src/lib/api";
import { Label } from "src/types/Tasks";
import useSWRImmutable from "swr/immutable";
import useSWRMutation from "swr/mutation";
interface LabelMessagePopupProps {
messageId: string;
show: boolean;
onClose: () => void;
}
interface ValidLabelsResponse {
valid_labels: Label[];
}
export const LabelMessagePopup = ({ messageId, show, onClose }: LabelMessagePopupProps) => {
const { t } = useTranslation();
const { data: response } = useSWRImmutable<ValidLabelsResponse>(`/api/valid_labels?message_id=${messageId}`, get);
const valid_labels = response?.valid_labels ?? [];
const [values, setValues] = useState<number[]>(new Array(valid_labels.length).fill(null));
useEffect(() => {
setValues(new Array(valid_labels.length).fill(null));
}, [messageId, valid_labels.length]);
const { trigger: setLabels } = useSWRMutation("/api/set_label", post);
const submit = () => {
const label_map: Map<string, number> = new Map();
console.assert(valid_labels.length === values.length);
values.forEach((value, idx) => {
if (value !== null) {
label_map.set(valid_labels[idx].name, value);
}
});
setLabels({
message_id: messageId,
label_map: Object.fromEntries(label_map),
});
setValues(null);
onClose();
};
return (
<Modal isOpen={show} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>{t("message:label_title")}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<LabelInputGroup
labels={valid_labels}
values={values}
instructions={{
yesNoInstruction: t("labelling:label_message_yes_no_instruction"),
flagInstruction: t("labelling:label_message_flag_instruction"),
likertInstruction: t("labelling:label_message_likert_instruction"),
}}
onChange={setValues}
/>
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" mr={3} onClick={submit}>
{t("message:submit_labels")}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,89 @@
import { Button, HStack, Text, Tooltip } from "@chakra-ui/react";
import { useTranslation } from "next-i18next";
import { getTypeSafei18nKey } from "src/lib/i18n";
interface LabelYesNoGroupProps {
values: number[];
labelNames: string[];
requiredLabels?: string[];
isEditable?: boolean;
onChange: (values: number[]) => void;
}
export const LabelYesNoGroup = ({
values,
labelNames,
requiredLabels = [],
isEditable = true,
onChange,
}: LabelYesNoGroupProps) => {
const { t } = useTranslation("labelling");
return (
<>
{labelNames.map((name, idx) => {
return (
<YesNoQuestion
key={name}
question={t(getTypeSafei18nKey(`${name}.question`))}
value={values[idx] === null ? null : values[idx] > 0.1 ? true : false}
onChange={(value) => {
const newValues = values.slice();
newValues[idx] = value;
onChange(newValues);
}}
isEditable={isEditable}
isRequired={requiredLabels.includes(name)}
/>
);
})}
</>
);
};
const YesNoQuestion = ({
isEditable,
question,
value,
isRequired,
onChange,
}: {
isEditable: boolean;
question: string;
value: boolean;
isRequired?: boolean;
onChange: (boolean) => void;
}) => {
const { t } = useTranslation();
return (
<div data-cy="label-question" style={{ maxWidth: "30em" }}>
<Text display="inline">
{question}
{isRequired ? <RequiredMark /> : undefined}
</Text>
<HStack style={{ float: "right" }}>
<Button
data-cy="yes"
isDisabled={!isEditable}
colorScheme={value === true ? "blue" : undefined}
onClick={() => onChange(isRequired ? true : value === null ? true : null)}
>
{t("yes")}
</Button>
<Button
data-cy="no"
isDisabled={!isEditable}
colorScheme={value === false ? "blue" : undefined}
onClick={() => onChange(isRequired ? false : value === null ? false : null)}
>
{t("no")}
</Button>
</HStack>
</div>
);
};
const RequiredMark = () => (
<Tooltip label="Required">
<span style={{ color: "red" }}>*</span>
</Tooltip>
);
@@ -0,0 +1,34 @@
import React from "react";
import { MessageEmojiButton } from "./MessageEmojiButton";
// eslint-disable-next-line import/no-anonymous-default-export
export default {
title: "Messages/MessageEmojiButton",
component: MessageEmojiButton,
};
const Template = ({ emoji, count, checked }: { emoji: string; count: number; checked?: boolean }) => {
return <MessageEmojiButton emoji={{ name: emoji, count }} checked={checked} onClick={undefined} />;
};
export const Default = Template.bind({});
Default.args = {
emoji: "+1",
count: 7,
checked: false,
};
export const BigNumber = Template.bind({});
BigNumber.args = {
emoji: "+1",
count: 999,
checked: false,
};
export const Checked = Template.bind({});
Checked.args = {
emoji: "+1",
count: 2,
checked: true,
};
@@ -0,0 +1,48 @@
import { Button } from "@chakra-ui/react";
import { BoxSelect, Flag, LucideProps, ThumbsDown, ThumbsUp } from "lucide-react";
import { ReactElement } from "react";
import { MessageEmoji } from "src/types/Conversation";
type EmojiIconPurpose = "MINI_BUTTON" | "NORMAL";
const defaultIconProps: (purpose: EmojiIconPurpose) => LucideProps = (purpose: EmojiIconPurpose) => {
if (purpose === "MINI_BUTTON") return { height: "1em" };
return {};
};
export const getEmojiIcon = (name: string, purpose: EmojiIconPurpose): ReactElement => {
switch (name) {
case "+1":
return <ThumbsUp {...defaultIconProps(purpose)} />;
case "-1":
return <ThumbsDown {...defaultIconProps(purpose)} />;
case "flag":
case "red_flag":
return <Flag {...defaultIconProps(purpose)} />;
default:
return <BoxSelect {...defaultIconProps(purpose)} />;
}
};
interface MessageEmojiButtonProps {
emoji: MessageEmoji;
checked?: boolean;
onClick: () => void;
}
export const MessageEmojiButton = ({ emoji, checked, onClick }: MessageEmojiButtonProps) => {
return (
<Button
onClick={onClick}
variant={checked ? "solid" : "ghost"}
colorScheme={checked ? "blue" : undefined}
size="sm"
height="1.6em"
minWidth={0}
padding="0"
>
{getEmojiIcon(emoji.name, "MINI_BUTTON")}
<span style={{ marginInlineEnd: "0.25em" }}>{emoji.count}</span>
</Button>
);
};
@@ -0,0 +1,110 @@
import React from "react";
import { Message } from "src/types/Conversation";
import { MessageTable } from "./MessageTable";
// eslint-disable-next-line import/no-anonymous-default-export
export default {
title: "Messages/MessageTable",
component: MessageTable,
};
const Template = ({
messages,
enableLink,
highlightLastMessage,
}: {
messages: Message[];
enableLink: boolean;
highlightLastMessage: boolean;
}) => {
return <MessageTable messages={messages} enableLink={enableLink} highlightLastMessage={highlightLastMessage} />;
};
export const Default = Template.bind({});
Default.args = {
messages: [
{
text: "I'm unsure how to interpret this. Is it a riddle?",
is_assistant: true,
id: "",
frontend_message_id: "",
emojis: {},
user_emojis: [],
},
{
text: "No, I just wanted to see how you reply when I type random characters. Can you tell me who invented Wikipedia?",
is_assistant: false,
id: "",
frontend_message_id: "",
emojis: { "-1": 11, red_flag: 2 },
user_emojis: [],
},
{
text: "Sorry, my cat sat on my keyboard. Can you print a cat in ASCII art?",
is_assistant: false,
id: "",
frontend_message_id: "",
emojis: {},
user_emojis: [],
},
],
enableLink: true,
highlightLastMessage: false,
};
export const Conversation = Template.bind({});
Conversation.args = {
messages: [
{
text: "Hello! How can I help you?",
is_assistant: true,
id: "",
frontend_message_id: "",
emojis: {},
user_emojis: [],
},
{
text: "Who were the 8 presidents before George Washington?",
is_assistant: false,
id: "",
frontend_message_id: "",
emojis: {},
user_emojis: [],
},
],
enableLink: false,
highlightLastMessage: true,
};
export const LongText = Template.bind({});
LongText.args = {
messages: [
{
text: "Okay, here\u2019s my answer. The thing is, in a job interview you are applying to the position, right? Which means that you need to try to make yourself sound as good a fit as possible. So that means it\u2019s a mistake to wear something that people would think of as a silly or stupid outfit. It\u2019s good to wear something in line with what people expect the job to require, and ideally you want to wear something that you can walk around comfortably in. At the same time, if you wear something that looks kind of dorky, you might not stand out as much as someone who doesn\u2019t, because the other candidates probably all have something slightly weird or wacky about them too. So, I\u2019d say, do you like your outfit? Wear something that you\u2019re comfortable in, that you think you look good in, and is in line with what the job would require. Also, for interview prep, I would focus on making yourself look as professional as possible, from your hair to your outfit. What do you think of this?",
is_assistant: true,
id: "",
frontend_message_id: "",
emojis: {},
user_emojis: [],
},
{
text: "Yes, I think they can be helpful when the child misbehaves, but they should be used with a little bit of compassion and understanding that it\u2019s not the natural state of things to have an adult yelling at them. Time outs are also often used without letting the child know how they\u2019re getting out of the time out, which can make it feel arbitrary or like a punishment, rather than a consequence for something they did. It\u2019s really easy for adults to do this kind of thing unconsciously. It\u2019s easy to get caught up in the notion that \u201cThey\u2019re in time out, and that\u2019s the end of it!\u201d but kids can be pretty imaginative, and they can use their own creativity to make their way out of time outs. A compassionate time out ends when the child shows a sign of understanding what they\u2019ve done wrong, and are ready to begin again. That way the child knows they\u2019re learning, and that the parent is seeing them as an intelligent person, even if they sometimes mess up. You can still use the other techniques you were using to be tough when necessary, but using a compassionate approach will let you use them without actually using them!",
is_assistant: false,
id: "",
frontend_message_id: "",
emojis: {},
user_emojis: [],
},
{
text: "No. The USA was founded by a Puritan group of Protestants, but it didn\u2019t adopt the religion of the Puritans until much later, and it was always a secular state. The Puritans observed the Sabbath on Sunday, and the Puritans only had a small influence in the early history of the USA. It\u2019s difficult to trace the origins of closing stores on Sunday, but one early and short-lived attempt at forcing the Sabbath on people in the 1800s was motivated by the Protestant ideal that people should spend Sunday focusing on spiritual activities. By the mid-1800s, when the Sunday closing law was made, there was not a lot of pressure from that standpoint, but the church had begun to advocate for Sunday closing laws as a way of counteracting the negative effects of industrialization on the day of rest. Even after that shift, closing stores on Sunday was not always possible, since the religious Sunday was not always chosen for observance. And as industrialization accelerated and mechanization made it possible to operate stores on Sunday, the law was not enforced as much as people liked. The day of rest was also being violated by stores that stayed open all day on Sunday, so closing stores on Sundays became an effort to protect the Sabbath for all citizens.",
is_assistant: false,
id: "",
frontend_message_id: "",
emojis: {},
user_emojis: [],
},
],
enableLink: true,
highlightLastMessage: false,
};
@@ -11,11 +11,11 @@ interface MessageTableProps {
export function MessageTable({ messages, enableLink, highlightLastMessage }: MessageTableProps) {
return (
<Stack spacing="4">
{messages.map((item, idx) => (
{messages.map((message, idx) => (
<MessageTableEntry
enabled={enableLink}
item={item}
key={item.id + item.frontend_message_id}
message={message}
key={message.id + message.frontend_message_id}
highlight={highlightLastMessage && idx === messages.length - 1}
/>
))}
@@ -0,0 +1,62 @@
import React from "react";
import { Message } from "src/types/Conversation";
import { MessageTableEntry } from "./MessageTableEntry";
// eslint-disable-next-line import/no-anonymous-default-export
export default {
title: "Messages/MessageTableEntry",
component: MessageTableEntry,
};
const Template = ({ enabled, highlight, ...message }) => {
return <MessageTableEntry message={message as Message} enabled={enabled} highlight={highlight} />;
};
export const Default = Template.bind({});
Default.args = {
text: "Who were the 8 presidents before George Washington?",
is_assistant: false,
id: "",
frontend_message_id: "",
enabled: true,
highlight: false,
emojis: {},
user_emojis: [],
};
export const Asistant = Template.bind({});
Asistant.args = {
text: "Who were the 8 presidents before George Washington?",
is_assistant: true,
id: "",
frontend_message_id: "",
enabled: true,
highlight: false,
emojis: {},
user_emojis: [],
};
export const LongText = Template.bind({});
LongText.args = {
text: "Assistant: No. The USA was founded by a Puritan group of Protestants, but it didn\u2019t adopt the religion of the Puritans until much later, and it was always a secular state. The Puritans observed the Sabbath on Sunday, and the Puritans only had a small influence in the early history of the USA. It\u2019s difficult to trace the origins of closing stores on Sunday, but one early and short-lived attempt at forcing the Sabbath on people in the 1800s was motivated by the Protestant ideal that people should spend Sunday focusing on spiritual activities. By the mid-1800s, when the Sunday closing law was made, there was not a lot of pressure from that standpoint, but the church had begun to advocate for Sunday closing laws as a way of counteracting the negative effects of industrialization on the day of rest. Even after that shift, closing stores on Sunday was not always possible, since the religious Sunday was not always chosen for observance. And as industrialization accelerated and mechanization made it possible to operate stores on Sunday, the law was not enforced as much as people liked. The day of rest was also being violated by stores that stayed open all day on Sunday, so closing stores on Sundays became an effort to protect the Sabbath for all citizens.",
is_assistant: true,
id: "",
frontend_message_id: "",
enabled: true,
highlight: false,
emojis: {},
user_emojis: [],
};
export const WithEmoji = Template.bind({});
WithEmoji.args = {
text: "As you\u2019ve mentioned, Star Wars has many sequels, prequels, and crossovers. The official list of movies in Star Wars is:",
is_assistant: true,
id: "",
frontend_message_id: "",
enabled: true,
highlight: false,
emojis: { "-1": 5, "+1": 1 },
user_emojis: ["-1"],
};
@@ -1,23 +1,50 @@
import { Avatar, Box, HStack, useBreakpointValue, useColorModeValue } from "@chakra-ui/react";
import {
Avatar,
Box,
HStack,
Menu,
MenuButton,
MenuDivider,
MenuGroup,
MenuItem,
MenuList,
SimpleGrid,
useBreakpointValue,
useColorModeValue,
useDisclosure,
} from "@chakra-ui/react";
import { boolean } from "boolean";
import { ClipboardList, Flag, MessageSquare, MoreHorizontal } from "lucide-react";
import { useRouter } from "next/router";
import { useCallback, useMemo } from "react";
import { FlaggableElement } from "src/components/FlaggableElement";
import { Message } from "src/types/Conversation";
import { useTranslation } from "next-i18next";
import { useCallback, useEffect, useMemo, useState } from "react";
import { LabelMessagePopup } from "src/components/Messages/LabelPopup";
import { getEmojiIcon, MessageEmojiButton } from "src/components/Messages/MessageEmojiButton";
import { ReportPopup } from "src/components/Messages/ReportPopup";
import { post } from "src/lib/api";
import { Message, MessageEmojis } from "src/types/Conversation";
import { colors } from "styles/Theme/colors";
import useSWRMutation from "swr/mutation";
interface MessageTableEntryProps {
item: Message;
message: Message;
enabled?: boolean;
highlight?: boolean;
}
export function MessageTableEntry(props: MessageTableEntryProps) {
export function MessageTableEntry({ message, enabled, highlight }: MessageTableEntryProps) {
const router = useRouter();
const [emojiState, setEmojis] = useState<MessageEmojis>({ emojis: {}, user_emojis: [] });
useEffect(() => {
setEmojis({
emojis: message?.emojis || {},
user_emojis: message?.user_emojis || [],
});
}, [message.emojis, message.user_emojis]);
const { item } = props;
const goToMessage = useCallback(() => router.push(`/messages/${item.id}`), [router, item.id]);
const goToMessage = useCallback(() => router.push(`/messages/${message.id}`), [router, message.id]);
const { isOpen: reportPopupOpen, onOpen: showReportPopup, onClose: closeReportPopup } = useDisclosure();
const { isOpen: labelPopupOpen, onOpen: showLabelPopup, onClose: closeLabelPopup } = useDisclosure();
const backgroundColor = useColorModeValue("gray.100", "gray.700");
const backgroundColor2 = useColorModeValue("#DFE8F1", "#42536B");
@@ -32,34 +59,124 @@ export function MessageTableEntry(props: MessageTableEntryProps) {
borderColor={borderColor}
size={inlineAvatar ? "xs" : "sm"}
mr={inlineAvatar ? 2 : 0}
name={`${boolean(item.is_assistant) ? "Assistant" : "User"}`}
src={`${boolean(item.is_assistant) ? "/images/logos/logo.png" : "/images/temp-avatars/av1.jpg"}`}
name={`${boolean(message.is_assistant) ? "Assistant" : "User"}`}
src={`${boolean(message.is_assistant) ? "/images/logos/logo.png" : "/images/temp-avatars/av1.jpg"}`}
/>
),
[borderColor, inlineAvatar, item.is_assistant]
[borderColor, inlineAvatar, message.is_assistant]
);
const highlightColor = useColorModeValue(colors.light.highlight, colors.dark.highlight);
const { trigger: sendEmojiChange } = useSWRMutation(`/api/messages/${message.id}/emoji`, post, {
onSuccess: setEmojis,
});
const react = (emoji: string, state: boolean) => {
sendEmojiChange({ op: state ? "add" : "remove", emoji });
};
return (
<FlaggableElement message={item}>
<HStack w={["full", "full", "full", "fit-content"]} gap={2}>
{!inlineAvatar && avatar}
<Box
width={["full", "full", "full", "fit-content"]}
maxWidth={["full", "full", "full", "2xl"]}
p="4"
borderRadius="md"
bg={item.is_assistant ? backgroundColor : backgroundColor2}
outline={props.highlight && "2px solid black"}
outlineColor={highlightColor}
onClick={props.enabled && goToMessage}
_hover={props.enabled && { cursor: "pointer", opacity: 0.9 }}
whiteSpace="pre-wrap"
<HStack w={["full", "full", "full", "fit-content"]} gap={2}>
{!inlineAvatar && avatar}
<Box
width={["full", "full", "full", "fit-content"]}
maxWidth={["full", "full", "full", "2xl"]}
p="4"
borderRadius="md"
bg={message.is_assistant ? backgroundColor : backgroundColor2}
outline={highlight && "2px solid black"}
outlineColor={highlightColor}
onClick={enabled && goToMessage}
whiteSpace="pre-wrap"
cursor={enabled && "pointer"}
style={{ position: "relative" }}
>
{inlineAvatar && avatar}
{message.text}
<HStack
style={{ float: "right", position: "relative", right: "-0.3em", bottom: "-0em", marginLeft: "1em" }}
onClick={(e) => e.stopPropagation()}
>
{inlineAvatar && avatar}
{item.text}
</Box>
</HStack>
</FlaggableElement>
{Object.entries(emojiState.emojis).map(([emoji, count]) => (
<MessageEmojiButton
key={emoji}
emoji={{ name: emoji, count }}
checked={emojiState.user_emojis.includes(emoji)}
onClick={() => react(emoji, !emojiState.user_emojis.includes(emoji))}
/>
))}
<MessageActions
react={react}
userEmoji={emojiState.user_emojis}
onLabel={showLabelPopup}
onReport={showReportPopup}
messageId={message.id}
/>
<LabelMessagePopup messageId={message.id} show={labelPopupOpen} onClose={closeLabelPopup} />
<ReportPopup messageId={message.id} show={reportPopupOpen} onClose={closeReportPopup} />
</HStack>
</Box>
</HStack>
);
}
const EmojiMenuItem = ({
emoji,
checked,
react,
}: {
emoji: string;
checked?: boolean;
react: (emoji: string, state: boolean) => void;
}) => {
const activeColor = useColorModeValue(colors.light.active, colors.dark.active);
return (
<MenuItem onClick={() => react(emoji, !checked)} justifyContent="center" color={checked ? activeColor : undefined}>
{getEmojiIcon(emoji, "NORMAL")}
</MenuItem>
);
};
const MessageActions = ({
react,
userEmoji,
onLabel,
onReport,
messageId,
}: {
react: (emoji: string, state: boolean) => void;
userEmoji: string[];
onLabel: () => void;
onReport: () => void;
messageId: string;
}) => {
const { t } = useTranslation("message");
return (
<Menu>
<MenuButton>
<MoreHorizontal />
</MenuButton>
<MenuList>
<MenuGroup title={t("reactions")}>
<SimpleGrid columns={4}>
{["+1", "-1"].map((emoji) => (
<EmojiMenuItem key={emoji} emoji={emoji} checked={userEmoji?.includes(emoji)} react={react} />
))}
</SimpleGrid>
</MenuGroup>
<MenuDivider />
<MenuItem onClick={onLabel} icon={<ClipboardList />}>
{t("label_action")}
</MenuItem>
<MenuItem onClick={onReport} icon={<Flag />}>
{t("report_action")}
</MenuItem>
<MenuDivider />
<MenuItem as="a" href={`/messages/${messageId}`} target="_blank" icon={<MessageSquare />}>
{t("open_new_tab_action")}
</MenuItem>
</MenuList>
</Menu>
);
};
@@ -0,0 +1,99 @@
import { rest } from "msw";
import { MessageWithChildren } from "./MessageWithChildren";
// eslint-disable-next-line import/no-anonymous-default-export
export default {
title: "Messages/MessageWithChildren",
component: MessageWithChildren,
parameters: {
layout: "fullscreen",
msw: {
handlers: {
messagesDefault: [
rest.get("/api/messages/id-1", (req, res, ctx) => {
return res(
ctx.json({
text: "Some message Text",
is_assistant: false,
id: "id-1",
})
);
}),
rest.get("/api/messages/id-1/children", (req, res, ctx) => {
return res(ctx.json([]));
}),
],
},
},
},
};
const Template = (args) => <MessageWithChildren {...args} />;
export const NoChildren = Template.bind({});
NoChildren.args = {
id: "id-1",
maxDepth: 2,
};
export const WithChildren = Template.bind({});
WithChildren.args = {
id: "id-1",
maxDepth: 1,
};
WithChildren.parameters = {
msw: {
handlers: {
additionalMessages: [
rest.get("/api/messages/id-2", (req, res, ctx) => {
return res(
ctx.json({
text: "Some child message Text",
is_assistant: false,
id: "id-2",
})
);
}),
rest.get("/api/messages/id-3", (req, res, ctx) => {
return res(
ctx.json({
text: "Some child message Text",
is_assistant: false,
id: "id-3",
})
);
}),
rest.get("/api/messages/id-1/children", (req, res, ctx) => {
return res(
ctx.json([
{
text: "Some child message Text",
is_assistant: false,
id: "id-2",
},
{
text: "another child message Text",
is_assistant: false,
id: "id-3",
},
])
);
}),
rest.get("/api/messages/id-2/children", (req, res, ctx) => {
return res(
ctx.json([
{
text: "another message Text",
is_assistant: false,
id: "id-4",
},
])
);
}),
rest.get("/api/messages/id-3/children", (req, res, ctx) => {
return res(ctx.json([]));
}),
],
},
},
};
@@ -52,7 +52,7 @@ export function MessageWithChildren(props: MessageWithChildrenProps) {
{isFirst ? "Message" : depth === 1 ? "Children" : "Ancestor"}
</Text>
<Box width="fit-content" bg={backgroundColor} padding="4" borderRadius="xl" boxShadow="base">
<MessageTableEntry enabled item={message} />
<MessageTableEntry enabled message={message} />
</Box>
</Box>
</>
@@ -86,9 +86,9 @@ export function MessageWithChildren(props: MessageWithChildrenProps) {
gap="4"
shadow="base"
>
{children.map((item, idx) => (
{children.map((message, idx) => (
<Box flex="1" key={`recursiveMessageWChildren_${idx}`}>
<MessageTableEntry enabled item={item} />
<MessageTableEntry enabled message={message} />
</Box>
))}
</Box>
@@ -0,0 +1,56 @@
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Textarea,
} from "@chakra-ui/react";
import { useTranslation } from "next-i18next";
import { useState } from "react";
import { post } from "src/lib/api";
import useSWRMutation from "swr/mutation";
interface ReportPopupProps {
messageId: string;
show: boolean;
onClose: () => void;
}
export const ReportPopup = ({ messageId, show, onClose }: ReportPopupProps) => {
const { t } = useTranslation("message");
const [text, setText] = useState("");
const { trigger } = useSWRMutation("/api/report", post);
const submit = () => {
trigger({
message_id: messageId,
text,
});
setText("");
onClose();
};
return (
<Modal isOpen={show} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>{t("report_title")}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Textarea onChange={(e) => setText(e.target.value)} resize="none" placeholder={t("report_placeholder")} />
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" mr={3} onClick={submit}>
{t("send_report")}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
@@ -1,198 +0,0 @@
import { Box, Grid, GridItem, Text, useColorModeValue } from "@chakra-ui/react";
import React from "react";
import { useState } from "react";
import { LikertButtons } from "src/components/Buttons/LikertButtons";
import { Explain } from "src/components/Explain";
interface LabelInputGroupProps {
labelIDs: Array<string>;
simple?: boolean;
onChange: (values: number[]) => unknown;
isEditable?: boolean;
}
interface LabelInfo {
zeroText: string;
oneText: string;
zeroDescription: string[];
oneDescription: string[];
inverted: boolean;
}
// This should be moved to the valid labels api endpoint
const label_messages: {
[label: string]: LabelInfo;
} = {
spam: {
zeroText: "Not Spam",
zeroDescription: ["Suitable for training Open Assistant."],
oneText: "Spam",
oneDescription: [
"Seems to be intentionally low-quality or irrelevant",
'We consider the following unwanted content as spam: trolling, intentional undermining of our purpose, illegal material, material that violates our code of conduct, and other things that are inappropriate for our dataset. We collect these under the common heading of "spam".',
"This is not an assessment of whether this message is the best possible answer. Especially for prompts or user-replies, we very much want to retain all kinds of responses in the dataset, so that the assistant can learn to reply appropriately.",
"Please mark this text as spam only if it is clearly unsuited to be part of our dataset, as outlined above, and try not to make any subjective value-judgments beyond that.",
],
inverted: true,
},
fails_task: {
zeroText: "Follows Instructions",
zeroDescription: [],
oneText: "Fails Task",
oneDescription: ["Fails to follow the correct instruction / task"],
inverted: true,
},
not_appropriate: {
zeroText: "Appropriate",
zeroDescription: [],
oneText: "Inappropriate",
oneDescription: ["Inappropriate for customer assistant"],
inverted: true,
},
violence: {
zeroText: "Harmless",
zeroDescription: [],
oneText: "Violent",
oneDescription: ["Encourages or fails to discourage violence/abuse/terrorism/self-harm"],
inverted: true,
},
excessive_harm: {
zeroText: "Safe",
zeroDescription: [],
oneText: "Harmful",
oneDescription: [
"Content likely to cause excessive harm not justifiable in the context",
"Harm refers to physical or mental damage or injury to someone or something. Excessive refers to a reasonable threshold of harm in the context, for instance damaging skin is not excessive in the context of surgery.",
],
inverted: true,
},
sexual_content: {
zeroText: "Non Sexual",
zeroDescription: [],
oneText: "Sexual",
oneDescription: ["Contains sexual content"],
inverted: true,
},
toxicity: {
zeroText: "Polite",
zeroDescription: [],
oneText: "Rude",
oneDescription: ["Contains rude, abusive, profane or insulting content"],
inverted: true,
},
moral_judgement: {
zeroText: "Non-Judgemental",
zeroDescription: [],
oneText: "Judgemental",
oneDescription: ["Expresses moral judgement"],
inverted: true,
},
political_content: {
zeroText: "Apolitical",
zeroDescription: [],
oneText: "Political",
oneDescription: ["Expresses political views"],
inverted: true,
},
humor: {
zeroText: "Serious",
zeroDescription: [],
oneText: "Humorous",
oneDescription: ["Contains humorous content including sarcasm"],
inverted: false,
},
hate_speech: {
zeroText: "Safe",
zeroDescription: [],
oneText: "Hateful",
oneDescription: [
"Content is abusive or threatening and expresses prejudice against a protected characteristic",
"Prejudice refers to preconceived views not based on reason. Protected characteristics include gender, ethnicity, religion, sexual orientation, and similar characteristics.",
],
inverted: true,
},
threat: {
zeroText: "Safe",
zeroDescription: [],
oneText: "Threatening",
oneDescription: ["Contains a threat against a person or persons"],
inverted: true,
},
misleading: {
zeroText: "Accurate",
zeroDescription: [],
oneText: "Misleading",
oneDescription: ["Contains text which is incorrect or misleading"],
inverted: true,
},
helpful: {
zeroText: "Unhelful",
zeroDescription: [],
oneText: "Helpful",
oneDescription: ["Completes the task to a high standard"],
inverted: false,
},
creative: {
zeroText: "Boring",
zeroDescription: [],
oneText: "Creative",
oneDescription: ["Expresses creativity in responding to the task"],
inverted: false,
},
};
export const LabelInputGroup = ({ labelIDs, onChange, isEditable = true }: LabelInputGroupProps) => {
const [labelValues, setLabelValues] = useState<number[]>(Array.from({ length: labelIDs.length }).map(() => null));
const cardColor = useColorModeValue("gray.50", "gray.800");
return (
<Grid templateColumns={"minmax(min-content, 30em)"} rowGap={2}>
{labelIDs.map((labelId, idx) => {
const { zeroText, oneText, zeroDescription, oneDescription, inverted } = label_messages[labelId];
let textA = zeroText;
let textB = oneText;
let descriptionA = zeroDescription;
let descriptionB = oneDescription;
if (inverted) [textA, textB, descriptionA, descriptionB] = [textB, textA, descriptionB, descriptionA];
return (
<Box key={idx} padding={2} bg={cardColor} borderRadius="md" position="relative">
<Grid
templateColumns={{
base: "minmax(0, 1fr) minmax(0, 1fr)",
sm: "minmax(0, 1fr) auto minmax(0, 1fr)",
}}
alignItems="center"
>
<Text>
{textA}
{descriptionA.length > 0 ? <Explain explanation={descriptionA} /> : null}
</Text>
<GridItem colSpan={{ base: 2, sm: 1 }} gridColumnStart={{ base: 1, sm: 2 }} gridRow={{ base: 2, sm: 1 }}>
<LikertButtons
isDisabled={!isEditable}
count={5}
data-cy="label-options"
onChange={(value) => {
const newState = labelValues.slice();
newState[idx] = value === null ? null : inverted ? 1 - value : value;
onChange(newState);
setLabelValues(newState);
}}
/>
</GridItem>
<GridItem>
<Text textAlign="right">
{textB}
{descriptionB.length > 0 ? <Explain explanation={descriptionB} /> : null}
</Text>
</GridItem>
</Grid>
</Box>
);
})}
</Grid>
);
};
@@ -0,0 +1,243 @@
import { Box, Grid, GridItem, Text, useColorModeValue } from "@chakra-ui/react";
import React from "react";
import { useState } from "react";
import { LikertButtons } from "src/components/Buttons/LikertButtons";
import { Explain } from "src/components/Explain";
interface LabelInputGroupProps {
labelIDs: Array<string>;
onChange: (values: number[]) => unknown;
isEditable?: boolean;
}
interface LabelInfo {
zeroText: string;
oneText: string;
zeroDescription: string[];
oneDescription: string[];
inverted: boolean;
}
const getLabelInfo = (label: string): LabelInfo => {
switch (label) {
case "spam":
return {
zeroText: "Not Spam",
zeroDescription: ["Suitable for training Open Assistant."],
oneText: "Spam",
oneDescription: [
"Seems to be intentionally low-quality or irrelevant",
'We consider the following unwanted content as spam: trolling, intentional undermining of our purpose, illegal material, material that violates our code of conduct, and other things that are inappropriate for our dataset. We collect these under the common heading of "spam".',
"This is not an assessment of whether this message is the best possible answer. Especially for prompts or user-replies, we very much want to retain all kinds of responses in the dataset, so that the assistant can learn to reply appropriately.",
"Please mark this text as spam only if it is clearly unsuited to be part of our dataset, as outlined above, and try not to make any subjective value-judgments beyond that.",
],
inverted: true,
};
case "fails_task":
return {
zeroText: "Follows Instructions",
zeroDescription: [],
oneText: "Fails Task",
oneDescription: ["Fails to follow the correct instruction / task"],
inverted: true,
};
case "not_appropriate":
return {
zeroText: "Appropriate",
zeroDescription: [],
oneText: "Inappropriate",
oneDescription: ["Inappropriate for customer assistant"],
inverted: true,
};
case "violence":
return {
zeroText: "Harmless",
zeroDescription: [],
oneText: "Violent",
oneDescription: ["Encourages or fails to discourage violence/abuse/terrorism/self-harm"],
inverted: true,
};
case "excessive_harm":
return {
zeroText: "Safe",
zeroDescription: [],
oneText: "Harmful",
oneDescription: [
"Content likely to cause excessive harm not justifiable in the context",
"Harm refers to physical or mental damage or injury to someone or something. Excessive refers to a reasonable threshold of harm in the context, for instance damaging skin is not excessive in the context of surgery.",
],
inverted: true,
};
case "sexual_content":
return {
zeroText: "Non Sexual",
zeroDescription: [],
oneText: "Sexual",
oneDescription: ["Contains sexual content"],
inverted: true,
};
case "toxicity":
return {
zeroText: "Polite",
zeroDescription: [],
oneText: "Rude",
oneDescription: ["Contains rude, abusive, profane or insulting content"],
inverted: true,
};
case "moral_judgement":
return {
zeroText: "Non-Judgemental",
zeroDescription: [],
oneText: "Judgemental",
oneDescription: ["Expresses moral judgement"],
inverted: true,
};
case "political_content":
return {
zeroText: "Apolitical",
zeroDescription: [],
oneText: "Political",
oneDescription: ["Expresses political views"],
inverted: true,
};
case "humor":
return {
zeroText: "Serious",
zeroDescription: [],
oneText: "Humorous",
oneDescription: ["Contains humorous content including sarcasm"],
inverted: false,
};
case "hate_speech":
return {
zeroText: "Safe",
zeroDescription: [],
oneText: "Hateful",
oneDescription: [
"Content is abusive or threatening and expresses prejudice against a protected characteristic",
"Prejudice refers to preconceived views not based on reason. Protected characteristics include gender, ethnicity, religion, sexual orientation, and similar characteristics.",
],
inverted: true,
};
case "threat":
return {
zeroText: "Safe",
zeroDescription: [],
oneText: "Threatening",
oneDescription: ["Contains a threat against a person or persons"],
inverted: true,
};
case "misleading":
return {
zeroText: "Accurate",
zeroDescription: [],
oneText: "Misleading",
oneDescription: ["Contains text which is incorrect or misleading"],
inverted: true,
};
case "helpfulness":
return {
zeroText: "Unhelpful",
zeroDescription: [],
oneText: "Helpful",
oneDescription: ["Completes the task to a high standard"],
inverted: false,
};
case "creative":
return {
zeroText: "Boring",
zeroDescription: [],
oneText: "Creative",
oneDescription: ["Expresses creativity in responding to the task"],
inverted: false,
};
case "pii":
return {
zeroText: "Clean",
zeroDescription: [],
oneText: "Contains PII",
oneDescription: ["Contains personally identifing information"],
inverted: false,
};
case "quality":
return {
zeroText: "Low Quality",
zeroDescription: [],
oneText: "High Quality",
oneDescription: [],
inverted: false,
};
case "creativity":
return {
zeroText: "Ordinary",
zeroDescription: [],
oneText: "Creative",
oneDescription: [],
inverted: false,
};
default:
return {
zeroText: `!${label}`,
zeroDescription: [],
oneText: label,
oneDescription: [],
inverted: false,
};
}
};
export const LabelLikertGroup = ({ labelIDs, onChange, isEditable = true }: LabelInputGroupProps) => {
const [labelValues, setLabelValues] = useState<number[]>(Array.from({ length: labelIDs.length }).map(() => null));
const cardColor = useColorModeValue("gray.50", "gray.800");
return (
<Grid templateColumns={"minmax(min-content, 30em)"} rowGap={2}>
{labelIDs.map((labelId, idx) => {
const { zeroText, oneText, zeroDescription, oneDescription, inverted } = getLabelInfo(labelId);
let textA = zeroText;
let textB = oneText;
let descriptionA = zeroDescription;
let descriptionB = oneDescription;
if (inverted) [textA, textB, descriptionA, descriptionB] = [textB, textA, descriptionB, descriptionA];
return (
<Box key={idx} padding={2} bg={cardColor} borderRadius="md" position="relative">
<Grid
templateColumns={{
base: "minmax(0, 1fr) minmax(0, 1fr)",
sm: "minmax(0, 1fr) auto minmax(0, 1fr)",
}}
alignItems="center"
>
<Text as="div">
{textA}
{descriptionA.length > 0 ? <Explain explanation={descriptionA} /> : null}
</Text>
<GridItem colSpan={{ base: 2, sm: 1 }} gridColumnStart={{ base: 1, sm: 2 }} gridRow={{ base: 2, sm: 1 }}>
<LikertButtons
isDisabled={!isEditable}
count={5}
data-cy="label-options"
onChange={(value) => {
const newState = labelValues.slice();
newState[idx] = value === null ? null : inverted ? 1 - value : value;
onChange(newState);
setLabelValues(newState);
}}
/>
</GridItem>
<GridItem>
<Text textAlign="right" as="div">
{textB}
{descriptionB.length > 0 ? <Explain explanation={descriptionB} /> : null}
</Text>
</GridItem>
</Grid>
</Box>
);
})}
</Grid>
);
};
+46 -51
View File
@@ -1,71 +1,66 @@
import { Box, Flex, IconButton, Tooltip, useColorModeValue } from "@chakra-ui/react";
import { Box, Flex, IconButton, Progress, Tooltip, useColorModeValue } from "@chakra-ui/react";
import { Edit2 } from "lucide-react";
import { SkipButton } from "src/components/Buttons/Skip";
import { SubmitButton } from "src/components/Buttons/Submit";
import { TaskInfo } from "src/components/TaskInfo/TaskInfo";
import { TaskStatus } from "src/components/Tasks/Task";
import { BaseTask } from "src/types/Task";
export interface TaskControlsProps {
// we need a task type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
task: any;
className?: string;
task: BaseTask;
taskStatus: TaskStatus;
isLoading: boolean;
onEdit: () => void;
onReview: () => void;
onSubmit: () => void;
onSkip: (reason: string) => void;
}
export const TaskControls = (props: TaskControlsProps) => {
export const TaskControls = ({
task,
taskStatus,
isLoading,
onEdit,
onReview,
onSubmit,
onSkip,
}: TaskControlsProps) => {
const backgroundColor = useColorModeValue("white", "gray.800");
return (
<Box
width="full"
bg={backgroundColor}
borderRadius="xl"
p="6"
display="flex"
flexDirection={["column", "row"]}
shadow="base"
gap="4"
>
<TaskInfo id={props.task.id} output="Submit your answer" />
<Flex width={["full", "fit-content"]} justify="center" ml="auto" gap={2}>
{props.taskStatus === "REVIEW" || props.taskStatus === "SUBMITTED" ? (
<>
<Tooltip label="Edit">
<IconButton
size="lg"
data-cy="edit"
aria-label="edit"
onClick={props.onEdit}
icon={<Edit2 size="1em" />}
/>
</Tooltip>
<SubmitButton
colorScheme="green"
data-cy="submit"
isDisabled={props.taskStatus === "SUBMITTED"}
onClick={props.onSubmit}
>
Submit
</SubmitButton>
</>
) : (
<>
<SkipButton onSkip={props.onSkip} />
<SubmitButton
colorScheme="blue"
data-cy="review"
isDisabled={props.taskStatus === "NOT_SUBMITTABLE"}
onClick={props.onReview}
>
Review
</SubmitButton>
</>
)}
<Box width="full" bg={backgroundColor} borderRadius="xl" shadow="base">
{isLoading && <Progress size="sm" isIndeterminate />}
<Flex p="6" gap="4" direction={["column", "row"]}>
<TaskInfo id={task.id} output="Submit your answer" />
<Flex width={["full", "fit-content"]} justify="center" ml="auto" gap={2}>
{taskStatus.mode === "EDIT" ? (
<>
<SkipButton onSkip={onSkip} />
<SubmitButton
colorScheme="blue"
data-cy="review"
isDisabled={taskStatus.replyValidity === "INVALID"}
onClick={onReview}
>
Review
</SubmitButton>
</>
) : (
<>
<Tooltip label="Edit">
<IconButton size="lg" data-cy="edit" aria-label="edit" onClick={onEdit} icon={<Edit2 size="1em" />} />
</Tooltip>
<SubmitButton
colorScheme="green"
data-cy="submit"
isDisabled={taskStatus.mode === "SUBMITTED"}
onClick={onSubmit}
>
Submit
</SubmitButton>
</>
)}
</Flex>
</Flex>
</Box>
);
@@ -0,0 +1,53 @@
import Head from "next/head";
import { useTranslation } from "next-i18next";
import { TaskEmptyState } from "src/components/EmptyState";
import { LoadingScreen } from "src/components/Loading/LoadingScreen";
import { Task } from "src/components/Tasks/Task";
import { TaskInfos } from "src/components/Tasks/TaskTypes";
import { taskApiHooks } from "src/lib/constants";
import { getTypeSafei18nKey } from "src/lib/i18n";
import { TaskType } from "src/types/Task";
import { KnownTaskType } from "src/types/Tasks";
type TaskPageProps = {
type: TaskType;
};
export const TaskPage = ({ type }: TaskPageProps) => {
const { t } = useTranslation(["tasks", "common"]);
const taskApiHook = taskApiHooks[type];
const { response, isLoading, completeTask, skipTask } = taskApiHook(type);
const taskInfo = TaskInfos.find((taskType) => taskType.type === type);
let body;
switch (response.taskAvailability) {
case "AWAITING_INITIAL":
body = <LoadingScreen text={t("common:loading")} />;
break;
case "NONE_AVAILABLE":
body = <TaskEmptyState />;
break;
case "AVAILABLE":
body = (
<Task
key={response.task.id}
frontendId={response.id}
task={response.task as KnownTaskType}
isLoading={isLoading}
completeTask={completeTask}
skipTask={skipTask}
/>
);
break;
}
return (
<>
<Head>
<title>{t(getTypeSafei18nKey(`${taskInfo.id}.label`))}</title>
<meta name="description" content={t(getTypeSafei18nKey(`${taskInfo.id}.desc`))} />
</Head>
{body}
</>
);
};
+9 -3
View File
@@ -7,6 +7,8 @@ import { TwoColumnsWithCards } from "src/components/Survey/TwoColumnsWithCards";
import { TaskSurveyProps } from "src/components/Tasks/Task";
import { TaskHeader } from "src/components/Tasks/TaskHeader";
import { getTypeSafei18nKey } from "src/lib/i18n";
import { TaskType } from "src/types/Task";
import { CreateTaskType } from "src/types/Tasks";
export const CreateTask = ({
task,
@@ -15,7 +17,7 @@ export const CreateTask = ({
isDisabled,
onReplyChanged,
onValidityChanged,
}: TaskSurveyProps<{ text: string }>) => {
}: TaskSurveyProps<CreateTaskType, { text: string }>) => {
const { t, i18n } = useTranslation(["tasks", "common"]);
const cardColor = useColorModeValue("gray.50", "gray.800");
const titleColor = useColorModeValue("gray.800", "gray.300");
@@ -39,7 +41,7 @@ export const CreateTask = ({
<TwoColumnsWithCards>
<>
<TaskHeader taskType={taskType} />
{!!task.conversation && (
{task.type !== TaskType.initial_prompt && (
<Box mt="4" borderRadius="lg" bg={cardColor} className="p-3 sm:p-6">
<MessageTable messages={task.conversation.messages} highlightLastMessage />
</Box>
@@ -56,7 +58,11 @@ export const CreateTask = ({
text={inputText}
onTextChange={textChangeHandler}
thresholds={{ low: 20, medium: 40, goal: 50 }}
textareaProps={{ placeholder: t("tasks:write_initial_prompt"), isDisabled, isReadOnly: !isEditable }}
textareaProps={{
placeholder: t(getTypeSafei18nKey(`tasks:${taskType.id}.response_placeholder`)),
isDisabled,
isReadOnly: !isEditable,
}}
/>
</Stack>
</>
+10 -6
View File
@@ -5,6 +5,8 @@ import { Sortable } from "src/components/Sortable/Sortable";
import { SurveyCard } from "src/components/Survey/SurveyCard";
import { TaskSurveyProps } from "src/components/Tasks/Task";
import { TaskHeader } from "src/components/Tasks/TaskHeader";
import { TaskType } from "src/types/Task";
import { RankTaskType } from "src/types/Tasks";
export const EvaluateTask = ({
task,
@@ -13,20 +15,22 @@ export const EvaluateTask = ({
isDisabled,
onReplyChanged,
onValidityChanged,
}: TaskSurveyProps<{ ranking: number[] }>) => {
}: TaskSurveyProps<RankTaskType, { ranking: number[] }>) => {
const cardColor = useColorModeValue("gray.50", "gray.800");
const [ranking, setRanking] = useState<number[]>(null);
let messages = [];
if (task.conversation) {
if (task.type !== TaskType.rank_initial_prompts) {
messages = task.conversation.messages;
messages = messages.map((message, index) => ({ ...message, id: index }));
}
useEffect(() => {
if (ranking === null) {
const defaultRanking = (task.replies ?? task.prompts).map((_, idx) => idx);
onReplyChanged({ ranking: defaultRanking });
if (task.type === TaskType.rank_initial_prompts) {
onReplyChanged({ ranking: task.prompts.map((_, idx) => idx) });
} else {
onReplyChanged({ ranking: task.replies.map((_, idx) => idx) });
}
onValidityChanged("DEFAULT");
} else {
onReplyChanged({ ranking });
@@ -34,7 +38,7 @@ export const EvaluateTask = ({
}
}, [task, ranking, onReplyChanged, onValidityChanged]);
const sortables = task.replies ? "replies" : "prompts";
const sortables = task.type === TaskType.rank_initial_prompts ? "prompts" : "replies";
return (
<div data-cy="task" data-task-type="evaluate-task">
@@ -1,12 +1,18 @@
import { Box, Flex, Text, useColorModeValue } from "@chakra-ui/react";
import { Box, useBoolean, useColorModeValue } from "@chakra-ui/react";
import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react";
import { MessageView } from "src/components/Messages";
import { LabelInputGroup } from "src/components/Messages/LabelInputGroup";
import { MessageTable } from "src/components/Messages/MessageTable";
import { LabelInputGroup } from "src/components/Survey/LabelInputGroup";
import { TwoColumnsWithCards } from "src/components/Survey/TwoColumnsWithCards";
import { TaskSurveyProps } from "src/components/Tasks/Task";
import { TaskHeader } from "src/components/Tasks/TaskHeader";
import { TaskType } from "src/types/Task";
import { LabelTaskType } from "src/types/Tasks";
const isRequired = (labelName: string, requiredLabels?: string[]) => {
return requiredLabels ? requiredLabels.includes(labelName) : false;
};
export const LabelTask = ({
task,
@@ -14,52 +20,67 @@ export const LabelTask = ({
isEditable,
onReplyChanged,
onValidityChanged,
}: TaskSurveyProps<{ text: string; labels: Record<string, number>; message_id: string }>) => {
const [sliderValues, setSliderValues] = useState<number[]>(new Array(task.valid_labels.length).fill(null));
}: TaskSurveyProps<LabelTaskType, { text: string; labels: Record<string, number>; message_id: string }>) => {
const { t } = useTranslation("labelling");
const [values, setValues] = useState<number[]>(new Array(task.labels.length).fill(null));
const [userInputMade, setUserInputMade] = useBoolean(false);
// Initial setup to run when the task changes
useEffect(() => {
console.assert(task.valid_labels.length === sliderValues.length);
const labels = Object.fromEntries(task.valid_labels.map((label, i) => [label, sliderValues[i]]));
onReplyChanged({ labels, text: task.reply || task.prompt, message_id: task.message_id });
onValidityChanged(sliderValues.every((value) => value !== null) ? "VALID" : "INVALID");
}, [task, sliderValues, onReplyChanged, onValidityChanged]);
setValues(new Array(task.labels.length).fill(null));
onValidityChanged(task.labels.some(({ name }) => isRequired(name, task.mandatory_labels)) ? "INVALID" : "DEFAULT");
setUserInputMade.off();
}, [task, setUserInputMade, onValidityChanged]);
// Update the reply and validity when the values change
useEffect(() => {
onReplyChanged({
text: "unused?",
labels: Object.fromEntries(task.labels.map(({ name }, idx) => [name, values[idx] || 0])),
message_id: task.message_id,
});
onValidityChanged(
task.labels.some(({ name }, idx) => values[idx] === null && isRequired(name, task.mandatory_labels))
? "INVALID"
: userInputMade
? "VALID"
: "DEFAULT"
);
}, [task, values, onReplyChanged, userInputMade, onValidityChanged]);
const cardColor = useColorModeValue("gray.50", "gray.800");
const isSpamTask = task.mode === "simple" && task.valid_labels.length === 1 && task.valid_labels[0] === "spam";
return (
<div data-cy="task" data-task-type="label-task">
<div data-cy="task" data-task-type={isSpamTask ? "spam-task" : "label-task"}>
<TwoColumnsWithCards>
<>
<TaskHeader taskType={taskType} />
{task.conversation ? (
{task.type !== TaskType.label_initial_prompt ? (
<Box mt="4" p={[4, 6]} borderRadius="lg" bg={cardColor}>
<MessageTable
messages={[
...(task.conversation?.messages ?? []),
{
text: task.reply,
is_assistant: task.type === TaskType.label_assistant_reply,
message_id: task.message_id,
},
]}
highlightLastMessage
/>
<MessageTable messages={task.conversation.messages} highlightLastMessage />
</Box>
) : (
<Box mt="4">
<MessageView text={task.prompt} is_assistant={false} id={task.message_id} />
<MessageView text={task.prompt} is_assistant={false} id={task.message_id} emojis={{}} user_emojis={[]} />
</Box>
)}
</>
<Flex direction="column" alignItems="stretch">
<Text>The highlighted message:</Text>
<LabelInputGroup
simple={task.mode === "simple"}
labelIDs={task.valid_labels}
isEditable={isEditable}
onChange={setSliderValues}
/>
</Flex>
<LabelInputGroup
labels={task.labels}
values={values}
requiredLabels={task.mandatory_labels}
isEditable={isEditable}
instructions={{
yesNoInstruction: t("label_highlighted_yes_no_instruction"),
flagInstruction: t("label_highlighted_flag_instruction"),
likertInstruction: t("label_highlighted_likert_instruction"),
}}
onChange={(values) => {
setValues(values);
setUserInputMade.on();
}}
/>
</TwoColumnsWithCards>
</div>
);
@@ -7,8 +7,10 @@ export default {
component: Task,
};
const Template = ({ frontendId, task, trigger, mutate }) => {
return <Task frontendId={frontendId} task={task} trigger={trigger} mutate={mutate} />;
const Template = ({ frontendId, task, isLoading, completeTask, skipTask }) => {
return (
<Task frontendId={frontendId} task={task} isLoading={isLoading} completeTask={completeTask} skipTask={skipTask} />
);
};
export const Default = Template.bind({});
@@ -23,10 +25,11 @@ Default.args = {
type: "label_prompter_reply",
valid_labels: ["spam", "fails_task"],
},
trigger: (id, update_type, content) => {
isLoading: false,
completeTask: (id, update_type, content) => {
console.log(content);
},
mutate: () => {
console.log("mutate");
skipTask: () => {
console.log("skip");
},
};
+143 -92
View File
@@ -1,5 +1,6 @@
import { useTranslation } from "next-i18next";
import { useRef, useState } from "react";
import { useCallback, useEffect, useReducer } from "react";
import { useMemo, useRef } from "react";
import { TaskControls } from "src/components/Survey/TaskControls";
import { CreateTask } from "src/components/Tasks/CreateTask";
import { EvaluateTask } from "src/components/Tasks/EvaluateTask";
@@ -8,15 +9,53 @@ import { TaskCategory, TaskInfo, TaskInfos } from "src/components/Tasks/TaskType
import { UnchangedWarning } from "src/components/Tasks/UnchangedWarning";
import { post } from "src/lib/api";
import { getTypeSafei18nKey } from "src/lib/i18n";
import { TaskContent, TaskReplyValidity } from "src/types/Task";
import { BaseTask, TaskContent, TaskReplyValidity } from "src/types/Task";
import { CreateTaskType, KnownTaskType, LabelTaskType, RankTaskType } from "src/types/Tasks";
import useSWRMutation from "swr/mutation";
export type TaskStatus = "NOT_SUBMITTABLE" | "DEFAULT" | "VALID" | "REVIEW" | "SUBMITTED";
interface EditMode {
mode: "EDIT";
replyValidity: TaskReplyValidity;
}
interface ReviewMode {
mode: "REVIEW";
}
interface DefaultWarnMode {
mode: "DEFAULT_WARN";
}
interface SubmittedMode {
mode: "SUBMITTED";
}
export interface TaskSurveyProps<T> {
// we need a task type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
task: any;
export type TaskStatus = EditMode | DefaultWarnMode | ReviewMode | SubmittedMode;
interface NewTask {
action: "NEW_TASK";
}
interface Review {
action: "REVIEW";
}
interface SetSubmitted {
action: "SET_SUBMITTED";
}
interface ReturnToEdit {
action: "RETURN_EDIT";
}
interface AcceptDefault {
action: "ACCEPT_DEFAULT";
}
interface UpdateValidity {
action: "UPDATE_VALIDITY";
replyValidity: TaskReplyValidity;
}
export interface TaskSurveyProps<TaskType extends BaseTask, T> {
task: TaskType;
taskType: TaskInfo;
isEditable: boolean;
isDisabled?: boolean;
@@ -24,19 +63,76 @@ export interface TaskSurveyProps<T> {
onValidityChanged: (validity: TaskReplyValidity) => void;
}
export const Task = ({ frontendId, task, trigger, mutate }) => {
interface TaskProps {
frontendId: string;
task: KnownTaskType;
isLoading: boolean;
completeTask: (TaskContent) => void;
skipTask: () => void;
}
export const Task = ({ frontendId, task, isLoading, completeTask, skipTask }: TaskProps) => {
const { t } = useTranslation("tasks");
const [taskStatus, setTaskStatus] = useState<TaskStatus>("NOT_SUBMITTABLE");
const [taskStatus, taskEvent] = useReducer(
(
status: TaskStatus,
event: NewTask | UpdateValidity | AcceptDefault | Review | ReturnToEdit | SetSubmitted
): TaskStatus => {
switch (event.action) {
case "NEW_TASK":
return { mode: "EDIT", replyValidity: "INVALID" };
case "UPDATE_VALIDITY":
return status.mode === "EDIT" ? { mode: "EDIT", replyValidity: event.replyValidity } : status;
case "ACCEPT_DEFAULT":
return status.mode === "DEFAULT_WARN" ? { mode: "REVIEW" } : status;
case "REVIEW": {
if (status.mode === "EDIT") {
switch (status.replyValidity) {
case "DEFAULT":
return { mode: "DEFAULT_WARN" };
case "VALID":
return { mode: "REVIEW" };
}
}
return status;
}
case "RETURN_EDIT": {
switch (status.mode) {
case "REVIEW":
return { mode: "EDIT", replyValidity: "VALID" };
case "DEFAULT_WARN":
return { mode: "EDIT", replyValidity: "DEFAULT" };
default:
return status;
}
}
case "SET_SUBMITTED": {
return status.mode === "REVIEW" ? { mode: "SUBMITTED" } : status;
}
}
},
{ mode: "EDIT", replyValidity: "INVALID" }
);
const replyContent = useRef<TaskContent>(null);
const [showUnchangedWarning, setShowUnchangedWarning] = useState(false);
const updateValidity = useCallback(
(replyValidity: TaskReplyValidity) => taskEvent({ action: "UPDATE_VALIDITY", replyValidity }),
[taskEvent]
);
useEffect(() => {
taskEvent({ action: "NEW_TASK" });
}, [task.id, updateValidity]);
const rootEl = useRef<HTMLDivElement>(null);
const taskType = TaskInfos.find((taskType) => taskType.type === task.type && taskType.mode === task.mode);
const taskType = useMemo(() => {
return TaskInfos.find((taskType) => taskType.type === task.type);
}, [task.type]);
const { trigger: sendRejection } = useSWRMutation("/api/reject_task", post, {
onSuccess: async () => {
mutate();
skipTask();
},
});
@@ -47,128 +143,83 @@ export const Task = ({ frontendId, task, trigger, mutate }) => {
});
};
const edit_mode = taskStatus === "NOT_SUBMITTABLE" || taskStatus === "DEFAULT" || taskStatus === "VALID";
const submitted = taskStatus === "SUBMITTED";
const onValidityChanged = (validity: TaskReplyValidity) => {
if (!edit_mode) return;
switch (validity) {
case "DEFAULT":
if (taskStatus !== "DEFAULT") setTaskStatus("DEFAULT");
break;
case "VALID":
if (taskStatus !== "VALID") setTaskStatus("VALID");
break;
case "INVALID":
if (taskStatus !== "NOT_SUBMITTABLE") setTaskStatus("NOT_SUBMITTABLE");
break;
}
};
const onReplyChanged = (content: TaskContent) => {
replyContent.current = content;
};
const reviewResponse = () => {
switch (taskStatus) {
case "DEFAULT":
setShowUnchangedWarning(true);
break;
case "VALID":
setTaskStatus("REVIEW");
break;
default:
return;
}
};
const editResponse = () => {
switch (taskStatus) {
case "REVIEW":
setTaskStatus("VALID");
break;
default:
return;
}
};
const onReplyChanged = useCallback(
(content: TaskContent) => {
replyContent.current = content;
},
[replyContent]
);
const submitResponse = () => {
switch (taskStatus) {
case "REVIEW": {
trigger({
id: frontendId,
update_type: taskType.update_type,
content: replyContent.current,
});
setTaskStatus("SUBMITTED");
scrollToTop(rootEl.current);
break;
}
default:
return;
if (taskStatus.mode === "REVIEW") {
completeTask({
id: frontendId,
update_type: taskType.update_type,
content: replyContent.current,
});
taskEvent({ action: "SET_SUBMITTED" });
scrollToTop(rootEl.current);
}
};
function taskTypeComponent() {
const taskTypeComponent = useMemo(() => {
switch (taskType.category) {
case TaskCategory.Create:
return (
<CreateTask
task={task}
task={task as CreateTaskType}
taskType={taskType}
isEditable={edit_mode}
isDisabled={submitted}
isEditable={taskStatus.mode === "EDIT"}
isDisabled={taskStatus.mode === "SUBMITTED"}
onReplyChanged={onReplyChanged}
onValidityChanged={onValidityChanged}
onValidityChanged={updateValidity}
/>
);
case TaskCategory.Evaluate:
return (
<EvaluateTask
task={task}
task={task as RankTaskType}
taskType={taskType}
isEditable={edit_mode}
isDisabled={submitted}
isEditable={taskStatus.mode === "EDIT"}
isDisabled={taskStatus.mode === "SUBMITTED"}
onReplyChanged={onReplyChanged}
onValidityChanged={onValidityChanged}
onValidityChanged={updateValidity}
/>
);
case TaskCategory.Label:
return (
<LabelTask
task={task}
task={task as LabelTaskType}
taskType={taskType}
isEditable={edit_mode}
isDisabled={submitted}
isEditable={taskStatus.mode === "EDIT"}
isDisabled={taskStatus.mode === "SUBMITTED"}
onReplyChanged={onReplyChanged}
onValidityChanged={onValidityChanged}
onValidityChanged={updateValidity}
/>
);
}
}
}, [task, taskType, taskStatus.mode, onReplyChanged, updateValidity]);
return (
<div ref={rootEl}>
{taskTypeComponent()}
{taskTypeComponent}
<TaskControls
task={task}
taskStatus={taskStatus}
onEdit={editResponse}
onReview={reviewResponse}
isLoading={isLoading}
onEdit={() => taskEvent({ action: "RETURN_EDIT" })}
onReview={() => taskEvent({ action: "REVIEW" })}
onSubmit={submitResponse}
onSkip={rejectTask}
/>
<UnchangedWarning
show={showUnchangedWarning}
show={taskStatus.mode === "DEFAULT_WARN"}
title={t(getTypeSafei18nKey(`${taskType.id}.unchanged_title`)) || t("default.unchanged_title")}
message={t(getTypeSafei18nKey(`${taskType.id}.unchanged_message`)) || t("default.unchanged_message")}
continueButtonText={"Continue anyway"}
onClose={() => setShowUnchangedWarning(false)}
onClose={() => taskEvent({ action: "RETURN_EDIT" })}
onContinueAnyway={() => {
if (taskStatus === "DEFAULT") {
setTaskStatus("REVIEW");
setShowUnchangedWarning(false);
}
taskEvent({ action: "ACCEPT_DEFAULT" });
}}
/>
</div>
+1 -2
View File
@@ -4,8 +4,7 @@ import { Pencil } from "lucide-react";
import Link from "next/link";
import { memo, useState } from "react";
import { get } from "src/lib/api";
import { FetchUsersResponse } from "src/lib/oasst_api_client";
import type { User } from "src/types/Users";
import type { FetchUsersResponse, User } from "src/types/Users";
import useSWR from "swr";
import { DataTable, DataTableColumnDef, FilterItem } from "./DataTable";