suggestion.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  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 isSentryEmployee = useIsSentryEmployee();
  102. const {
  103. data,
  104. isLoading: dataIsLoading,
  105. isError: dataIsError,
  106. refetch: dataRefetch,
  107. error,
  108. } = useApiQuery<{suggestion: string}>(
  109. [
  110. `/projects/${organization.slug}/${projectSlug}/events/${event.eventID}/ai-fix-suggest/`,
  111. {
  112. query: {
  113. consent: suggestedSolutionLocalConfig.individualConsent ? 'yes' : undefined,
  114. pii_certified: isSentryEmployee ? (piiCertified ? 'yes' : 'no') : undefined,
  115. },
  116. },
  117. ],
  118. {
  119. enabled: isSentryEmployee ? (piiCertified ? true : false) : true,
  120. staleTime: Infinity,
  121. retry: false,
  122. }
  123. );
  124. const handleFeedbackClick = useCallback(() => {
  125. addSuccessMessage('Thank you for your feedback!');
  126. }, []);
  127. if (isSentryEmployee && !piiCertified) {
  128. return (
  129. <EmptyMessage
  130. icon={<IconFlag size="xl" />}
  131. title={t('PII Certification Required')}
  132. description={t(
  133. 'Before using this feature, please confirm that there is no personally identifiable information in this event.'
  134. )}
  135. action={
  136. <ButtonBar gap={2}>
  137. <Button priority="primary" onClick={() => setPiiCertified(true)}>
  138. {t('Certify No PII')}
  139. </Button>
  140. </ButtonBar>
  141. }
  142. />
  143. );
  144. }
  145. return (
  146. <Panel>
  147. <Header>
  148. <Title>
  149. {t('AI Solution')}
  150. <ExperimentalFeatureBadge />
  151. </Title>
  152. <Button size="xs" onClick={onHideSuggestion}>
  153. {t('Hide Suggestion')}
  154. </Button>
  155. </Header>
  156. <PanelBody>
  157. {dataIsLoading ? (
  158. <LoaderWrapper>
  159. <div className="ai-suggestion-wheel-of-fortune" />
  160. <SuggestionLoaderMessage />
  161. </LoaderWrapper>
  162. ) : dataIsError ? (
  163. <ErrorDescription
  164. onRefetch={dataRefetch}
  165. organizationSlug={organization.slug}
  166. onSetIndividualConsent={() =>
  167. setSuggestedSolutionLocalConfig({individualConsent: true})
  168. }
  169. restriction={error?.responseJSON?.restriction as any}
  170. onHideSuggestion={onHideSuggestion}
  171. />
  172. ) : (
  173. <Content
  174. dangerouslySetInnerHTML={{
  175. __html: limitedMarked(data.suggestion, {
  176. gfm: true,
  177. breaks: true,
  178. }),
  179. }}
  180. />
  181. )}
  182. </PanelBody>
  183. {!dataIsLoading && !dataIsError && (
  184. <PanelFooter>
  185. <Feedback>
  186. <strong>{t('Was this helpful?')}</strong>
  187. <ButtonBar gap={1}>
  188. <Button
  189. icon={<IconSad color="red300" />}
  190. size="xs"
  191. onClick={() => {
  192. trackAnalytics(
  193. 'ai_suggested_solution.feedback_helpful_nope_button_clicked',
  194. {
  195. organization,
  196. project_id: event.projectID,
  197. group_id: event.groupID,
  198. ...getAnalyticsDataForEvent(event),
  199. }
  200. );
  201. handleFeedbackClick();
  202. }}
  203. >
  204. {t('Nope')}
  205. </Button>
  206. <Button
  207. icon={<IconMeh color="yellow300" />}
  208. size="xs"
  209. onClick={() => {
  210. trackAnalytics(
  211. 'ai_suggested_solution.feedback_helpful_kinda_button_clicked',
  212. {
  213. organization,
  214. project_id: event.projectID,
  215. group_id: event.groupID,
  216. ...getAnalyticsDataForEvent(event),
  217. }
  218. );
  219. handleFeedbackClick();
  220. }}
  221. >
  222. {t('Kinda')}
  223. </Button>
  224. <Button
  225. icon={<IconHappy color="green300" />}
  226. size="xs"
  227. onClick={() => {
  228. trackAnalytics(
  229. 'ai_suggested_solution.feedback_helpful_yes_button_clicked',
  230. {
  231. organization,
  232. project_id: event.projectID,
  233. group_id: event.groupID,
  234. ...getAnalyticsDataForEvent(event),
  235. }
  236. );
  237. handleFeedbackClick();
  238. }}
  239. >
  240. {t('Yes, Surprisingly\u2026')}
  241. </Button>
  242. </ButtonBar>
  243. </Feedback>
  244. </PanelFooter>
  245. )}
  246. </Panel>
  247. );
  248. }
  249. const Header = styled(PanelHeader)`
  250. background: transparent;
  251. padding: ${space(1)} ${space(2)};
  252. align-items: center;
  253. color: ${p => p.theme.gray300};
  254. `;
  255. const Feedback = styled('div')`
  256. padding: ${space(1)} ${space(2)};
  257. display: grid;
  258. grid-template-columns: 1fr;
  259. align-items: center;
  260. text-align: left;
  261. gap: ${space(1)};
  262. font-size: ${p => p.theme.fontSizeSmall};
  263. @media (min-width: ${p => p.theme.breakpoints.small}) {
  264. grid-template-columns: 1fr max-content;
  265. text-align: right;
  266. gap: ${space(2)};
  267. }
  268. `;
  269. const SuggestionLoadingError = styled(LoadingError)`
  270. margin-bottom: 0;
  271. border: none;
  272. /* This is just to be consitent with other */
  273. /* padding-top and padding-bottom we are using in the empty state component */
  274. padding-top: ${space(4)};
  275. padding-bottom: ${space(4)};
  276. `;
  277. const LoaderWrapper = styled('div')`
  278. padding: ${space(4)} 0;
  279. text-align: center;
  280. gap: ${space(2)};
  281. display: flex;
  282. flex-direction: column;
  283. `;
  284. const Content = styled('div')`
  285. padding: ${space(2)};
  286. /* hack until we update backend to send us other heading */
  287. h4 {
  288. font-size: ${p => p.theme.fontSizeExtraLarge};
  289. margin-bottom: ${space(1)};
  290. }
  291. `;
  292. const Title = styled('div')`
  293. /* to be consistent with the feature badge size */
  294. height: ${space(2)};
  295. line-height: ${space(2)};
  296. display: flex;
  297. align-items: center;
  298. `;