diff --git a/packages/client/modules/meeting/components/MeetingCheckInPrompt/NewCheckInQuestion.tsx b/packages/client/modules/meeting/components/MeetingCheckInPrompt/NewCheckInQuestion.tsx index a4b795672a2..dac4215e2e2 100644 --- a/packages/client/modules/meeting/components/MeetingCheckInPrompt/NewCheckInQuestion.tsx +++ b/packages/client/modules/meeting/components/MeetingCheckInPrompt/NewCheckInQuestion.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled' import {Create as CreateIcon, Refresh as RefreshIcon} from '@mui/icons-material' import graphql from 'babel-plugin-relay/macro' -import {convertToRaw, EditorState, SelectionState} from 'draft-js' +import {ContentState, convertToRaw, EditorState, SelectionState} from 'draft-js' import React, {useRef, useState} from 'react' import {useFragment} from 'react-relay' import {NewCheckInQuestion_meeting$key} from '~/__generated__/NewCheckInQuestion_meeting.graphql' @@ -15,6 +15,13 @@ import useTooltip from '../../../../hooks/useTooltip' import UpdateNewCheckInQuestionMutation from '../../../../mutations/UpdateNewCheckInQuestionMutation' import {PALETTE} from '../../../../styles/paletteV3' import convertToTaskContent from '../../../../utils/draftjs/convertToTaskContent' +import useMutationProps from '../../../../hooks/useMutationProps' +import { + useModifyCheckInQuestionMutation$data as TModifyCheckInQuestion$data, + ModifyType +} from '../../../../__generated__/useModifyCheckInQuestionMutation.graphql' +import {Button} from '../../../../ui/Button/Button' +import {useModifyCheckInQuestionMutation} from '../../../../mutations/useModifyCheckInQuestionMutation' const CogIcon = styled('div')({ color: PALETTE.SLATE_700, @@ -77,14 +84,34 @@ const NewCheckInQuestion = (props: Props) => { checkInQuestion } } + team { + organization { + featureFlags { + aiIcebreakers + } + } + } } `, meetingRef ) const [isEditing, setIsEditing] = useState(false) - const {id: meetingId, localPhase, facilitatorUserId} = meeting + const [aiUpdatedIcebreaker, setAiUpdatedIcebreaker] = useState('') + const { + id: meetingId, + localPhase, + facilitatorUserId, + team: { + organization: {featureFlags} + } + } = meeting const {checkInQuestion} = localPhase + const {viewerId} = atmosphere + const isFacilitating = facilitatorUserId === viewerId + const [editorState, setEditorState] = useEditorState(checkInQuestion) + const {submitting, submitMutation, onCompleted, onError} = useMutationProps() + const updateQuestion = (nextEditorState: EditorState) => { const wasFocused = editorState.getSelection().getHasFocus() const isFocused = nextEditorState.getSelection().getHasFocus() @@ -94,11 +121,16 @@ const NewCheckInQuestion = (props: Props) => { const nextCheckInQuestion = nextContent.hasText() ? JSON.stringify(convertToRaw(nextContent)) : '' + if (nextCheckInQuestion === checkInQuestion) return - UpdateNewCheckInQuestionMutation(atmosphere, { - meetingId, - checkInQuestion: nextCheckInQuestion - }) + UpdateNewCheckInQuestionMutation( + atmosphere, + { + meetingId, + checkInQuestion: nextCheckInQuestion + }, + {onCompleted, onError} + ) } setEditorState(nextEditorState) } @@ -108,27 +140,16 @@ const NewCheckInQuestion = (props: Props) => { const currentText = editorRef.current?.value const nextCheckInQuestion = convertToTaskContent(currentText || '') if (nextCheckInQuestion === checkInQuestion) return - UpdateNewCheckInQuestionMutation(atmosphere, { - meetingId, - checkInQuestion: nextCheckInQuestion - }) + UpdateNewCheckInQuestionMutation( + atmosphere, + { + meetingId, + checkInQuestion: nextCheckInQuestion + }, + {onCompleted, onError} + ) } - const focusQuestion = () => { - closeEditIcebreakerTooltip() - editorRef.current && editorRef.current.focus() - const selection = editorState.getSelection() - const contentState = editorState.getCurrentContent() - const jumpToEnd = (selection as any).merge({ - anchorOffset: contentState.getLastBlock().getLength(), - focusOffset: contentState.getLastBlock().getLength() - }) as SelectionState - const nextEditorState = EditorState.forceSelection(editorState, jumpToEnd) - setEditorState(nextEditorState) - } - const {viewerId} = atmosphere - const isFacilitating = facilitatorUserId === viewerId - // eslint-disable-next-line react-hooks/rules-of-hooks const { tooltipPortal: editIcebreakerTooltipPortal, openTooltip: openEditIcebreakerTooltip, @@ -146,53 +167,165 @@ const NewCheckInQuestion = (props: Props) => { disabled: !isFacilitating }) + const focusQuestion = () => { + closeEditIcebreakerTooltip() + editorRef.current && editorRef.current.focus() + const selection = editorState.getSelection() + const contentState = editorState.getCurrentContent() + const jumpToEnd = (selection as any).merge({ + anchorOffset: contentState.getLastBlock().getLength(), + focusOffset: contentState.getLastBlock().getLength() + }) as SelectionState + const nextEditorState = EditorState.forceSelection(editorState, jumpToEnd) + setEditorState(nextEditorState) + } + const refresh = () => { - UpdateNewCheckInQuestionMutation(atmosphere, { - meetingId, - checkInQuestion: '' + UpdateNewCheckInQuestionMutation( + atmosphere, + { + meetingId, + checkInQuestion: '' + }, + {onCompleted, onError} + ) + setAiUpdatedIcebreaker('') + } + + const updateCheckInQuestionWithGeneratedContent = () => { + submitMutation() + UpdateNewCheckInQuestionMutation( + atmosphere, + { + meetingId, + checkInQuestion: JSON.stringify( + convertToRaw(ContentState.createFromText(aiUpdatedIcebreaker)) + ) + }, + {onCompleted, onError} + ) + setAiUpdatedIcebreaker('') + } + + const [executeModifyCheckInQuestionMutation, isModifyingCheckInQuestion] = + useModifyCheckInQuestionMutation() + const modifyCheckInQuestion = (modifyType: ModifyType) => { + const icebreakerToModify = aiUpdatedIcebreaker || checkInQuestion! + executeModifyCheckInQuestionMutation({ + variables: { + meetingId, + checkInQuestion: icebreakerToModify, + modifyType + }, + onCompleted: (res: TModifyCheckInQuestion$data) => { + const {modifyCheckInQuestion} = res + if (!modifyCheckInQuestion.modifiedCheckInQuestion) { + return + } + + setAiUpdatedIcebreaker(modifyCheckInQuestion.modifiedCheckInQuestion) + } }) } + const shouldShowAiIcebreakers = featureFlags?.aiIcebreakers && isFacilitating + return ( - - {/* cannot set min width because iPhone 5 has a width of 320*/} - - {isFacilitating && ( -
- - - - - - - - - - + <> + + {/* cannot set min width because iPhone 5 has a width of 320*/} + + {isFacilitating && ( +
+ + + + + + + + + + +
+ )} +
+ {shouldShowAiIcebreakers && ( +
+
+
+
Modify current icebreaker with AI
+
+
+
As a facilitator, you can spice up the current icebreaker with AI.
+
Others will see the result only if you approve it.
+
+
+ {aiUpdatedIcebreaker &&
{aiUpdatedIcebreaker}
} +
+ + + +
+
+ +
)} {editIcebreakerTooltipPortal(<>Edit icebreaker)} {refreshIcebreakerTooltipPortal(<>Refresh icebreaker)} - + ) } diff --git a/packages/client/mutations/UpdateNewCheckInQuestionMutation.ts b/packages/client/mutations/UpdateNewCheckInQuestionMutation.ts index d2595db270f..c7dc80a21e9 100644 --- a/packages/client/mutations/UpdateNewCheckInQuestionMutation.ts +++ b/packages/client/mutations/UpdateNewCheckInQuestionMutation.ts @@ -1,7 +1,7 @@ import graphql from 'babel-plugin-relay/macro' import {commitMutation} from 'react-relay' import {RecordProxy} from 'relay-runtime' -import {SimpleMutation} from '../types/relayMutations' +import {StandardMutation} from '../types/relayMutations' import {UpdateNewCheckInQuestionMutation as TUpdateNewCheckInQuestionMutation} from '../__generated__/UpdateNewCheckInQuestionMutation.graphql' graphql` fragment UpdateNewCheckInQuestionMutation_meeting on UpdateNewCheckInQuestionPayload { @@ -30,9 +30,10 @@ type CheckInPhase = NonNullable< NonNullable['meeting'] >['phases'][0] -const UpdateNewCheckInQuestionMutation: SimpleMutation = ( +const UpdateNewCheckInQuestionMutation: StandardMutation = ( atmosphere, - variables + variables, + {onCompleted, onError} ) => { return commitMutation(atmosphere, { mutation, @@ -48,7 +49,9 @@ const UpdateNewCheckInQuestionMutation: SimpleMutation phase.getValue('__typename') === 'CheckInPhase' ) as RecordProxy checkInPhase.setValue(checkInQuestion, 'checkInQuestion') - } + }, + onCompleted, + onError }) } diff --git a/packages/client/mutations/useModifyCheckInQuestionMutation.ts b/packages/client/mutations/useModifyCheckInQuestionMutation.ts new file mode 100644 index 00000000000..ce2ee15c5b0 --- /dev/null +++ b/packages/client/mutations/useModifyCheckInQuestionMutation.ts @@ -0,0 +1,38 @@ +import graphql from 'babel-plugin-relay/macro' +import {useMutation, UseMutationConfig} from 'react-relay' +import {useModifyCheckInQuestionMutation as TModifyCheckInQuestionMutation} from '../__generated__/useModifyCheckInQuestionMutation.graphql' + +graphql` + fragment useModifyCheckInQuestionMutation_success on ModifyCheckInQuestionSuccess { + modifiedCheckInQuestion + } +` + +const mutation = graphql` + mutation useModifyCheckInQuestionMutation( + $meetingId: ID! + $checkInQuestion: String! + $modifyType: ModifyType! + ) { + modifyCheckInQuestion( + meetingId: $meetingId + checkInQuestion: $checkInQuestion + modifyType: $modifyType + ) { + ... on ErrorPayload { + error { + message + } + } + ...useModifyCheckInQuestionMutation_success @relay(mask: false) + } + } +` + +export const useModifyCheckInQuestionMutation = () => { + const [commit, submitting] = useMutation(mutation) + const execute = (config: UseMutationConfig) => { + return commit(config) + } + return [execute, submitting] as const +} diff --git a/packages/client/package.json b/packages/client/package.json index 885e1671eab..692938eec8f 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -81,6 +81,7 @@ "@radix-ui/react-scroll-area": "^1.0.3", "@radix-ui/react-select": "^1.2.2", "@radix-ui/react-tooltip": "^1.0.7", + "@radix-ui/react-slot": "^1.0.2", "@sentry/browser": "^5.8.0", "@stripe/react-stripe-js": "^1.16.5", "@stripe/stripe-js": "^1.47.0", diff --git a/packages/client/ui/Button/Button.tsx b/packages/client/ui/Button/Button.tsx new file mode 100644 index 00000000000..a60c67700d7 --- /dev/null +++ b/packages/client/ui/Button/Button.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import {Slot} from '@radix-ui/react-slot' +import clsx from 'clsx' + +type Variant = 'primary' | 'secondary' | 'destructive' | 'ghost' | 'link' | 'outline' +type Size = 'sm' | 'md' | 'lg' | 'default' +type Shape = 'pill' | 'circle' | 'default' + +const BASE_STYLES = + 'cursor-pointer inline-flex items-center justify-center whitespace-nowrap font-semibold transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50' + +// TODO: make sure the styles match the designs +const VARIANT_STYLES: Record = { + primary: 'bg-primary text-white hover:bg-primary/90', + destructive: 'bg-tomato-500 text-white hover:bg-tomato-500/90', + outline: 'text-slate-900 border border-slate-400 hover:bg-slate-200 px-2.5 py-1 bg-transparent', + secondary: 'bg-sky-500 text-white hover:bg-sky-500/80', + ghost: 'hover:bg-accent', + link: 'text-primary underline-offset-4 hover:underline' +} + +const SIZE_STYLES: Record = { + default: 'px-4 py-2 text-xs', + sm: 'h-7 px-3 text-xs', + md: 'h-9 px-4 text-sm', + lg: 'h-11 px-8 text-base' +} + +const SHAPE_STYLES: Record = { + pill: 'rounded-full', + circle: 'rounded-full aspect-square', + default: 'rounded-md' +} + +export interface ButtonProps extends React.ButtonHTMLAttributes { + asChild?: boolean + variant: Variant + size?: Size + shape: Shape +} + +const Button = React.forwardRef( + ({className, variant, size, shape, asChild = false, ...props}, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + } +) + +Button.displayName = 'Button' + +export {Button} diff --git a/packages/server/graphql/private/typeDefs/updateOrgFeatureFlag.graphql b/packages/server/graphql/private/typeDefs/updateOrgFeatureFlag.graphql index d4c31a4b795..caf4fb8943e 100644 --- a/packages/server/graphql/private/typeDefs/updateOrgFeatureFlag.graphql +++ b/packages/server/graphql/private/typeDefs/updateOrgFeatureFlag.graphql @@ -16,6 +16,7 @@ enum OrganizationFeatureFlagsEnum { publicTeams meetingInception kudos + aiIcebreakers } extend type Mutation { diff --git a/packages/server/graphql/public/mutations/modifyCheckInQuestion.ts b/packages/server/graphql/public/mutations/modifyCheckInQuestion.ts new file mode 100644 index 00000000000..03072962dd2 --- /dev/null +++ b/packages/server/graphql/public/mutations/modifyCheckInQuestion.ts @@ -0,0 +1,52 @@ +import {getUserId, isTeamMember} from '../../../utils/authorization' + +import {SubscriptionChannel} from 'parabol-client/types/constEnums' +import publish from '../../../utils/publish' + +import {MutationResolvers} from '../resolverTypes' +import getRethink from '../../../database/rethinkDriver' +import standardError from '../../../utils/standardError' +import OpenAIServerManager from '../../../utils/OpenAIServerManager' +import {analytics} from '../../../utils/analytics/analytics' + +const modifyCheckInQuestion: MutationResolvers['modifyCheckInQuestion'] = async ( + _source, + {meetingId, checkInQuestion, modifyType}, + {authToken, dataLoader, socketId: mutatorId} +) => { + const r = await getRethink() + const operationId = dataLoader.share() + const subOptions = {mutatorId, operationId} + const viewerId = getUserId(authToken) + + // AUTH + const [meeting, viewer] = await Promise.all([ + r.table('NewMeeting').get(meetingId).run(), + dataLoader.get('users').loadNonNull(viewerId) + ]) + if (!meeting) return standardError(new Error('Meeting not found'), {userId: viewerId}) + const {endedAt, teamId} = meeting + if (!isTeamMember(authToken, teamId)) { + return standardError(new Error('Team not found'), {userId: viewerId}) + } + + if (meeting.facilitatorUserId !== viewerId) { + return standardError(new Error('Nice try!'), {userId: viewerId}) + } + + if (endedAt) { + return standardError(new Error('Meeting has already ended'), {userId: viewerId}) + } + + const openai = new OpenAIServerManager() + const modifiedCheckInQuestion = await openai.modifyCheckInQuestion(checkInQuestion, modifyType) + + analytics.icebreakerModified(viewer, meetingId, modifyType, modifiedCheckInQuestion !== null) + + // RESOLUTION + const data = {modifiedCheckInQuestion} + publish(SubscriptionChannel.MEETING, meetingId, 'ModifyCheckInQuestionSuccess', data, subOptions) + return data +} + +export default modifyCheckInQuestion diff --git a/packages/server/graphql/public/typeDefs/Organization.graphql b/packages/server/graphql/public/typeDefs/Organization.graphql index 47a14c0965e..b315674556a 100644 --- a/packages/server/graphql/public/typeDefs/Organization.graphql +++ b/packages/server/graphql/public/typeDefs/Organization.graphql @@ -196,4 +196,5 @@ type OrganizationFeatureFlags { publicTeams: Boolean! meetingInception: Boolean! kudos: Boolean! + aiIcebreakers: Boolean! } diff --git a/packages/server/graphql/public/typeDefs/modifyCheckInQuestion.graphql b/packages/server/graphql/public/typeDefs/modifyCheckInQuestion.graphql new file mode 100644 index 00000000000..44d4ba066b8 --- /dev/null +++ b/packages/server/graphql/public/typeDefs/modifyCheckInQuestion.graphql @@ -0,0 +1,25 @@ +enum ModifyType { + SERIOUS + FUNNY + EXCITING +} + +extend type Mutation { + """ + Describe the mutation here + """ + modifyCheckInQuestion( + meetingId: ID! + checkInQuestion: String! + modifyType: ModifyType! + ): ModifyCheckInQuestionPayload! +} + +""" +Return value for modifyCheckInQuestion, which could be an error +""" +union ModifyCheckInQuestionPayload = ErrorPayload | ModifyCheckInQuestionSuccess + +type ModifyCheckInQuestionSuccess { + modifiedCheckInQuestion: String +} diff --git a/packages/server/graphql/public/types/OrganizationFeatureFlags.ts b/packages/server/graphql/public/types/OrganizationFeatureFlags.ts index ef337347fe8..1e4892a4fef 100644 --- a/packages/server/graphql/public/types/OrganizationFeatureFlags.ts +++ b/packages/server/graphql/public/types/OrganizationFeatureFlags.ts @@ -14,7 +14,8 @@ const OrganizationFeatureFlags: OrganizationFeatureFlagsResolvers = { publicTeams: ({publicTeams}) => !!publicTeams, singleColumnStandups: ({singleColumnStandups}) => !!singleColumnStandups, meetingInception: ({meetingInception}) => !!meetingInception, - kudos: ({kudos}) => !!kudos + kudos: ({kudos}) => !!kudos, + aiIcebreakers: ({aiIcebreakers}) => !!aiIcebreakers } export default OrganizationFeatureFlags diff --git a/packages/server/utils/OpenAIServerManager.ts b/packages/server/utils/OpenAIServerManager.ts index b2f2542e334..73721dcfa2d 100644 --- a/packages/server/utils/OpenAIServerManager.ts +++ b/packages/server/utils/OpenAIServerManager.ts @@ -1,6 +1,7 @@ import OpenAI from 'openai' import sendToSentry from './sendToSentry' import Reflection from '../database/types/Reflection' +import {ModifyType} from '../graphql/public/resolverTypes' class OpenAIServerManager { private openAIApi @@ -243,6 +244,45 @@ class OpenAIServerManager { return null } } + + async modifyCheckInQuestion(question: string, modifyType: ModifyType) { + if (!this.openAIApi) return null + + const maxQuestionLength = 160 + const prompt: Record = { + EXCITING: `Transform the following team retrospective ice breaker question into something imaginative and unexpected, using simple and clear language suitable for an international audience. Keep it engaging and thrilling, while ensuring it's easy to understand. Ensure the modified question does not exceed ${maxQuestionLength} characters. + Original question: "${question}"`, + + FUNNY: `Rewrite the following team retrospective ice breaker question to add humor, using straightforward and easy-to-understand language. Aim for a light-hearted, amusing twist that is accessible to an international audience. Ensure the modified question does not exceed ${maxQuestionLength} characters. + Original question: "${question}"`, + + SERIOUS: `Modify the following team retrospective ice breaker question to make it more thought-provoking, using clear and simple language. Make it profound to stimulate insightful discussions, while ensuring it remains comprehensible to a diverse international audience. Ensure the modified question does not exceed ${maxQuestionLength} characters. + Original question: "${question}"` + } + + try { + const response = await this.openAIApi.chat.completions.create({ + model: 'gpt-4', + messages: [ + { + role: 'user', + content: prompt[modifyType] + } + ], + temperature: 0.8, + max_tokens: 256, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0 + }) + + return (response.choices[0]?.message?.content?.trim() as string).replaceAll(`"`, '') ?? null + } catch (e) { + const error = e instanceof Error ? e : new Error('OpenAI failed to modifyCheckInQuestion') + sendToSentry(error) + return null + } + } } export default OpenAIServerManager diff --git a/packages/server/utils/analytics/analytics.ts b/packages/server/utils/analytics/analytics.ts index 632327cc186..ccbfde59576 100644 --- a/packages/server/utils/analytics/analytics.ts +++ b/packages/server/utils/analytics/analytics.ts @@ -19,6 +19,7 @@ import {createMeetingProperties} from './helpers' import {SlackNotificationEventEnum} from '../../database/types/SlackNotification' import TemplateScale from '../../database/types/TemplateScale' import {DataLoaderWorker} from '../../graphql/graphql' +import {ModifyType} from '../../graphql/public/resolverTypes' export type AnalyticsUser = { id: string @@ -168,6 +169,7 @@ export type AnalyticsEvent = | 'Conversion Modal Pay Later Clicked' // kudos | 'Kudos Sent' + | 'Icebreaker Modified' // Deprecated Events // These will be replaced with tracking plan compliant versions by the data team // Lowercase words are for backwards compatibility @@ -722,6 +724,20 @@ class Analytics { }) } + icebreakerModified = ( + user: AnalyticsUser, + meetingId: string, + modifyType: ModifyType, + success: boolean + ) => { + this.track(user, 'Icebreaker Modified', { + userId: user.id, + meetingId, + modifyType, + success + }) + } + identify = (options: IdentifyOptions) => { this.amplitudeAnalytics.identify(options) } diff --git a/yarn.lock b/yarn.lock index f14a5aacd2a..46e3970e270 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7261,7 +7261,7 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-compose-refs" "1.0.0" -"@radix-ui/react-slot@1.0.2": +"@radix-ui/react-slot@1.0.2", "@radix-ui/react-slot@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab" integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==