mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +08:00
feat(core): add ai usage in account-setting (#6516)
This commit is contained in:
@@ -20,6 +20,7 @@ export type SettingAtom = Pick<
|
||||
'activeTab' | 'workspaceMetadata'
|
||||
> & {
|
||||
open: boolean;
|
||||
scrollAnchor?: string;
|
||||
};
|
||||
|
||||
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';
|
||||
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 = () => {
|
||||
<Suspense>
|
||||
<StoragePanel />
|
||||
</Suspense>
|
||||
<SWRErrorBoundary fallback={<div />}>
|
||||
<Suspense>
|
||||
<AIUsagePanel />
|
||||
</Suspense>
|
||||
</SWRErrorBoundary>
|
||||
<SettingRow
|
||||
name={t[`Sign out`]()}
|
||||
desc={t['com.affine.setting.sign.out.message']()}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const AICancel = ({
|
||||
onSuccess: data => {
|
||||
// refresh idempotency key
|
||||
setIdempotencyKey(nanoid());
|
||||
onSubscriptionUpdate(data.cancelSubscription);
|
||||
onSubscriptionUpdate?.(data.cancelSubscription);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -48,7 +48,7 @@ export const AIResume = ({
|
||||
onSuccess: data => {
|
||||
// refresh idempotency key
|
||||
setIdempotencyKey(nanoid());
|
||||
onSubscriptionUpdate(data.resumeSubscription);
|
||||
onSubscriptionUpdate?.(data.resumeSubscription);
|
||||
notify({
|
||||
icon: (
|
||||
<SingleSelectSelectSolidIcon
|
||||
|
||||
@@ -5,7 +5,9 @@ import { popupWindow } from '@affine/core/utils';
|
||||
import {
|
||||
createCheckoutSessionMutation,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
} from '@affine/graphql';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
@@ -16,10 +18,11 @@ export interface AISubscribeProps extends BaseActionProps, ButtonProps {}
|
||||
|
||||
export const AISubscribe = ({
|
||||
price,
|
||||
recurring,
|
||||
recurring = SubscriptionRecurring.Yearly,
|
||||
onSubscriptionUpdate,
|
||||
...btnProps
|
||||
}: AISubscribeProps) => {
|
||||
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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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 (
|
||||
<div className={styles.plansLayoutRoot}>
|
||||
{/* TODO: SettingHeader component shouldn't have margin itself */}
|
||||
@@ -81,7 +99,9 @@ export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => {
|
||||
{ai ? (
|
||||
<>
|
||||
<Divider />
|
||||
{ai}
|
||||
<div ref={aiPricingPlanRef} id="aiPricingPlan">
|
||||
{ai}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
query getCopilotQuota($workspaceId: String!, $docId: String!) {
|
||||
query getCopilotQuota {
|
||||
currentUser {
|
||||
copilot {
|
||||
quota {
|
||||
|
||||
@@ -281,7 +281,7 @@ export const getCopilotQuotaQuery = {
|
||||
definitionName: 'currentUser',
|
||||
containsFile: false,
|
||||
query: `
|
||||
query getCopilotQuota($workspaceId: String!, $docId: String!) {
|
||||
query getCopilotQuota {
|
||||
currentUser {
|
||||
copilot {
|
||||
quota {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user