suggestion.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import {useCallback, 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 LoadingError from 'sentry/components/loadingError';
  8. import Panel from 'sentry/components/panels/panel';
  9. import PanelBody from 'sentry/components/panels/panelBody';
  10. import PanelFooter from 'sentry/components/panels/panelFooter';
  11. import PanelHeader from 'sentry/components/panels/panelHeader';
  12. import {IconFile, IconFlag, IconHappy, IconMeh, IconSad} from 'sentry/icons';
  13. import {t} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import type {Event} from 'sentry/types/event';
  16. import type {Project} from 'sentry/types/project';
  17. import {trackAnalytics} from 'sentry/utils/analytics';
  18. import {getAnalyticsDataForEvent} from 'sentry/utils/events';
  19. import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
  20. import {limitedMarked} from 'sentry/utils/marked';
  21. import {useApiQuery} from 'sentry/utils/queryClient';
  22. import {useIsSentryEmployee} from 'sentry/utils/useIsSentryEmployee';
  23. import useOrganization from 'sentry/utils/useOrganization';
  24. import {ExperimentalFeatureBadge} from './experimentalFeatureBadge';
  25. import {SuggestionLoaderMessage} from './suggestionLoaderMessage';
  26. import {useOpenAISuggestionLocalStorage} from './useOpenAISuggestionLocalStorage';
  27. type Props = {
  28. event: Event;
  29. onHideSuggestion: () => void;
  30. projectSlug: Project['slug'];
  31. };
  32. function ErrorDescription({
  33. restriction,
  34. organizationSlug,
  35. onRefetch,
  36. onSetIndividualConsent,
  37. onHideSuggestion,
  38. }: {
  39. onHideSuggestion: () => void;
  40. onRefetch: () => void;
  41. onSetIndividualConsent: (consent: boolean) => void;
  42. organizationSlug: string;
  43. restriction?: 'subprocessor' | 'individual_consent';
  44. }) {
  45. if (restriction === 'subprocessor') {
  46. return (
  47. <EmptyMessage
  48. icon={<IconFile size="xl" />}
  49. title={t('OpenAI Subprocessor Acknowledgment')}
  50. description={t(
  51. 'In order to use this feature, your organization needs to accept the OpenAI Subprocessor Acknowledgment.'
  52. )}
  53. action={
  54. <ButtonBar gap={2}>
  55. <Button onClick={onHideSuggestion}>{t('Dismiss')}</Button>
  56. <Button priority="primary" to={`/settings/${organizationSlug}/legal/`}>
  57. {t('Accept in Settings')}
  58. </Button>
  59. </ButtonBar>
  60. }
  61. />
  62. );
  63. }
  64. if (restriction === 'individual_consent') {
  65. const activeSuperUser = isActiveSuperuser();
  66. return (
  67. <EmptyMessage
  68. icon={<IconFlag size="xl" />}
  69. title={t('We need your consent')}
  70. description={t(
  71. '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.'
  72. )}
  73. action={
  74. <ButtonBar gap={2}>
  75. <Button onClick={onHideSuggestion}>{t('Dismiss')}</Button>
  76. <Button
  77. priority="primary"
  78. onClick={() => {
  79. onSetIndividualConsent(true);
  80. onRefetch();
  81. }}
  82. disabled={activeSuperUser}
  83. title={
  84. activeSuperUser ? t("Superusers can't consent to policies") : undefined
  85. }
  86. >
  87. {t('Confirm')}
  88. </Button>
  89. </ButtonBar>
  90. }
  91. />
  92. );
  93. }
  94. return <SuggestionLoadingError onRetry={onRefetch} />;
  95. }
  96. export function Suggestion({onHideSuggestion, projectSlug, event}: Props) {
  97. const organization = useOrganization();
  98. const [suggestedSolutionLocalConfig, setSuggestedSolutionLocalConfig] =
  99. useOpenAISuggestionLocalStorage();
  100. const [piiCertified, setPiiCertified] = useState(false);
  101. const [feedbackProvided, setFeedbackProvided] = useState(false);
  102. const isSentryEmployee = useIsSentryEmployee();
  103. const {
  104. data,
  105. isLoading: dataIsLoading,
  106. isError: dataIsError,
  107. refetch: dataRefetch,
  108. error,
  109. } = useApiQuery<{suggestion: string}>(
  110. [
  111. `/projects/${organization.slug}/${projectSlug}/events/${event.eventID}/ai-fix-suggest/`,
  112. {
  113. query: {
  114. consent: suggestedSolutionLocalConfig.individualConsent ? 'yes' : undefined,
  115. pii_certified: isSentryEmployee ? (piiCertified ? 'yes' : 'no') : undefined,
  116. },
  117. },
  118. ],
  119. {
  120. enabled: isSentryEmployee ? (piiCertified ? true : false) : true,
  121. staleTime: Infinity,
  122. retry: false,
  123. }
  124. );
  125. const handleFeedbackClick = useCallback(() => {
  126. addSuccessMessage('Thank you for your feedback!');
  127. setFeedbackProvided(true);
  128. }, []);
  129. if (isSentryEmployee && !piiCertified) {
  130. return (
  131. <EmptyMessage
  132. icon={<IconFlag size="xl" />}
  133. title={t('PII Certification Required')}
  134. description={t(
  135. 'Before using this feature, please confirm that there is no personally identifiable information in this event.'
  136. )}
  137. action={
  138. <ButtonBar gap={2}>
  139. <Button priority="primary" onClick={() => setPiiCertified(true)}>
  140. {t('Certify No PII')}
  141. </Button>
  142. </ButtonBar>
  143. }
  144. />
  145. );
  146. }
  147. return (
  148. <Panel>
  149. <Header>
  150. <Title>
  151. {t('AI Solution')}
  152. <ExperimentalFeatureBadge />
  153. </Title>
  154. <Button size="xs" onClick={onHideSuggestion}>
  155. {t('Hide Suggestion')}
  156. </Button>
  157. </Header>
  158. <PanelBody>
  159. {dataIsLoading ? (
  160. <LoaderWrapper>
  161. <div className="ai-suggestion-wheel-of-fortune" />
  162. <SuggestionLoaderMessage />
  163. </LoaderWrapper>
  164. ) : dataIsError ? (
  165. <ErrorDescription
  166. onRefetch={dataRefetch}
  167. organizationSlug={organization.slug}
  168. onSetIndividualConsent={() =>
  169. setSuggestedSolutionLocalConfig({individualConsent: true})
  170. }
  171. restriction={error?.responseJSON?.restriction as any}
  172. onHideSuggestion={onHideSuggestion}
  173. />
  174. ) : (
  175. <Content
  176. dangerouslySetInnerHTML={{
  177. __html: limitedMarked(data.suggestion, {
  178. gfm: true,
  179. breaks: true,
  180. }),
  181. }}
  182. />
  183. )}
  184. </PanelBody>
  185. {!dataIsLoading && !dataIsError && !feedbackProvided && (
  186. <PanelFooter>
  187. <Feedback>
  188. <strong>{t('Was this helpful?')}</strong>
  189. <ButtonBar gap={1}>
  190. <Button
  191. icon={<IconSad color="red300" />}
  192. size="xs"
  193. onClick={() => {
  194. trackAnalytics(
  195. 'ai_suggested_solution.feedback_helpful_nope_button_clicked',
  196. {
  197. organization,
  198. project_id: event.projectID,
  199. group_id: event.groupID,
  200. ...getAnalyticsDataForEvent(event),
  201. }
  202. );
  203. handleFeedbackClick();
  204. }}
  205. >
  206. {t('Nope')}
  207. </Button>
  208. <Button
  209. icon={<IconMeh color="yellow300" />}
  210. size="xs"
  211. onClick={() => {
  212. trackAnalytics(
  213. 'ai_suggested_solution.feedback_helpful_kinda_button_clicked',
  214. {
  215. organization,
  216. project_id: event.projectID,
  217. group_id: event.groupID,
  218. ...getAnalyticsDataForEvent(event),
  219. }
  220. );
  221. handleFeedbackClick();
  222. }}
  223. >
  224. {t('Kinda')}
  225. </Button>
  226. <Button
  227. icon={<IconHappy color="green300" />}
  228. size="xs"
  229. onClick={() => {
  230. trackAnalytics(
  231. 'ai_suggested_solution.feedback_helpful_yes_button_clicked',
  232. {
  233. organization,
  234. project_id: event.projectID,
  235. group_id: event.groupID,
  236. ...getAnalyticsDataForEvent(event),
  237. }
  238. );
  239. handleFeedbackClick();
  240. }}
  241. >
  242. {t('Yes, Surprisingly\u2026')}
  243. </Button>
  244. </ButtonBar>
  245. </Feedback>
  246. </PanelFooter>
  247. )}
  248. </Panel>
  249. );
  250. }
  251. const Header = styled(PanelHeader)`
  252. background: transparent;
  253. padding: ${space(1)} ${space(2)};
  254. align-items: center;
  255. color: ${p => p.theme.gray300};
  256. `;
  257. const Feedback = styled('div')`
  258. padding: ${space(1)} ${space(2)};
  259. display: grid;
  260. grid-template-columns: 1fr;
  261. align-items: center;
  262. text-align: left;
  263. gap: ${space(1)};
  264. font-size: ${p => p.theme.fontSizeSmall};
  265. @media (min-width: ${p => p.theme.breakpoints.small}) {
  266. grid-template-columns: 1fr max-content;
  267. text-align: right;
  268. gap: ${space(2)};
  269. }
  270. `;
  271. const SuggestionLoadingError = styled(LoadingError)`
  272. margin-bottom: 0;
  273. border: none;
  274. /* This is just to be consitent with other */
  275. /* padding-top and padding-bottom we are using in the empty state component */
  276. padding-top: ${space(4)};
  277. padding-bottom: ${space(4)};
  278. `;
  279. const LoaderWrapper = styled('div')`
  280. padding: ${space(4)} 0;
  281. text-align: center;
  282. gap: ${space(2)};
  283. display: flex;
  284. flex-direction: column;
  285. `;
  286. const Content = styled('div')`
  287. padding: ${space(2)};
  288. /* hack until we update backend to send us other heading */
  289. h4 {
  290. font-size: ${p => p.theme.fontSizeExtraLarge};
  291. margin-bottom: ${space(1)};
  292. }
  293. `;
  294. const Title = styled('div')`
  295. /* to be consistent with the feature badge size */
  296. height: ${space(2)};
  297. line-height: ${space(2)};
  298. display: flex;
  299. align-items: center;
  300. `;