suggestion.tsx 8.8 KB

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