diff --git a/packages/backend/src/graphql/mutation-resolvers.ts b/packages/backend/src/graphql/mutation-resolvers.ts index 16d23e7c1d..256743c2cb 100644 --- a/packages/backend/src/graphql/mutation-resolvers.ts +++ b/packages/backend/src/graphql/mutation-resolvers.ts @@ -1,6 +1,5 @@ import type { MutationResolvers } from './__generated__/types.generated' -import generateAiSteps from './mutations/ai/generate-ai-steps' -import refineFormInput from './mutations/ai/refine-form-input' +import aiMutationResolvers from './mutations/ai' import bulkRetryExecutions from './mutations/bulk-retry-executions' import bulkRetryIterations from './mutations/bulk-retry-iterations' import createConnection from './mutations/create-connection' @@ -61,8 +60,6 @@ export default { bulkRetryIterations, createConnection, generateAuthUrl, - generateAiSteps, - refineFormInput, updateConnection, resetConnection, verifyConnection, @@ -97,6 +94,7 @@ export default { deleteUploadedFile, generatePresignedPost, ...tilesMutationResolvers, + ...aiMutationResolvers, // This is a special stub that enables us to group all our admin-related // mutations into a special AdminMutation object; each "mutation" is handled by field diff --git a/packages/backend/src/graphql/mutations/ai/index.ts b/packages/backend/src/graphql/mutations/ai/index.ts new file mode 100644 index 0000000000..1a76a7d7bc --- /dev/null +++ b/packages/backend/src/graphql/mutations/ai/index.ts @@ -0,0 +1,11 @@ +import type { MutationResolvers } from '../../__generated__/types.generated' + +import generateAiSteps from './generate-ai-steps' +import refineFormInput from './refine-form-input' +import updateChatFeedback from './update-chat-feedback' + +export default { + generateAiSteps, + refineFormInput, + updateChatFeedback, +} satisfies MutationResolvers diff --git a/packages/backend/src/graphql/mutations/ai/update-chat-feedback.ts b/packages/backend/src/graphql/mutations/ai/update-chat-feedback.ts new file mode 100644 index 0000000000..97afaba4ec --- /dev/null +++ b/packages/backend/src/graphql/mutations/ai/update-chat-feedback.ts @@ -0,0 +1,35 @@ +import appConfig from '@/config/app' +import { langfuseClient } from '@/helpers/langfuse' +import logger from '@/helpers/logger' + +import { MutationResolvers } from '../../__generated__/types.generated' + +const updateChatFeedback: MutationResolvers['updateChatFeedback'] = async ( + _parent, + params, + context, +) => { + const { traceId, feedback, score } = params.input + + try { + langfuseClient.score.create({ + traceId, + id: `feedback-${traceId}-${context.currentUser.email}`, + environment: appConfig.appEnv, + name: 'user-feedback', + value: score, // 1 for positive, 0 for negative + comment: feedback.comment as string, + ...(feedback?.category && { + metadata: { category: feedback.category }, + }), + }) + + return true + } catch (error) { + logger.error('Failed to update chat feedback', { error }) + // we don't throw because it doesn't stop the user from using the chat + return false + } +} + +export default updateChatFeedback diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 0fed93f492..28733a1ea0 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -134,6 +134,7 @@ type Mutation { # Ai builder mutations generateAiSteps(input: GenerateAiStepsInput!): JSONObject refineFormInput(input: RefineFormInputInput!): JSONObject + updateChatFeedback(input: UpdateChatFeedbackInput!): Boolean! # Admin mutations admin: AdminMutation! } @@ -149,6 +150,12 @@ input RefineFormInputInput { sessionId: String } +input UpdateChatFeedbackInput { + traceId: String! + feedback: JSONObject + score: Int! +} + type StepInput { type: StepEnumType! appKey: String! diff --git a/packages/frontend/src/graphql/mutations/ai/update-chat-feedback.ts b/packages/frontend/src/graphql/mutations/ai/update-chat-feedback.ts new file mode 100644 index 0000000000..2d051cae57 --- /dev/null +++ b/packages/frontend/src/graphql/mutations/ai/update-chat-feedback.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client' + +export const UPDATE_CHAT_FEEDBACK = gql` + mutation updateChatFeedback($input: UpdateChatFeedbackInput!) { + updateChatFeedback(input: $input) + } +` diff --git a/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessage.tsx b/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessage.tsx index b9c6439b0b..dc2087c8c4 100644 --- a/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessage.tsx +++ b/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessage.tsx @@ -18,7 +18,7 @@ const AiMessage = ({ message }: ChatMessageProps) => { {message.text || ''} - + ) diff --git a/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar.tsx b/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar.tsx deleted file mode 100644 index 6549315f95..0000000000 --- a/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { FaRegThumbsDown } from 'react-icons/fa' -import { Flex, Icon, IconButton, Tooltip } from '@chakra-ui/react' - -const DEFAULT_BUTTON_PROPS = { - size: 'xs', - variant: 'clear', - color: 'gray.500', - _hover: { color: 'gray.700', bg: 'gray.100' }, -} - -export default function ChatMessageToolbar() { - const handleThumbsDown = () => { - // TODO: Implement feedback functionality - } - - return ( - - - } - onClick={handleThumbsDown} - /> - - - ) -} diff --git a/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar/FeedbackButton.tsx b/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar/FeedbackButton.tsx new file mode 100644 index 0000000000..fe513ab429 --- /dev/null +++ b/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar/FeedbackButton.tsx @@ -0,0 +1,144 @@ +import { FaRegThumbsDown, FaRegThumbsUp } from 'react-icons/fa' +import { useMutation } from '@apollo/client' +import { + Button, + ButtonGroup, + FocusLock, + FormControl, + Icon, + IconButton, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, + Stack, + useDisclosure, +} from '@chakra-ui/react' +import { useToast } from '@opengovsg/design-system-react' + +import Form from '@/components/Form' +import { UPDATE_CHAT_FEEDBACK } from '@/graphql/mutations/ai/update-chat-feedback' + +import { FEEDBACK_POPOVER_DETAILS } from './constants' +import FeedbackFormContent from './FeedbackFormContent' + +interface FeedbackFormData { + 'feedback-dropdown'?: string + 'feedback-details': string +} + +interface FeedbackButtonProps { + feedbackType: 'positive' | 'negative' + traceId: string +} + +export const FeedbackButton = ({ + feedbackType, + traceId, +}: FeedbackButtonProps) => { + const { onOpen, onClose, isOpen } = useDisclosure() + const toast = useToast() + const icon = feedbackType === 'positive' ? FaRegThumbsUp : FaRegThumbsDown + const [updateChatFeedback] = useMutation(UPDATE_CHAT_FEEDBACK) + const { + dropdownLabel, + dropdownOptions, + textAreaLabel, + textAreaPlaceholder, + score, + } = FEEDBACK_POPOVER_DETAILS[feedbackType] + + const handleSubmitFeedback = async (data: FeedbackFormData) => { + try { + if (!traceId) { + return + } + + // NOTE: we send feedback to the backend instead of using Langfuse directly + // as there are additional headers required to call Rome/Istanbul endpoints + // that should not be exposed to the frontend + await updateChatFeedback({ + variables: { + input: { + traceId, + feedback: { + category: data['feedback-dropdown'], + comment: data['feedback-details'], + }, + score, + }, + }, + }) + } catch { + // don't throw error if feedback submission fails + // as it is not critical to the user experience + } finally { + // NOTE: do not reset form here + // so that user will see what they previously typed or submitted + // if they attempt to submit again + onClose() + toast({ + title: "Thank you! We've sent your feedback to the Plumber team.", + status: 'success', + duration: 3000, + isClosable: true, + position: 'top', + }) + } + } + + return ( + + + } + onClick={onOpen} + // HACKFIX(kevinkim-ogp): prevent autofocus when new input is sent + tabIndex={-1} + /> + + + + + + +
+ handleSubmitFeedback(data as FeedbackFormData) + } + > + + + + + + + + + +
+
+
+
+
+ ) +} diff --git a/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar/FeedbackFormContent.tsx b/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar/FeedbackFormContent.tsx new file mode 100644 index 0000000000..e43ee3bdba --- /dev/null +++ b/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar/FeedbackFormContent.tsx @@ -0,0 +1,65 @@ +import { Controller, useFormContext } from 'react-hook-form' +import { Flex, FormLabel, Textarea } from '@chakra-ui/react' + +import { SingleSelect } from '@/components/SingleSelect' + +interface FeedbackFormData { + 'feedback-dropdown'?: string + 'feedback-details': string +} + +const FeedbackFormContent = ({ + dropdownLabel, + dropdownOptions, + textAreaLabel, + textAreaPlaceholder, + autoFocus, +}: { + dropdownLabel: string | null + dropdownOptions: string[] | null + textAreaLabel: string + textAreaPlaceholder: string + autoFocus?: boolean +}) => { + const { control, register } = useFormContext() + + return ( + + {dropdownLabel != null && dropdownOptions != null && ( + <> + + {dropdownLabel} + + ( + + )} + /> + + )} + + {textAreaLabel} + +