feat(core): add ai usage in account-setting (#6516)
This commit is contained in:
@@ -20,6 +20,7 @@ export type SettingAtom = Pick<
|
|||||||
'activeTab' | 'workspaceMetadata'
|
'activeTab' | 'workspaceMetadata'
|
||||||
> & {
|
> & {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
scrollAnchor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const openSettingModalAtom = atom<SettingAtom>({
|
export const openSettingModalAtom = atom<SettingAtom>({
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<SettingRow
|
||||||
|
desc={t['com.affine.payment.ai.usage-description-purchased']()}
|
||||||
|
name={t['com.affine.payment.ai.usage-title']()}
|
||||||
|
>
|
||||||
|
<Button onClick={openAiPricingPlan}>
|
||||||
|
{t['com.affine.payment.ai.usage.change-button-label']()}
|
||||||
|
</Button>
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType === 'resume') {
|
||||||
|
return (
|
||||||
|
<SettingRow
|
||||||
|
desc={t['com.affine.payment.ai.usage-description-purchased']()}
|
||||||
|
name={t['com.affine.payment.ai.usage-title']()}
|
||||||
|
>
|
||||||
|
<Action onSubscriptionUpdate={mutateSubscription} />
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AIUsagePanelNotSubscripted />;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<SettingRow
|
||||||
|
spreadCol={false}
|
||||||
|
desc=""
|
||||||
|
name={t['com.affine.payment.ai.usage-title']()}
|
||||||
|
>
|
||||||
|
<div className={styles.storageProgressContainer}>
|
||||||
|
<div className={styles.storageProgressWrapper}>
|
||||||
|
<div className="storage-progress-desc">
|
||||||
|
<span>{t['com.affine.payment.ai.usage.used-caption']()}</span>
|
||||||
|
<span>
|
||||||
|
{t['com.affine.payment.ai.usage.used-detail']({
|
||||||
|
used: used.toString(),
|
||||||
|
limit: limit.toString(),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="storage-progress-bar-wrapper">
|
||||||
|
<div
|
||||||
|
className={styles.storageProgressBar}
|
||||||
|
style={{ width: `${percent}%`, backgroundColor: color }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Action
|
||||||
|
recurring={SubscriptionRecurring.Yearly}
|
||||||
|
onSubscriptionUpdate={mutateSubscription}
|
||||||
|
price={price}
|
||||||
|
type="primary"
|
||||||
|
className={styles.storageButton}
|
||||||
|
>
|
||||||
|
{actionType === 'subscribe'
|
||||||
|
? t['com.affine.payment.ai.usage.purchase-button-label']()
|
||||||
|
: null}
|
||||||
|
</Action>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@affine/component/setting-components';
|
} from '@affine/component/setting-components';
|
||||||
import { Avatar } from '@affine/component/ui/avatar';
|
import { Avatar } from '@affine/component/ui/avatar';
|
||||||
import { Button } from '@affine/component/ui/button';
|
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 { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import {
|
import {
|
||||||
removeAvatarMutation,
|
removeAvatarMutation,
|
||||||
@@ -28,6 +29,7 @@ import { useMutation } from '../../../../hooks/use-mutation';
|
|||||||
import { mixpanel } from '../../../../utils';
|
import { mixpanel } from '../../../../utils';
|
||||||
import { validateAndReduceImage } from '../../../../utils/reduce-image';
|
import { validateAndReduceImage } from '../../../../utils/reduce-image';
|
||||||
import { Upload } from '../../../pure/file-upload';
|
import { Upload } from '../../../pure/file-upload';
|
||||||
|
import { AIUsagePanel } from './ai-usage-panel';
|
||||||
import { StorageProgress } from './storage-progress';
|
import { StorageProgress } from './storage-progress';
|
||||||
import * as styles from './style.css';
|
import * as styles from './style.css';
|
||||||
|
|
||||||
@@ -256,6 +258,11 @@ export const AccountSetting: FC = () => {
|
|||||||
<Suspense>
|
<Suspense>
|
||||||
<StoragePanel />
|
<StoragePanel />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<SWRErrorBoundary fallback={<div />}>
|
||||||
|
<Suspense>
|
||||||
|
<AIUsagePanel />
|
||||||
|
</Suspense>
|
||||||
|
</SWRErrorBoundary>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
name={t[`Sign out`]()}
|
name={t[`Sign out`]()}
|
||||||
desc={t['com.affine.setting.sign.out.message']()}
|
desc={t['com.affine.setting.sign.out.message']()}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const AICancel = ({
|
|||||||
onSuccess: data => {
|
onSuccess: data => {
|
||||||
// refresh idempotency key
|
// refresh idempotency key
|
||||||
setIdempotencyKey(nanoid());
|
setIdempotencyKey(nanoid());
|
||||||
onSubscriptionUpdate(data.cancelSubscription);
|
onSubscriptionUpdate?.(data.cancelSubscription);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const AIResume = ({
|
|||||||
onSuccess: data => {
|
onSuccess: data => {
|
||||||
// refresh idempotency key
|
// refresh idempotency key
|
||||||
setIdempotencyKey(nanoid());
|
setIdempotencyKey(nanoid());
|
||||||
onSubscriptionUpdate(data.resumeSubscription);
|
onSubscriptionUpdate?.(data.resumeSubscription);
|
||||||
notify({
|
notify({
|
||||||
icon: (
|
icon: (
|
||||||
<SingleSelectSelectSolidIcon
|
<SingleSelectSelectSolidIcon
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { popupWindow } from '@affine/core/utils';
|
|||||||
import {
|
import {
|
||||||
createCheckoutSessionMutation,
|
createCheckoutSessionMutation,
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
|
SubscriptionRecurring,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
@@ -16,10 +18,11 @@ export interface AISubscribeProps extends BaseActionProps, ButtonProps {}
|
|||||||
|
|
||||||
export const AISubscribe = ({
|
export const AISubscribe = ({
|
||||||
price,
|
price,
|
||||||
recurring,
|
recurring = SubscriptionRecurring.Yearly,
|
||||||
onSubscriptionUpdate,
|
onSubscriptionUpdate,
|
||||||
...btnProps
|
...btnProps
|
||||||
}: AISubscribeProps) => {
|
}: AISubscribeProps) => {
|
||||||
|
assertExists(price);
|
||||||
const idempotencyKey = useMemo(() => `${nanoid()}-${recurring}`, [recurring]);
|
const idempotencyKey = useMemo(() => `${nanoid()}-${recurring}`, [recurring]);
|
||||||
const { priceReadable, priceFrequency } = useAffineAIPrice(price);
|
const { priceReadable, priceFrequency } = useAffineAIPrice(price);
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ export const AISubscribe = ({
|
|||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
newTabRef.current = null;
|
newTabRef.current = null;
|
||||||
onSubscriptionUpdate();
|
onSubscriptionUpdate?.();
|
||||||
}, [onSubscriptionUpdate]);
|
}, [onSubscriptionUpdate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { SubscriptionMutator } from '@affine/core/hooks/use-subscription';
|
|||||||
import type { PricesQuery, SubscriptionRecurring } from '@affine/graphql';
|
import type { PricesQuery, SubscriptionRecurring } from '@affine/graphql';
|
||||||
|
|
||||||
export interface BaseActionProps {
|
export interface BaseActionProps {
|
||||||
price: PricesQuery['prices'][number];
|
price?: PricesQuery['prices'][number];
|
||||||
recurring: SubscriptionRecurring;
|
recurring?: SubscriptionRecurring;
|
||||||
onSubscriptionUpdate: SubscriptionMutator;
|
onSubscriptionUpdate?: SubscriptionMutator;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { Divider, IconButton } from '@affine/component';
|
import { Divider, IconButton } from '@affine/component';
|
||||||
import { SettingHeader } from '@affine/component/setting-components';
|
import { SettingHeader } from '@affine/component/setting-components';
|
||||||
|
import { openSettingModalAtom } from '@affine/core/atoms';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { ArrowRightBigIcon, ArrowUpSmallIcon } from '@blocksuite/icons';
|
import { ArrowRightBigIcon, ArrowUpSmallIcon } from '@blocksuite/icons';
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
type HtmlHTMLAttributes,
|
type HtmlHTMLAttributes,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
@@ -70,6 +74,20 @@ export interface PlanLayoutProps {
|
|||||||
|
|
||||||
export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => {
|
export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
|
const [{ scrollAnchor }, setOpenSettingModal] = useAtom(openSettingModalAtom);
|
||||||
|
const aiPricingPlanRef = useRef<HTMLDivElement>(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 (
|
return (
|
||||||
<div className={styles.plansLayoutRoot}>
|
<div className={styles.plansLayoutRoot}>
|
||||||
{/* TODO: SettingHeader component shouldn't have margin itself */}
|
{/* TODO: SettingHeader component shouldn't have margin itself */}
|
||||||
@@ -81,7 +99,9 @@ export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => {
|
|||||||
{ai ? (
|
{ai ? (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
<div ref={aiPricingPlanRef} id="aiPricingPlan">
|
||||||
{ai}
|
{ai}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
query getCopilotQuota($workspaceId: String!, $docId: String!) {
|
query getCopilotQuota {
|
||||||
currentUser {
|
currentUser {
|
||||||
copilot {
|
copilot {
|
||||||
quota {
|
quota {
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ export const getCopilotQuotaQuery = {
|
|||||||
definitionName: 'currentUser',
|
definitionName: 'currentUser',
|
||||||
containsFile: false,
|
containsFile: false,
|
||||||
query: `
|
query: `
|
||||||
query getCopilotQuota($workspaceId: String!, $docId: String!) {
|
query getCopilotQuota {
|
||||||
currentUser {
|
currentUser {
|
||||||
copilot {
|
copilot {
|
||||||
quota {
|
quota {
|
||||||
|
|||||||
@@ -369,10 +369,7 @@ export type GetCopilotHistoriesQuery = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetCopilotQuotaQueryVariables = Exact<{
|
export type GetCopilotQuotaQueryVariables = Exact<{ [key: string]: never }>;
|
||||||
workspaceId: Scalars['String']['input'];
|
|
||||||
docId: Scalars['String']['input'];
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type GetCopilotQuotaQuery = {
|
export type GetCopilotQuotaQuery = {
|
||||||
__typename?: 'Query';
|
__typename?: 'Query';
|
||||||
|
|||||||
@@ -910,6 +910,12 @@
|
|||||||
"com.affine.payment.ai.benefit.g3-1": "Memorize and tidy up your knowledge",
|
"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-2": "Auto-sorting and auto-tagging",
|
||||||
"com.affine.payment.ai.benefit.g3-3": "Open source & Privacy ensured",
|
"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-1": "Unlimited local workspaces",
|
||||||
"com.affine.payment.benefit-2": "Unlimited login devices",
|
"com.affine.payment.benefit-2": "Unlimited login devices",
|
||||||
"com.affine.payment.benefit-3": "Unlimited blocks",
|
"com.affine.payment.benefit-3": "Unlimited blocks",
|
||||||
|
|||||||
Reference in New Issue
Block a user