mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
Merge pull request #36140 from overleaf/mg-ai-usage-limit-show
Add 'ai-usage-limit-show' event for ai limits GitOrigin-RevId: 887ecc8b3fea52bcd45cf771ab0d22b8f4539308
This commit is contained in:
@@ -7,14 +7,23 @@ import { useTranslation } from 'react-i18next'
|
||||
import { formatSecondsToHoursAndMinutes } from '@/shared/utils/time'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { AiFeatureLocations } from './types/ai'
|
||||
import { paywallTypeByLocation } from '../utils/ai-features'
|
||||
import {
|
||||
getAiPlanTypeWithoutUpgrade,
|
||||
paywallTypeByLocation,
|
||||
} from '../utils/ai-features'
|
||||
|
||||
function AiPaywallNotification({
|
||||
isActionBelowContent = false,
|
||||
featureLocation,
|
||||
isVisible = true,
|
||||
}: {
|
||||
isActionBelowContent?: boolean
|
||||
featureLocation: AiFeatureLocations
|
||||
/**
|
||||
* Whether the notification is on screen. Prevents analytics firing for
|
||||
* messages on mounted-but-hidden surfaces (e.g. the workbench rail).
|
||||
*/
|
||||
isVisible?: boolean
|
||||
}) {
|
||||
const {
|
||||
hasSuggestionsLeft,
|
||||
@@ -22,6 +31,7 @@ function AiPaywallNotification({
|
||||
tokenResetDate,
|
||||
premiumSuggestionResetDate,
|
||||
} = useEditorContext()
|
||||
const hasAddOn = getMeta('ol-hasUnlimitedAi')
|
||||
|
||||
const user = getMeta('ol-user')
|
||||
|
||||
@@ -32,8 +42,6 @@ function AiPaywallNotification({
|
||||
return null
|
||||
}
|
||||
|
||||
const hasAddOn = getMeta('ol-hasUnlimitedAi')
|
||||
|
||||
// error assist only needs usage quota
|
||||
const canUseErrorAssist = hasSuggestionsLeft
|
||||
if (canUseErrorAssist && featureLocation === 'errorAssist') {
|
||||
@@ -68,6 +76,7 @@ function AiPaywallNotification({
|
||||
<FairUseLimit
|
||||
secondsTillReset={secondsTillReset}
|
||||
featureLocation={featureLocation}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -77,6 +86,7 @@ function AiPaywallNotification({
|
||||
<GroupsPaywall
|
||||
secondsTillReset={secondsTillReset}
|
||||
featureLocation={featureLocation}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -86,6 +96,7 @@ function AiPaywallNotification({
|
||||
<CommonsPaywall
|
||||
secondsTillReset={secondsTillReset}
|
||||
featureLocation={featureLocation}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -94,6 +105,55 @@ function AiPaywallNotification({
|
||||
secondsTillReset={secondsTillReset}
|
||||
isActionBelowContent={isActionBelowContent}
|
||||
featureLocation={featureLocation}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Shared by the variants that offer no way out of the limit. Reports
|
||||
// `ai-usage-limit-show`, the no-CTA counterpart to UpgradePaywall's
|
||||
// `paywall-prompt`, so the two events partition every limit message shown.
|
||||
function AiUsageLimitNotification({
|
||||
title,
|
||||
content,
|
||||
featureLocation,
|
||||
isVisible,
|
||||
}: {
|
||||
title: string
|
||||
content: string
|
||||
featureLocation: AiFeatureLocations
|
||||
isVisible: boolean
|
||||
}) {
|
||||
const { sendEvent } = useEditorAnalytics()
|
||||
const paywallType = paywallTypeByLocation[featureLocation]
|
||||
const hasUnlimitedQuota = getMeta('ol-hasUnlimitedAi')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
const planType = getAiPlanTypeWithoutUpgrade(hasUnlimitedQuota)
|
||||
if (!planType) {
|
||||
return
|
||||
}
|
||||
|
||||
const { planCode } = getMeta('ol-user')
|
||||
sendEvent('ai-usage-limit-show', {
|
||||
'paywall-type': paywallType,
|
||||
'plan-type': planType,
|
||||
'plan-code': planCode,
|
||||
})
|
||||
}, [sendEvent, paywallType, isVisible, hasUnlimitedQuota])
|
||||
|
||||
return (
|
||||
<Notification
|
||||
type="info"
|
||||
title={title}
|
||||
content={content}
|
||||
isDismissible={false}
|
||||
customIcon={null}
|
||||
className="ai-paywall-notification"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -101,41 +161,38 @@ function AiPaywallNotification({
|
||||
function GroupsPaywall({
|
||||
secondsTillReset,
|
||||
featureLocation,
|
||||
isVisible,
|
||||
}: {
|
||||
secondsTillReset: number
|
||||
featureLocation: AiFeatureLocations
|
||||
isVisible: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const message = t('your_limit_will_reset_in_time_or_speak_to_admin', {
|
||||
time: formatSecondsToHoursAndMinutes(t, secondsTillReset),
|
||||
})
|
||||
|
||||
const title =
|
||||
featureLocation === 'workbench'
|
||||
? t('youve_reached_your_daily_ai_limit')
|
||||
: t('youve_hit_your_daily_ai_limit')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Notification
|
||||
type="info"
|
||||
title={title}
|
||||
content={message}
|
||||
isDismissible={false}
|
||||
customIcon={null}
|
||||
className="ai-paywall-notification"
|
||||
/>
|
||||
</>
|
||||
<AiUsageLimitNotification
|
||||
featureLocation={featureLocation}
|
||||
isVisible={isVisible}
|
||||
title={
|
||||
featureLocation === 'workbench'
|
||||
? t('youve_reached_your_daily_ai_limit')
|
||||
: t('youve_hit_your_daily_ai_limit')
|
||||
}
|
||||
content={t('your_limit_will_reset_in_time_or_speak_to_admin', {
|
||||
time: formatSecondsToHoursAndMinutes(t, secondsTillReset),
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommonsPaywall({
|
||||
secondsTillReset,
|
||||
featureLocation,
|
||||
isVisible,
|
||||
}: {
|
||||
secondsTillReset: number
|
||||
featureLocation: AiFeatureLocations
|
||||
isVisible: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -144,62 +201,49 @@ function CommonsPaywall({
|
||||
return null
|
||||
}
|
||||
|
||||
const message = t('this_will_reset_in', {
|
||||
time: formatSecondsToHoursAndMinutes(t, secondsTillReset),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Notification
|
||||
type="info"
|
||||
title={t('youve_reached_your_ai_usage_limit')}
|
||||
content={message}
|
||||
isDismissible={false}
|
||||
customIcon={null}
|
||||
className="ai-paywall-notification"
|
||||
/>
|
||||
</>
|
||||
<AiUsageLimitNotification
|
||||
featureLocation={featureLocation}
|
||||
isVisible={isVisible}
|
||||
title={t('youve_reached_your_ai_usage_limit')}
|
||||
content={t('this_will_reset_in', {
|
||||
time: formatSecondsToHoursAndMinutes(t, secondsTillReset),
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FairUseLimit({
|
||||
secondsTillReset,
|
||||
featureLocation,
|
||||
isVisible,
|
||||
}: {
|
||||
secondsTillReset: number
|
||||
featureLocation: AiFeatureLocations
|
||||
isVisible: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const title =
|
||||
featureLocation === 'workbench'
|
||||
? t('usage_limit_reached')
|
||||
: t('youve_reached_the_fair_usage')
|
||||
|
||||
const workbenchMessage = t(
|
||||
'youve_reached_the_fair_usage_limit_on_your_plan_you_can_start_chatting_again_in_time',
|
||||
{
|
||||
time: formatSecondsToHoursAndMinutes(t, secondsTillReset),
|
||||
}
|
||||
)
|
||||
|
||||
const assistMessage = t('this_will_reset_in', {
|
||||
time: formatSecondsToHoursAndMinutes(t, secondsTillReset),
|
||||
})
|
||||
const message =
|
||||
featureLocation === 'workbench' ? workbenchMessage : assistMessage
|
||||
const isWorkbench = featureLocation === 'workbench'
|
||||
const time = formatSecondsToHoursAndMinutes(t, secondsTillReset)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Notification
|
||||
type="info"
|
||||
title={title}
|
||||
content={message}
|
||||
isDismissible={false}
|
||||
customIcon={null}
|
||||
className="ai-paywall-notification"
|
||||
/>
|
||||
</>
|
||||
<AiUsageLimitNotification
|
||||
featureLocation={featureLocation}
|
||||
isVisible={isVisible}
|
||||
title={
|
||||
isWorkbench
|
||||
? t('usage_limit_reached')
|
||||
: t('youve_reached_the_fair_usage')
|
||||
}
|
||||
content={
|
||||
isWorkbench
|
||||
? t(
|
||||
'youve_reached_the_fair_usage_limit_on_your_plan_you_can_start_chatting_again_in_time',
|
||||
{ time }
|
||||
)
|
||||
: t('this_will_reset_in', { time })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -207,20 +251,25 @@ function UpgradePaywall({
|
||||
secondsTillReset,
|
||||
isActionBelowContent,
|
||||
featureLocation,
|
||||
isVisible,
|
||||
}: {
|
||||
secondsTillReset: number
|
||||
isActionBelowContent: boolean
|
||||
featureLocation: AiFeatureLocations
|
||||
isVisible: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { sendEvent } = useEditorAnalytics()
|
||||
const paywallType = paywallTypeByLocation[featureLocation]
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
return
|
||||
}
|
||||
sendEvent('paywall-prompt', {
|
||||
'paywall-type': paywallType,
|
||||
})
|
||||
}, [sendEvent, paywallType])
|
||||
}, [sendEvent, paywallType, isVisible])
|
||||
|
||||
return (
|
||||
<Notification
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import getMeta from '@/utils/meta'
|
||||
import { AiFeatureLocations, PaywallType } from '../components/types/ai'
|
||||
|
||||
export const paywallTypeByLocation: Record<AiFeatureLocations, PaywallType> = {
|
||||
@@ -5,3 +6,24 @@ export const paywallTypeByLocation: Record<AiFeatureLocations, PaywallType> = {
|
||||
errorAssist: 'assistant',
|
||||
citationsReviewer: 'citations-reviewer',
|
||||
}
|
||||
|
||||
// Returns the plan type for a limit with no upgrade path, or null when an
|
||||
// upgrade is offered. Membership is checked before quota so commons/group users
|
||||
// with unlimited AI aren't reported as individual; group before commons matches
|
||||
// the rendered paywall's precedence for users who are in both.
|
||||
export function getAiPlanTypeWithoutUpgrade(
|
||||
hasUnlimitedQuota: boolean
|
||||
): 'commons' | 'group' | 'individual' | null {
|
||||
const { hasInstitutionLicence, isMemberOfGroupSubscription } =
|
||||
getMeta('ol-user')
|
||||
if (isMemberOfGroupSubscription) {
|
||||
return 'group'
|
||||
}
|
||||
if (hasInstitutionLicence) {
|
||||
return 'commons'
|
||||
}
|
||||
if (hasUnlimitedQuota) {
|
||||
return 'individual'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ export type EditorProvidersProps = {
|
||||
| 'signUpDate'
|
||||
| 'activeProfessionalGroupSubscriptions'
|
||||
| 'isProfessionalGroupPlan'
|
||||
| 'isMemberOfGroupSubscription'
|
||||
| 'hasInstitutionLicence'
|
||||
| 'planCode'
|
||||
>
|
||||
projectId?: string
|
||||
projectName?: string
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import AiPaywallNotification from '@/shared/components/ai-paywall-notification'
|
||||
import { AiFeatureLocations } from '@/shared/components/types/ai'
|
||||
import {
|
||||
EditorProviders,
|
||||
makeEditorProvider,
|
||||
USER_EMAIL,
|
||||
USER_ID,
|
||||
type EditorProvidersProps,
|
||||
} from '../../helpers/editor-providers'
|
||||
|
||||
type User = Partial<NonNullable<EditorProvidersProps['user']>>
|
||||
|
||||
const futureDate = () => new Date(Date.now() + 60 * 60 * 1000)
|
||||
|
||||
// These fixtures carry no planCode, so plan-type is the only thing identifying
|
||||
// them in the event.
|
||||
const COMMONS_USER: User = { hasInstitutionLicence: true }
|
||||
|
||||
const GROUP_USER: User = { isMemberOfGroupSubscription: true }
|
||||
|
||||
const ADD_ON_USER: User = { planCode: 'professional' }
|
||||
|
||||
const FAIR_USE_TITLE = 'Usage limit reached'
|
||||
|
||||
function mountPaywall({
|
||||
user = {},
|
||||
hasUnlimitedAi = false,
|
||||
featureLocation = 'errorAssist',
|
||||
isVisible = true,
|
||||
}: {
|
||||
user?: User
|
||||
hasUnlimitedAi?: boolean
|
||||
featureLocation?: AiFeatureLocations
|
||||
isVisible?: boolean
|
||||
} = {}) {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-showAiFeatures', true)
|
||||
win.metaAttributesCache.set('ol-hasUnlimitedAi', hasUnlimitedAi)
|
||||
win.metaAttributesCache.get('ol-ExposedSettings').isOverleaf = true
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
user={{ id: USER_ID, email: USER_EMAIL, ...user }}
|
||||
providers={{
|
||||
EditorProvider: makeEditorProvider({
|
||||
hasSuggestionsLeft: false,
|
||||
hasTokensLeft: false,
|
||||
premiumSuggestionResetDate: futureDate(),
|
||||
tokenResetDate: futureDate(),
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<AiPaywallNotification
|
||||
featureLocation={featureLocation}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
}
|
||||
|
||||
describe('<AiPaywallNotification />', function () {
|
||||
beforeEach(function () {
|
||||
cy.intercept('POST', '/event/ai-usage-limit-show', { statusCode: 204 }).as(
|
||||
'limitShown'
|
||||
)
|
||||
cy.intercept('POST', '/event/paywall-prompt', { statusCode: 204 }).as(
|
||||
'paywallPrompt'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports ai-usage-limit-show for a commons user', function () {
|
||||
mountPaywall({ user: COMMONS_USER })
|
||||
|
||||
cy.wait('@limitShown').then(({ request }) => {
|
||||
expect(request.body).to.deep.include({
|
||||
'paywall-type': 'assistant',
|
||||
'plan-type': 'commons',
|
||||
})
|
||||
expect(request.body).not.to.have.property('plan-code')
|
||||
})
|
||||
})
|
||||
|
||||
// A commons entitlement can grant unlimited AI, which renders the fair-use
|
||||
// copy. The user is still commons and must be reported as such.
|
||||
it('reports a commons user as commons when their plan grants unlimited AI', function () {
|
||||
mountPaywall({
|
||||
user: COMMONS_USER,
|
||||
hasUnlimitedAi: true,
|
||||
featureLocation: 'workbench',
|
||||
})
|
||||
|
||||
cy.contains(FAIR_USE_TITLE).should('be.visible')
|
||||
cy.wait('@limitShown').then(({ request }) => {
|
||||
expect(request.body).to.deep.include({
|
||||
'paywall-type': 'workbench',
|
||||
'plan-type': 'commons',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('reports ai-usage-limit-show for a group member', function () {
|
||||
mountPaywall({ user: GROUP_USER })
|
||||
|
||||
cy.wait('@limitShown').then(({ request }) => {
|
||||
expect(request.body).to.deep.include({
|
||||
'paywall-type': 'assistant',
|
||||
'plan-type': 'group',
|
||||
})
|
||||
expect(request.body).not.to.have.property('plan-code')
|
||||
})
|
||||
})
|
||||
|
||||
it('reports ai-usage-limit-show for a user on the AI add-on', function () {
|
||||
mountPaywall({
|
||||
user: ADD_ON_USER,
|
||||
hasUnlimitedAi: true,
|
||||
featureLocation: 'workbench',
|
||||
})
|
||||
|
||||
cy.wait('@limitShown').then(({ request }) => {
|
||||
expect(request.body).to.deep.include({
|
||||
'paywall-type': 'workbench',
|
||||
'plan-code': 'professional',
|
||||
'plan-type': 'individual',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('reports paywall-prompt, not ai-usage-limit-show, when an upgrade is offered', function () {
|
||||
mountPaywall({ user: { planCode: 'personal' } })
|
||||
|
||||
cy.wait('@paywallPrompt')
|
||||
cy.get('@limitShown.all').should('have.length', 0)
|
||||
})
|
||||
|
||||
// The workbench rail and its tab panes stay mounted while hidden, so the
|
||||
// banner exists long before anyone sees it.
|
||||
it('does not report a limit message the user cannot see', function () {
|
||||
mountPaywall({
|
||||
user: COMMONS_USER,
|
||||
featureLocation: 'workbench',
|
||||
hasUnlimitedAi: true,
|
||||
isVisible: false,
|
||||
})
|
||||
|
||||
cy.contains(FAIR_USE_TITLE).should('be.visible')
|
||||
cy.get('@limitShown.all').should('have.length', 0)
|
||||
})
|
||||
|
||||
it('does not report a paywall prompt the user cannot see', function () {
|
||||
mountPaywall({
|
||||
user: { planCode: 'personal' },
|
||||
featureLocation: 'workbench',
|
||||
isVisible: false,
|
||||
})
|
||||
|
||||
// Proves the upgrade variant rendered, so the absent event is the gate
|
||||
// rather than the component having taken some other branch.
|
||||
cy.findByRole('link', { name: 'Upgrade' }).should('be.visible')
|
||||
cy.get('@paywallPrompt.all').should('have.length', 0)
|
||||
})
|
||||
|
||||
it('reports nothing for a commons user in the workbench, where no message is shown', function () {
|
||||
mountPaywall({ user: COMMONS_USER, featureLocation: 'workbench' })
|
||||
|
||||
cy.contains('You’ve reached your AI usage limit').should('not.exist')
|
||||
cy.get('@limitShown.all').should('have.length', 0)
|
||||
cy.get('@paywallPrompt.all').should('have.length', 0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user