|
@@ -0,0 +1,321 @@
|
|
|
+import {useCallback} from 'react';
|
|
|
+import {InjectedRouter} from 'react-router';
|
|
|
+import {css} from '@emotion/react';
|
|
|
+import styled from '@emotion/styled';
|
|
|
+
|
|
|
+import {addSuccessMessage} from 'sentry/actionCreators/indicator';
|
|
|
+import {Button} from 'sentry/components/button';
|
|
|
+import ButtonBar from 'sentry/components/buttonBar';
|
|
|
+import EmptyMessage from 'sentry/components/emptyMessage';
|
|
|
+import LoadingError from 'sentry/components/loadingError';
|
|
|
+import {Panel, PanelBody, PanelFooter, PanelHeader} from 'sentry/components/panels';
|
|
|
+import {IconFile, IconFlag, IconHappy, IconMeh, IconSad} from 'sentry/icons';
|
|
|
+import {t} from 'sentry/locale';
|
|
|
+import {space} from 'sentry/styles/space';
|
|
|
+import {Event, Project} from 'sentry/types';
|
|
|
+import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
|
|
|
+import {getAnalyticsDataForEvent} from 'sentry/utils/events';
|
|
|
+import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
|
|
|
+import marked from 'sentry/utils/marked';
|
|
|
+import {useApiQuery} from 'sentry/utils/queryClient';
|
|
|
+import useOrganization from 'sentry/utils/useOrganization';
|
|
|
+import useRouter from 'sentry/utils/useRouter';
|
|
|
+
|
|
|
+import {ExperimentalFeatureBadge} from './experimentalFeatureBadge';
|
|
|
+import suggestionWheelOfFortuneDarkMode from './suggestion-wheel-of-fortune-dark-mode.mp4';
|
|
|
+import suggestionWheelOfFortuneLightMode from './suggestion-wheel-of-fortune-light-mode.mp4';
|
|
|
+import {SuggestionLoaderMessage} from './suggestionLoaderMessage';
|
|
|
+import {useOpenAISuggestionLocalStorage} from './useOpenAISuggestionLocalStorage';
|
|
|
+
|
|
|
+type Props = {
|
|
|
+ darkMode: boolean;
|
|
|
+ event: Event;
|
|
|
+ onHideSuggestion: () => void;
|
|
|
+ projectSlug: Project['slug'];
|
|
|
+};
|
|
|
+
|
|
|
+function ErrorDescription({
|
|
|
+ restriction,
|
|
|
+ organizationSlug,
|
|
|
+ router,
|
|
|
+ onRefetch,
|
|
|
+ onSetIndividualConsent,
|
|
|
+}: {
|
|
|
+ onRefetch: () => void;
|
|
|
+ onSetIndividualConsent: (consent: boolean) => void;
|
|
|
+ organizationSlug: string;
|
|
|
+ router: InjectedRouter;
|
|
|
+ restriction?: 'subprocessor' | 'individual_consent';
|
|
|
+}) {
|
|
|
+ if (restriction === 'subprocessor') {
|
|
|
+ return (
|
|
|
+ <EmptyState
|
|
|
+ icon={<IconFile size="xl" />}
|
|
|
+ title={t('OpenAI Subprocessor Acknowledgment')}
|
|
|
+ description={t(
|
|
|
+ 'In order to use this feature, your organization needs to accept the OpenAI Subprocessor Acknowledgment.'
|
|
|
+ )}
|
|
|
+ action={
|
|
|
+ <ButtonBar gap={2}>
|
|
|
+ <Button
|
|
|
+ to={{
|
|
|
+ pathname: router.location.pathname,
|
|
|
+ query: {...router.location.query, showSuggestedFix: undefined},
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {t('Dismiss')}
|
|
|
+ </Button>
|
|
|
+ <Button priority="primary" to={`/settings/${organizationSlug}/legal/`}>
|
|
|
+ {t('Accept in Settings')}
|
|
|
+ </Button>
|
|
|
+ </ButtonBar>
|
|
|
+ }
|
|
|
+ />
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (restriction === 'individual_consent') {
|
|
|
+ const activeSuperUser = isActiveSuperuser();
|
|
|
+ return (
|
|
|
+ <EmptyState
|
|
|
+ icon={<IconFlag size="xl" />}
|
|
|
+ title={t('We need your consent')}
|
|
|
+ description={t(
|
|
|
+ '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.'
|
|
|
+ )}
|
|
|
+ action={
|
|
|
+ <ButtonBar gap={2}>
|
|
|
+ <Button
|
|
|
+ to={{
|
|
|
+ pathname: router.location.pathname,
|
|
|
+ query: {...router.location.query, showSuggestedFix: undefined},
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {t('Dismiss')}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ priority="primary"
|
|
|
+ onClick={() => {
|
|
|
+ onSetIndividualConsent(true);
|
|
|
+ onRefetch();
|
|
|
+ }}
|
|
|
+ disabled={activeSuperUser}
|
|
|
+ title={
|
|
|
+ activeSuperUser ? t("Superusers can't consent to policies") : undefined
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {t('Confirm')}
|
|
|
+ </Button>
|
|
|
+ </ButtonBar>
|
|
|
+ }
|
|
|
+ />
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return <SuggestionLoadingError onRetry={onRefetch} />;
|
|
|
+}
|
|
|
+
|
|
|
+export function Suggestion({onHideSuggestion, projectSlug, event, darkMode}: Props) {
|
|
|
+ const organization = useOrganization();
|
|
|
+ const router = useRouter();
|
|
|
+ const [individualConsent, setIndividualConsent] = useOpenAISuggestionLocalStorage();
|
|
|
+
|
|
|
+ const {
|
|
|
+ data,
|
|
|
+ isLoading: dataIsLoading,
|
|
|
+ isError: dataIsError,
|
|
|
+ refetch: dataRefetch,
|
|
|
+ error,
|
|
|
+ } = useApiQuery<{suggestion: string}>(
|
|
|
+ [
|
|
|
+ `/projects/${organization.slug}/${projectSlug}/events/${event.eventID}/ai-fix-suggest/`,
|
|
|
+ {query: {consent: individualConsent ? 'yes' : undefined}},
|
|
|
+ ],
|
|
|
+ {
|
|
|
+ staleTime: Infinity,
|
|
|
+ retry: false,
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ const handleFeedbackClick = useCallback(() => {
|
|
|
+ addSuccessMessage('Thank you for your feedback!');
|
|
|
+ onHideSuggestion();
|
|
|
+ }, [onHideSuggestion]);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Wrapper darkMode={darkMode}>
|
|
|
+ <Header>
|
|
|
+ <div>
|
|
|
+ {t('AI Solution')}
|
|
|
+ <ExperimentalFeatureBadge />
|
|
|
+ </div>
|
|
|
+ <Button size="xs" onClick={onHideSuggestion}>
|
|
|
+ {t('Hide Suggestion')}
|
|
|
+ </Button>
|
|
|
+ </Header>
|
|
|
+ <PanelBody>
|
|
|
+ {dataIsLoading ? (
|
|
|
+ <LoaderWrapper>
|
|
|
+ <video height="309px" autoPlay loop muted>
|
|
|
+ <source
|
|
|
+ src={
|
|
|
+ darkMode
|
|
|
+ ? suggestionWheelOfFortuneDarkMode
|
|
|
+ : suggestionWheelOfFortuneLightMode
|
|
|
+ }
|
|
|
+ type="video/mp4"
|
|
|
+ />
|
|
|
+ {t('Your browser does not support the video tag.')}
|
|
|
+ </video>
|
|
|
+ <SuggestionLoaderMessage />
|
|
|
+ </LoaderWrapper>
|
|
|
+ ) : dataIsError ? (
|
|
|
+ <ErrorDescription
|
|
|
+ onRefetch={dataRefetch}
|
|
|
+ organizationSlug={organization.slug}
|
|
|
+ router={router}
|
|
|
+ onSetIndividualConsent={setIndividualConsent}
|
|
|
+ restriction={error?.responseJSON?.restriction}
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <Content
|
|
|
+ dangerouslySetInnerHTML={{
|
|
|
+ __html: marked(data.suggestion, {
|
|
|
+ gfm: true,
|
|
|
+ breaks: true,
|
|
|
+ }),
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </PanelBody>
|
|
|
+ <PanelFooter>
|
|
|
+ <Feedback>
|
|
|
+ <strong>{t('Was this helpful?')}</strong>
|
|
|
+ <ButtonBar gap={1}>
|
|
|
+ <Button
|
|
|
+ icon={<IconSad color="red300" size="xs" />}
|
|
|
+ size="xs"
|
|
|
+ onClick={() => {
|
|
|
+ trackAdvancedAnalyticsEvent(
|
|
|
+ 'ai_suggested_solution.feedback_helpful_nope_button_clicked',
|
|
|
+ {
|
|
|
+ organization,
|
|
|
+ project_id: event.projectID,
|
|
|
+ group_id: event.groupID,
|
|
|
+ ...getAnalyticsDataForEvent(event),
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ handleFeedbackClick();
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {t('Nope')}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ icon={<IconMeh color="yellow300" size="xs" />}
|
|
|
+ size="xs"
|
|
|
+ onClick={() => {
|
|
|
+ trackAdvancedAnalyticsEvent(
|
|
|
+ 'ai_suggested_solution.feedback_helpful_kinda_button_clicked',
|
|
|
+ {
|
|
|
+ organization,
|
|
|
+ project_id: event.projectID,
|
|
|
+ group_id: event.groupID,
|
|
|
+ ...getAnalyticsDataForEvent(event),
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ handleFeedbackClick();
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {t('Kinda')}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ icon={<IconHappy color="green300" size="xs" />}
|
|
|
+ size="xs"
|
|
|
+ onClick={() => {
|
|
|
+ trackAdvancedAnalyticsEvent(
|
|
|
+ 'ai_suggested_solution.feedback_helpful_yes_button_clicked',
|
|
|
+ {
|
|
|
+ organization,
|
|
|
+ project_id: event.projectID,
|
|
|
+ group_id: event.groupID,
|
|
|
+ ...getAnalyticsDataForEvent(event),
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ handleFeedbackClick();
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {t('Yes, Surprisingly\u2026')}
|
|
|
+ </Button>
|
|
|
+ </ButtonBar>
|
|
|
+ </Feedback>
|
|
|
+ </PanelFooter>
|
|
|
+ </Wrapper>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+const Wrapper = styled(Panel)<{darkMode: boolean}>`
|
|
|
+ ${p =>
|
|
|
+ p.darkMode &&
|
|
|
+ css`
|
|
|
+ /* hack until we update the background of the loading video */
|
|
|
+ background: #2b2031;
|
|
|
+ `}
|
|
|
+`;
|
|
|
+
|
|
|
+const Header = styled(PanelHeader)`
|
|
|
+ background: transparent;
|
|
|
+ padding: ${space(1)} ${space(2)};
|
|
|
+ align-items: center;
|
|
|
+ color: ${p => p.theme.gray300};
|
|
|
+`;
|
|
|
+
|
|
|
+const Feedback = styled('div')`
|
|
|
+ padding: ${space(1)} ${space(2)};
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 1fr;
|
|
|
+ align-items: center;
|
|
|
+ text-align: left;
|
|
|
+ gap: ${space(1)};
|
|
|
+ font-size: ${p => p.theme.fontSizeSmall};
|
|
|
+ @media (min-width: ${p => p.theme.breakpoints.small}) {
|
|
|
+ grid-template-columns: 1fr max-content;
|
|
|
+ text-align: right;
|
|
|
+ gap: ${space(2)};
|
|
|
+ }
|
|
|
+`;
|
|
|
+
|
|
|
+const SuggestionLoadingError = styled(LoadingError)`
|
|
|
+ margin-bottom: 0;
|
|
|
+ border: none;
|
|
|
+ /* This is just to be consitent with other */
|
|
|
+ /* padding-top and padding-bottom we are using in this panel */
|
|
|
+ padding-top: ${space(2)};
|
|
|
+ padding-bottom: ${space(2)};
|
|
|
+`;
|
|
|
+
|
|
|
+const LoaderWrapper = styled('div')`
|
|
|
+ padding: ${space(4)} 0;
|
|
|
+ text-align: center;
|
|
|
+ gap: ${space(2)};
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+`;
|
|
|
+
|
|
|
+const Content = styled('div')`
|
|
|
+ padding: ${space(2)};
|
|
|
+ /* hack until we update backend to send us other heading */
|
|
|
+ h4 {
|
|
|
+ font-size: ${p => p.theme.fontSizeExtraLarge};
|
|
|
+ margin-bottom: ${space(1)};
|
|
|
+ }
|
|
|
+`;
|
|
|
+
|
|
|
+const EmptyState = styled(EmptyMessage)`
|
|
|
+ /* This is just to be consitent with other */
|
|
|
+ /* padding-top and padding-bottom we are using in this panel */
|
|
|
+ padding-top: ${space(2)};
|
|
|
+ padding-bottom: ${space(2)};
|
|
|
+`;
|