openAIFixSuggestionPanel.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import {Fragment, useCallback, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addSuccessMessage} from 'sentry/actionCreators/indicator';
  4. import {Button} from 'sentry/components/button';
  5. import ButtonBar from 'sentry/components/buttonBar';
  6. import EmptyMessage from 'sentry/components/emptyMessage';
  7. import FeatureBadge from 'sentry/components/featureBadge';
  8. import {feedbackClient} from 'sentry/components/featureFeedback/feedbackModal';
  9. import LoadingError from 'sentry/components/loadingError';
  10. import {Panel, PanelBody, PanelFooter, PanelHeader} from 'sentry/components/panels';
  11. import {IconFile, IconFlag, IconHappy, IconMeh, IconSad} from 'sentry/icons';
  12. import {IconChevron} from 'sentry/icons/iconChevron';
  13. import {t} from 'sentry/locale';
  14. import ConfigStore from 'sentry/stores/configStore';
  15. import {space} from 'sentry/styles/space';
  16. import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
  17. import marked from 'sentry/utils/marked';
  18. import {useApiQuery} from 'sentry/utils/queryClient';
  19. import useOrganization from 'sentry/utils/useOrganization';
  20. import useRouter from 'sentry/utils/useRouter';
  21. import {AiLoaderMessage} from 'sentry/views/issueDetails/openAIFixSuggestion/aiLoaderMessage';
  22. import {useOpenAISuggestionLocalStorage} from 'sentry/views/issueDetails/openAIFixSuggestion/useOpenAISuggestionLocalStorage';
  23. import {experimentalFeatureTooltipDesc} from 'sentry/views/issueDetails/openAIFixSuggestion/utils';
  24. enum OpenAISatisfactoryLevel {
  25. HELPED = 'helped',
  26. PARTIALLY_HELPED = 'partially_helped',
  27. DID_NOT_HELP = 'did_not_help',
  28. }
  29. const openAIFeedback = {
  30. [OpenAISatisfactoryLevel.HELPED]: t('It helped me solve the issue'),
  31. [OpenAISatisfactoryLevel.PARTIALLY_HELPED]: t('It partially helped me solve the issue'),
  32. [OpenAISatisfactoryLevel.DID_NOT_HELP]: t('It did not help me solve the issue'),
  33. };
  34. type Props = {
  35. eventID: string;
  36. projectSlug: string;
  37. };
  38. export function OpenAIFixSuggestionPanel({eventID, projectSlug}: Props) {
  39. const user = ConfigStore.get('user');
  40. const organization = useOrganization();
  41. const router = useRouter();
  42. const showSuggestedFix = router.location.query.showSuggestedFix === 'true';
  43. const [individualConsent, setIndividualConsent] = useOpenAISuggestionLocalStorage();
  44. const [expandedSuggestedFix, setExpandedSuggestedFix] = useState(showSuggestedFix);
  45. useEffect(() => {
  46. setExpandedSuggestedFix(!!router.location.query.showSuggestedFix);
  47. }, [router.location.query.showSuggestedFix]);
  48. const handleOpenAISuggestionFeedback = useCallback(
  49. (openAISatisfactoryLevel: OpenAISatisfactoryLevel) => {
  50. feedbackClient.captureEvent({
  51. request: {
  52. url: window.location.href, // gives the full url (origin + pathname)
  53. },
  54. tags: {
  55. featureName: 'open-ai-suggestion',
  56. },
  57. user,
  58. level: 'info',
  59. message: `OpenAI Suggestion Feedback - ${openAIFeedback[openAISatisfactoryLevel]}`,
  60. });
  61. addSuccessMessage('Thank you for your feedback!');
  62. setExpandedSuggestedFix(false);
  63. },
  64. [user]
  65. );
  66. const {
  67. data,
  68. isLoading: dataIsLoading,
  69. isError: dataIsError,
  70. refetch: dataRefetch,
  71. error,
  72. } = useApiQuery<{suggestion: string}>(
  73. [
  74. `/projects/${organization.slug}/${projectSlug}/events/${eventID}/ai-fix-suggest/`,
  75. {query: {consent: individualConsent ? 'yes' : undefined}},
  76. ],
  77. {
  78. staleTime: Infinity,
  79. retry: false,
  80. enabled: showSuggestedFix,
  81. }
  82. );
  83. let PolicyErrorState;
  84. if (error?.responseJSON?.restriction === 'subprocessor') {
  85. PolicyErrorState = (
  86. <EmptyMessage
  87. icon={<IconFile size="xl" />}
  88. title={t('OpenAI Subprocessor Acknowledgment')}
  89. description={t(
  90. 'In order to use this feature, your organization needs to accept the OpenAI Subprocessor Acknowledgment.'
  91. )}
  92. action={
  93. <ButtonBar gap={2}>
  94. <Button
  95. to={{
  96. pathname: router.location.pathname,
  97. query: {...router.location.query, showSuggestedFix: undefined},
  98. }}
  99. >
  100. {t('Dismiss')}
  101. </Button>
  102. <Button priority="primary" to={`/settings/${organization.slug}/legal/`}>
  103. {t('Accept in Settings')}
  104. </Button>
  105. </ButtonBar>
  106. }
  107. />
  108. );
  109. }
  110. if (error?.responseJSON?.restriction === 'individual_consent') {
  111. const activeSuperUser = isActiveSuperuser();
  112. PolicyErrorState = (
  113. <EmptyMessage
  114. icon={<IconFlag size="xl" />}
  115. title={t('We need your consent')}
  116. description={t(
  117. 'By using this feature, you agree that OpenAI is a subprocessor and may process the data that you’ve chosen to submit. Sentry makes no guarantees as to the accuracy of the feature’s AI-generated recommendations.'
  118. )}
  119. action={
  120. <ButtonBar gap={2}>
  121. <Button
  122. to={{
  123. pathname: router.location.pathname,
  124. query: {...router.location.query, showSuggestedFix: undefined},
  125. }}
  126. >
  127. {t('Dismiss')}
  128. </Button>
  129. <Button
  130. priority="primary"
  131. onClick={() => {
  132. setIndividualConsent(true);
  133. dataRefetch();
  134. }}
  135. disabled={activeSuperUser}
  136. title={
  137. activeSuperUser ? t("Superusers can't consent to policies") : undefined
  138. }
  139. >
  140. {t('Confirm')}
  141. </Button>
  142. </ButtonBar>
  143. }
  144. />
  145. );
  146. }
  147. if (!organization.features.includes('open-ai-suggestion')) {
  148. return null;
  149. }
  150. if (!showSuggestedFix) {
  151. return null;
  152. }
  153. return (
  154. <FixSuggestionPanel>
  155. <FixSuggestionPanelHeader isExpanded={expandedSuggestedFix}>
  156. <HeaderDescription>
  157. {t('Suggested Fix')}
  158. <FeatureBadgeNotUppercase
  159. type="experimental"
  160. title={experimentalFeatureTooltipDesc}
  161. />
  162. </HeaderDescription>
  163. <Button
  164. size="xs"
  165. title={t('Toggle Suggested Fix Panel')}
  166. aria-label={t('Toggle Suggested Fix Panel')}
  167. icon={<IconChevron direction={expandedSuggestedFix ? 'up' : 'down'} />}
  168. onClick={() => setExpandedSuggestedFix(!expandedSuggestedFix)}
  169. />
  170. </FixSuggestionPanelHeader>
  171. {expandedSuggestedFix && (
  172. <Fragment>
  173. <StyledPanelBody withPadding whiteBg={dataIsLoading}>
  174. {dataIsLoading ? (
  175. <AiLoaderWrapper>
  176. <div className="ai-loader" />
  177. <AiLoaderMessage />
  178. </AiLoaderWrapper>
  179. ) : dataIsError ? (
  180. PolicyErrorState ? (
  181. PolicyErrorState
  182. ) : (
  183. <LoadingErrorWithoutMarginBottom onRetry={dataRefetch} />
  184. )
  185. ) : (
  186. <div
  187. dangerouslySetInnerHTML={{
  188. __html: marked(data.suggestion, {
  189. gfm: true,
  190. breaks: true,
  191. }),
  192. }}
  193. />
  194. )}
  195. </StyledPanelBody>
  196. {!dataIsLoading && !dataIsError && (
  197. <PanelFooter>
  198. <Feedback>
  199. <strong>{t('Was this helpful?')}</strong>
  200. <div>
  201. <Button
  202. title={openAIFeedback[OpenAISatisfactoryLevel.DID_NOT_HELP]}
  203. aria-label={openAIFeedback[OpenAISatisfactoryLevel.DID_NOT_HELP]}
  204. icon={<IconSad color="red300" />}
  205. size="xs"
  206. borderless
  207. onClick={() =>
  208. handleOpenAISuggestionFeedback(OpenAISatisfactoryLevel.DID_NOT_HELP)
  209. }
  210. />
  211. <Button
  212. title={openAIFeedback[OpenAISatisfactoryLevel.PARTIALLY_HELPED]}
  213. aria-label={openAIFeedback[OpenAISatisfactoryLevel.PARTIALLY_HELPED]}
  214. icon={<IconMeh color="yellow300" />}
  215. size="xs"
  216. borderless
  217. onClick={() =>
  218. handleOpenAISuggestionFeedback(
  219. OpenAISatisfactoryLevel.PARTIALLY_HELPED
  220. )
  221. }
  222. />
  223. <Button
  224. title={openAIFeedback[OpenAISatisfactoryLevel.HELPED]}
  225. aria-label={openAIFeedback[OpenAISatisfactoryLevel.HELPED]}
  226. icon={<IconHappy color="green300" />}
  227. size="xs"
  228. borderless
  229. onClick={() =>
  230. handleOpenAISuggestionFeedback(OpenAISatisfactoryLevel.HELPED)
  231. }
  232. />
  233. </div>
  234. </Feedback>
  235. </PanelFooter>
  236. )}
  237. </Fragment>
  238. )}
  239. </FixSuggestionPanel>
  240. );
  241. }
  242. const FixSuggestionPanel = styled(Panel)`
  243. margin-top: ${space(1.5)};
  244. margin-bottom: ${space(1.5)};
  245. overflow: hidden;
  246. `;
  247. const StyledPanelBody = styled(PanelBody)<{whiteBg: boolean}>`
  248. background: ${p => (p.whiteBg ? 'white' : undefined)};
  249. min-height: 268px;
  250. `;
  251. const FixSuggestionPanelHeader = styled(PanelHeader)<{isExpanded: boolean}>`
  252. border-bottom: ${p => (p.isExpanded ? 'inherit' : 'none')};
  253. `;
  254. const HeaderDescription = styled('div')`
  255. display: flex;
  256. align-items: center;
  257. `;
  258. const Feedback = styled('div')`
  259. padding: ${space(1)} ${space(2)};
  260. display: grid;
  261. grid-template-columns: 1fr max-content max-content max-content;
  262. align-items: center;
  263. text-align: right;
  264. gap: ${space(1)};
  265. `;
  266. const LoadingErrorWithoutMarginBottom = styled(LoadingError)`
  267. margin-bottom: 0;
  268. `;
  269. const FeatureBadgeNotUppercase = styled(FeatureBadge)`
  270. text-transform: capitalize;
  271. `;
  272. const AiLoaderWrapper = styled('div')`
  273. text-align: center;
  274. padding-bottom: ${space(1.5)};
  275. `;