feat(core): add ai-usage info in sidebar user avatar menu (#7294)

This commit is contained in:
CatsJuice
2024-06-21 02:47:20 +00:00
parent 92be6b2ff7
commit f24c0caaea
5 changed files with 182 additions and 19 deletions

View File

@@ -1,5 +1,6 @@
import { Tooltip } from '@affine/component/ui/tooltip';
import { mixpanel } from '@affine/core/utils';
import { SubscriptionPlan } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useServices } from '@toeverything/infra';
import { useSetAtom } from 'jotai';
@@ -61,15 +62,12 @@ export const UserPlanButton = () => {
return;
}
if (!plan) {
// no plan, do nothing
return;
}
const planLabel = plan ?? SubscriptionPlan.Free;
return (
<Tooltip content={t['com.affine.payment.tag-tooltips']()} side="top">
<div className={styles.userPlanButton} onClick={handleClick}>
{plan}
{planLabel}
</div>
</Tooltip>
);

View File

@@ -32,21 +32,46 @@ export const operationMenu = style({
flexDirection: 'column',
gap: 4,
});
// TODO: refactor menu, use `gap` to replace `margin`
globalStyle(`.${operationMenu} > div:not([data-divider])`, {
marginBottom: '0 !important',
marginTop: '0 !important',
});
export const cloudUsage = style({
export const usageBlock = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
borderRadius: 4,
});
export const aiUsageBlock = style({
padding: 12,
cursor: 'pointer',
':hover': {
background: cssVar('hoverColor'),
},
selectors: {
'&[data-pro]': {
padding: '12px 12px 2px 12px',
},
},
});
export const cloudUsageBlock = style({
padding: '4px 12px',
});
export const cloudUsageLabel = style({
fontWeight: 500,
export const usageLabel = style({
fontWeight: 400,
lineHeight: '20px',
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
export const cloudUsageLabelUsed = style({
color: progressColorVar,
export const usageLabelTitle = style({
color: cssVar('textPrimaryColor'),
marginRight: '0.5em',
});
export const cloudUsageBar = style({
@@ -54,7 +79,7 @@ export const cloudUsageBar = style({
borderRadius: 5,
overflow: 'hidden',
position: 'relative',
minWidth: 160,
minWidth: 260,
'::before': {
position: 'absolute',
@@ -69,3 +94,13 @@ export const cloudUsageBarInner = style({
borderRadius: 'inherit',
backgroundColor: progressColorVar,
});
export const freeTag = style({
height: 20,
padding: '0px 4px',
borderRadius: 4,
fontWeight: 500,
fontSize: cssVar('fontXs'),
lineHeight: '20px',
color: cssVar('pureWhite'),
background: cssVar('primaryColor'),
});

View File

@@ -6,6 +6,7 @@ import {
Menu,
MenuIcon,
MenuItem,
type MenuProps,
Skeleton,
} from '@affine/component';
import {
@@ -18,15 +19,21 @@ import { mixpanel } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import { AccountIcon, SignOutIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
import {
type AuthAccountInfo,
AuthService,
ServerConfigService,
SubscriptionService,
UserCopilotQuotaService,
UserQuotaService,
} from '../../modules/cloud';
import { UserPlanButton } from '../affine/auth/user-plan-button';
import * as styles from './index.css';
import { UnknownUserIcon } from './unknow-user';
@@ -40,9 +47,12 @@ export const UserInfo = () => {
);
};
const menuContentOptions: MenuProps['contentOptions'] = {
className: styles.operationMenu,
};
const AuthorizedUserInfo = ({ account }: { account: AuthAccountInfo }) => {
return (
<Menu items={<OperationMenu />}>
<Menu items={<OperationMenu />} contentOptions={menuContentOptions}>
<Button
data-testid="sidebar-user-avatar"
type="plain"
@@ -112,7 +122,6 @@ const AccountMenu = () => {
>
{t['com.affine.workspace.cloud.account.settings']()}
</MenuItem>
<Divider />
<MenuItem
preFix={
<MenuIcon>
@@ -129,6 +138,7 @@ const AccountMenu = () => {
};
const CloudUsage = () => {
const t = useI18n();
const quota = useService(UserQuotaService).quota;
const quotaError = useLiveData(quota.error$);
@@ -155,15 +165,126 @@ const CloudUsage = () => {
return (
<div
className={styles.cloudUsage}
className={clsx(styles.usageBlock, styles.cloudUsageBlock)}
style={assignInlineVars({
[styles.progressColorVar]: color,
})}
>
<div className={styles.cloudUsageLabel}>
<span className={styles.cloudUsageLabelUsed}>{usedFormatted}</span>
<span>&nbsp;/&nbsp;</span>
<span>{maxFormatted}</span>
<div className={styles.usageLabel}>
<div>
<span className={styles.usageLabelTitle}>
{t['com.affine.user-info.usage.cloud']()}
</span>
<span>{usedFormatted}</span>
<span>&nbsp;/&nbsp;</span>
<span>{maxFormatted}</span>
</div>
<UserPlanButton />
</div>
<div className={styles.cloudUsageBar}>
<div
className={styles.cloudUsageBarInner}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
};
const AIUsage = () => {
const t = useI18n();
const copilotQuotaService = useService(UserCopilotQuotaService);
const subscriptionService = useService(SubscriptionService);
useEffect(() => {
// revalidate latest subscription status
subscriptionService.subscription.revalidate();
}, [subscriptionService]);
useEffect(() => {
copilotQuotaService.copilotQuota.revalidate();
}, [copilotQuotaService]);
const copilotActionLimit = useLiveData(
copilotQuotaService.copilotQuota.copilotActionLimit$
);
const copilotActionUsed = useLiveData(
copilotQuotaService.copilotQuota.copilotActionUsed$
);
const loading = copilotActionLimit === null || copilotActionUsed === null;
const loadError = useLiveData(copilotQuotaService.copilotQuota.error$);
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const goToAIPlanPage = useCallback(() => {
setSettingModalAtom({
open: true,
activeTab: 'plans',
scrollAnchor: 'aiPricingPlan',
});
}, [setSettingModalAtom]);
const goToAccountSetting = useCallback(() => {
setSettingModalAtom({
open: true,
activeTab: 'account',
});
}, [setSettingModalAtom]);
if (loading) {
if (loadError) console.error(loadError);
return null;
}
// unlimited
if (copilotActionLimit === 'unlimited') {
return (
<div
onClick={goToAccountSetting}
data-pro
className={clsx(styles.usageBlock, styles.aiUsageBlock)}
>
<div className={styles.usageLabel}>
<div className={styles.usageLabelTitle}>
{t['com.affine.user-info.usage.ai']()}
</div>
</div>
<div className={styles.usageLabel}>
{t['com.affine.payment.ai.usage-description-purchased']()}
</div>
</div>
);
}
const percent = Math.min(
100,
Math.max(
0.5,
Number(((copilotActionUsed / copilotActionLimit) * 100).toFixed(4))
)
);
const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor');
return (
<div
onClick={goToAIPlanPage}
className={clsx(styles.usageBlock, styles.aiUsageBlock)}
style={assignInlineVars({
[styles.progressColorVar]: color,
})}
>
<div className={styles.usageLabel}>
<div>
<span className={styles.usageLabelTitle}>
{t['com.affine.user-info.usage.ai']()}
</span>
<span>{copilotActionUsed}</span>
<span>&nbsp;/&nbsp;</span>
<span>{copilotActionLimit}</span>
</div>
<div className={styles.freeTag}>Free</div>
</div>
<div className={styles.cloudUsageBar}>
@@ -177,8 +298,14 @@ const CloudUsage = () => {
};
const OperationMenu = () => {
const serverConfigService = useService(ServerConfigService);
const serverFeatures = useLiveData(
serverConfigService.serverConfig.features$
);
return (
<>
{serverFeatures?.copilot ? <AIUsage /> : null}
<CloudUsage />
<Divider />
<AccountMenu />