suggestion.tsx 9.6 KB

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