diff --git a/packages/frontend/core/src/atoms/index.ts b/packages/frontend/core/src/atoms/index.ts index ce5eb51a3..f4d28f5b6 100644 --- a/packages/frontend/core/src/atoms/index.ts +++ b/packages/frontend/core/src/atoms/index.ts @@ -20,6 +20,7 @@ export type SettingAtom = Pick< 'activeTab' | 'workspaceMetadata' > & { open: boolean; + scrollAnchor?: string; }; export const openSettingModalAtom = atom({ diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/ai-usage-panel.tsx b/packages/frontend/core/src/components/affine/setting-modal/account-setting/ai-usage-panel.tsx new file mode 100644 index 000000000..0ad6bda2f --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/ai-usage-panel.tsx @@ -0,0 +1,124 @@ +import { Button } from '@affine/component'; +import { SettingRow } from '@affine/component/setting-components'; +import { openSettingModalAtom } from '@affine/core/atoms'; +import { useQuery } from '@affine/core/hooks/use-query'; +import { useUserSubscription } from '@affine/core/hooks/use-subscription'; +import { + getCopilotQuotaQuery, + pricesQuery, + SubscriptionPlan, + SubscriptionRecurring, +} from '@affine/graphql'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { assertExists } from '@blocksuite/global/utils'; +import { cssVar } from '@toeverything/theme'; +import { useSetAtom } from 'jotai'; +import { useCallback } from 'react'; + +import { useAffineAISubscription } from '../general-setting/plans/ai/use-affine-ai-subscription'; +import * as styles from './storage-progress.css'; + +export const AIUsagePanel = () => { + const t = useAFFiNEI18N(); + const setOpenSettingModal = useSetAtom(openSettingModalAtom); + const [, mutateSubscription] = useUserSubscription(); + const { actionType, Action } = useAffineAISubscription(); + + const openAiPricingPlan = useCallback(() => { + setOpenSettingModal({ + open: true, + activeTab: 'plans', + scrollAnchor: 'aiPricingPlan', + }); + }, [setOpenSettingModal]); + + if (actionType === 'cancel') { + return ( + + + + ); + } + + if (actionType === 'resume') { + return ( + + + + ); + } + + return ; +}; + +export const AIUsagePanelNotSubscripted = () => { + const t = useAFFiNEI18N(); + const [, mutateSubscription] = useUserSubscription(); + const { actionType, Action } = useAffineAISubscription(); + + const { + data: { prices }, + } = useQuery({ query: pricesQuery }); + const { data: quota } = useQuery({ + query: getCopilotQuotaQuery, + }); + const { limit = 10, used = 0 } = quota.currentUser?.copilot.quota || {}; + const percent = Math.min( + 100, + Math.max(0.5, Number(((used / limit) * 100).toFixed(4))) + ); + + const price = prices.find(p => p.plan === SubscriptionPlan.AI); + assertExists(price); + + const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor'); + + return ( + +
+
+
+ {t['com.affine.payment.ai.usage.used-caption']()} + + {t['com.affine.payment.ai.usage.used-detail']({ + used: used.toString(), + limit: limit.toString(), + })} + +
+ +
+
+
+
+ + + {actionType === 'subscribe' + ? t['com.affine.payment.ai.usage.purchase-button-label']() + : null} + +
+
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx index 43911a262..8e0ffd481 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx @@ -5,6 +5,7 @@ import { } from '@affine/component/setting-components'; import { Avatar } from '@affine/component/ui/avatar'; import { Button } from '@affine/component/ui/button'; +import { SWRErrorBoundary } from '@affine/core/components/pure/swr-error-bundary'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { removeAvatarMutation, @@ -28,6 +29,7 @@ import { useMutation } from '../../../../hooks/use-mutation'; import { mixpanel } from '../../../../utils'; import { validateAndReduceImage } from '../../../../utils/reduce-image'; import { Upload } from '../../../pure/file-upload'; +import { AIUsagePanel } from './ai-usage-panel'; import { StorageProgress } from './storage-progress'; import * as styles from './style.css'; @@ -256,6 +258,11 @@ export const AccountSetting: FC = () => { + }> + + + + { // refresh idempotency key setIdempotencyKey(nanoid()); - onSubscriptionUpdate(data.cancelSubscription); + onSubscriptionUpdate?.(data.cancelSubscription); }, } ); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/resume.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/resume.tsx index fb2ca500f..0823609f7 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/resume.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/resume.tsx @@ -48,7 +48,7 @@ export const AIResume = ({ onSuccess: data => { // refresh idempotency key setIdempotencyKey(nanoid()); - onSubscriptionUpdate(data.resumeSubscription); + onSubscriptionUpdate?.(data.resumeSubscription); notify({ icon: ( { + assertExists(price); const idempotencyKey = useMemo(() => `${nanoid()}-${recurring}`, [recurring]); const { priceReadable, priceFrequency } = useAffineAIPrice(price); @@ -31,7 +34,7 @@ export const AISubscribe = ({ const onClose = useCallback(() => { newTabRef.current = null; - onSubscriptionUpdate(); + onSubscriptionUpdate?.(); }, [onSubscriptionUpdate]); useEffect(() => { diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/types.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/types.ts index e23573c5a..a8eb24b69 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/types.ts +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/types.ts @@ -2,7 +2,7 @@ import type { SubscriptionMutator } from '@affine/core/hooks/use-subscription'; import type { PricesQuery, SubscriptionRecurring } from '@affine/graphql'; export interface BaseActionProps { - price: PricesQuery['prices'][number]; - recurring: SubscriptionRecurring; - onSubscriptionUpdate: SubscriptionMutator; + price?: PricesQuery['prices'][number]; + recurring?: SubscriptionRecurring; + onSubscriptionUpdate?: SubscriptionMutator; } diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx index b3d3add42..899209b6b 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx @@ -1,14 +1,18 @@ import { Divider, IconButton } from '@affine/component'; import { SettingHeader } from '@affine/component/setting-components'; +import { openSettingModalAtom } from '@affine/core/atoms'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ArrowRightBigIcon, ArrowUpSmallIcon } from '@blocksuite/icons'; import * as Collapsible from '@radix-ui/react-collapsible'; import * as ScrollArea from '@radix-ui/react-scroll-area'; +import { useAtom } from 'jotai'; import { type HtmlHTMLAttributes, type PropsWithChildren, type ReactNode, useCallback, + useLayoutEffect, + useRef, useState, } from 'react'; @@ -70,6 +74,20 @@ export interface PlanLayoutProps { export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => { const t = useAFFiNEI18N(); + const [{ scrollAnchor }, setOpenSettingModal] = useAtom(openSettingModalAtom); + const aiPricingPlanRef = useRef(null); + + // TODO: Need a better solution to handle this situation + useLayoutEffect(() => { + if (!scrollAnchor) return; + setTimeout(() => { + if (scrollAnchor === 'aiPricingPlan' && aiPricingPlanRef.current) { + aiPricingPlanRef.current.scrollIntoView(); + setOpenSettingModal(prev => ({ ...prev, scrollAnchor: undefined })); + } + }); + }, [scrollAnchor, setOpenSettingModal]); + return (
{/* TODO: SettingHeader component shouldn't have margin itself */} @@ -81,7 +99,9 @@ export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => { {ai ? ( <> - {ai} +
+ {ai} +
) : null}
diff --git a/packages/frontend/graphql/src/graphql/get-copilot-quota.gql b/packages/frontend/graphql/src/graphql/get-copilot-quota.gql index 61e589b2f..5ec67af2c 100644 --- a/packages/frontend/graphql/src/graphql/get-copilot-quota.gql +++ b/packages/frontend/graphql/src/graphql/get-copilot-quota.gql @@ -1,4 +1,4 @@ -query getCopilotQuota($workspaceId: String!, $docId: String!) { +query getCopilotQuota { currentUser { copilot { quota { diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index cdb7e9247..9bcd67b69 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -281,7 +281,7 @@ export const getCopilotQuotaQuery = { definitionName: 'currentUser', containsFile: false, query: ` -query getCopilotQuota($workspaceId: String!, $docId: String!) { +query getCopilotQuota { currentUser { copilot { quota { diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 035d5dd09..7ffd1f122 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -369,10 +369,7 @@ export type GetCopilotHistoriesQuery = { } | null; }; -export type GetCopilotQuotaQueryVariables = Exact<{ - workspaceId: Scalars['String']['input']; - docId: Scalars['String']['input']; -}>; +export type GetCopilotQuotaQueryVariables = Exact<{ [key: string]: never }>; export type GetCopilotQuotaQuery = { __typename?: 'Query'; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 8df5faa65..203c9316e 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -910,6 +910,12 @@ "com.affine.payment.ai.benefit.g3-1": "Memorize and tidy up your knowledge", "com.affine.payment.ai.benefit.g3-2": "Auto-sorting and auto-tagging", "com.affine.payment.ai.benefit.g3-3": "Open source & Privacy ensured", + "com.affine.payment.ai.usage-title": "AFFiNE AI Usage", + "com.affine.payment.ai.usage-description-purchased": "You have purchased AFFiNE AI.", + "com.affine.payment.ai.usage.change-button-label": "Upgraded", + "com.affine.payment.ai.usage.purchase-button-label": "Upgrade", + "com.affine.payment.ai.usage.used-caption": "Times used", + "com.affine.payment.ai.usage.used-detail": "{{used}}/{{limit}} Times", "com.affine.payment.benefit-1": "Unlimited local workspaces", "com.affine.payment.benefit-2": "Unlimited login devices", "com.affine.payment.benefit-3": "Unlimited blocks",