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}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
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}
+
+
+
+ )
+}
+
+export default FeedbackFormContent
diff --git a/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar/constants.ts b/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar/constants.ts
new file mode 100644
index 0000000000..82603b7495
--- /dev/null
+++ b/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar/constants.ts
@@ -0,0 +1,23 @@
+export const FEEDBACK_POPOVER_DETAILS = {
+ positive: {
+ dropdownLabel: null,
+ dropdownOptions: null,
+ textAreaLabel:
+ 'Provide details on what was satisfying about this response:',
+ textAreaPlaceholder: 'What was satisfying about this response?',
+ score: 1,
+ },
+ negative: {
+ dropdownLabel: 'What type of issue do you wish to report?',
+ dropdownOptions: [
+ 'Incorrect workflow generated',
+ 'Incomplete response',
+ 'UI bug',
+ "I don't understand the response",
+ 'Other',
+ ],
+ textAreaLabel: 'Provide details on what was wrong with this response:',
+ textAreaPlaceholder: 'What was wrong with this response?',
+ score: 0,
+ },
+}
diff --git a/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar/index.tsx b/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar/index.tsx
new file mode 100644
index 0000000000..cd7f32bc49
--- /dev/null
+++ b/packages/frontend/src/pages/AiBuilder/components/ChatMessages/ChatMessageToolbar/index.tsx
@@ -0,0 +1,18 @@
+import { Flex } from '@chakra-ui/react'
+
+import { FeedbackButton } from './FeedbackButton'
+
+interface ChatMessageToolbarProps {
+ traceId: string
+}
+
+export default function ChatMessageToolbar({
+ traceId,
+}: ChatMessageToolbarProps) {
+ return (
+
+
+
+
+ )
+}