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
@@ -25,6 +25,7 @@ export const Divider = forwardRef<HTMLDivElement, DividerProps>(
) => { ) => {
return ( return (
<div <div
data-divider
ref={ref} ref={ref}
className={clsx( className={clsx(
styles.divider, styles.divider,
@@ -1,5 +1,6 @@
import { Tooltip } from '@affine/component/ui/tooltip'; import { Tooltip } from '@affine/component/ui/tooltip';
import { mixpanel } from '@affine/core/utils'; import { mixpanel } from '@affine/core/utils';
import { SubscriptionPlan } from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useLiveData, useServices } from '@toeverything/infra'; import { useLiveData, useServices } from '@toeverything/infra';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
@@ -61,15 +62,12 @@ export const UserPlanButton = () => {
return; return;
} }
if (!plan) { const planLabel = plan ?? SubscriptionPlan.Free;
// no plan, do nothing
return;
}
return ( return (
<Tooltip content={t['com.affine.payment.tag-tooltips']()} side="top"> <Tooltip content={t['com.affine.payment.tag-tooltips']()} side="top">
<div className={styles.userPlanButton} onClick={handleClick}> <div className={styles.userPlanButton} onClick={handleClick}>
{plan} {planLabel}
</div> </div>
</Tooltip> </Tooltip>
); );
@@ -32,21 +32,46 @@ export const operationMenu = style({
flexDirection: 'column', flexDirection: 'column',
gap: 4, 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', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 4, 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', padding: '4px 12px',
}); });
export const cloudUsageLabel = style({
fontWeight: 500, export const usageLabel = style({
fontWeight: 400,
lineHeight: '20px', lineHeight: '20px',
fontSize: cssVar('fontXs'), fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'), color: cssVar('textSecondaryColor'),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}); });
export const cloudUsageLabelUsed = style({ export const usageLabelTitle = style({
color: progressColorVar, color: cssVar('textPrimaryColor'),
marginRight: '0.5em',
}); });
export const cloudUsageBar = style({ export const cloudUsageBar = style({
@@ -54,7 +79,7 @@ export const cloudUsageBar = style({
borderRadius: 5, borderRadius: 5,
overflow: 'hidden', overflow: 'hidden',
position: 'relative', position: 'relative',
minWidth: 160, minWidth: 260,
'::before': { '::before': {
position: 'absolute', position: 'absolute',
@@ -69,3 +94,13 @@ export const cloudUsageBarInner = style({
borderRadius: 'inherit', borderRadius: 'inherit',
backgroundColor: progressColorVar, 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'),
});
@@ -6,6 +6,7 @@ import {
Menu, Menu,
MenuIcon, MenuIcon,
MenuItem, MenuItem,
type MenuProps,
Skeleton, Skeleton,
} from '@affine/component'; } from '@affine/component';
import { import {
@@ -18,15 +19,21 @@ import { mixpanel } from '@affine/core/utils';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { AccountIcon, SignOutIcon } from '@blocksuite/icons/rc'; import { AccountIcon, SignOutIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { import {
type AuthAccountInfo, type AuthAccountInfo,
AuthService, AuthService,
ServerConfigService,
SubscriptionService,
UserCopilotQuotaService,
UserQuotaService, UserQuotaService,
} from '../../modules/cloud'; } from '../../modules/cloud';
import { UserPlanButton } from '../affine/auth/user-plan-button';
import * as styles from './index.css'; import * as styles from './index.css';
import { UnknownUserIcon } from './unknow-user'; import { UnknownUserIcon } from './unknow-user';
@@ -40,9 +47,12 @@ export const UserInfo = () => {
); );
}; };
const menuContentOptions: MenuProps['contentOptions'] = {
className: styles.operationMenu,
};
const AuthorizedUserInfo = ({ account }: { account: AuthAccountInfo }) => { const AuthorizedUserInfo = ({ account }: { account: AuthAccountInfo }) => {
return ( return (
<Menu items={<OperationMenu />}> <Menu items={<OperationMenu />} contentOptions={menuContentOptions}>
<Button <Button
data-testid="sidebar-user-avatar" data-testid="sidebar-user-avatar"
type="plain" type="plain"
@@ -112,7 +122,6 @@ const AccountMenu = () => {
> >
{t['com.affine.workspace.cloud.account.settings']()} {t['com.affine.workspace.cloud.account.settings']()}
</MenuItem> </MenuItem>
<Divider />
<MenuItem <MenuItem
preFix={ preFix={
<MenuIcon> <MenuIcon>
@@ -129,6 +138,7 @@ const AccountMenu = () => {
}; };
const CloudUsage = () => { const CloudUsage = () => {
const t = useI18n();
const quota = useService(UserQuotaService).quota; const quota = useService(UserQuotaService).quota;
const quotaError = useLiveData(quota.error$); const quotaError = useLiveData(quota.error$);
@@ -155,15 +165,126 @@ const CloudUsage = () => {
return ( return (
<div <div
className={styles.cloudUsage} className={clsx(styles.usageBlock, styles.cloudUsageBlock)}
style={assignInlineVars({ style={assignInlineVars({
[styles.progressColorVar]: color, [styles.progressColorVar]: color,
})} })}
> >
<div className={styles.cloudUsageLabel}> <div className={styles.usageLabel}>
<span className={styles.cloudUsageLabelUsed}>{usedFormatted}</span> <div>
<span>&nbsp;/&nbsp;</span> <span className={styles.usageLabelTitle}>
<span>{maxFormatted}</span> {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>
<div className={styles.cloudUsageBar}> <div className={styles.cloudUsageBar}>
@@ -177,8 +298,14 @@ const CloudUsage = () => {
}; };
const OperationMenu = () => { const OperationMenu = () => {
const serverConfigService = useService(ServerConfigService);
const serverFeatures = useLiveData(
serverConfigService.serverConfig.features$
);
return ( return (
<> <>
{serverFeatures?.copilot ? <AIUsage /> : null}
<CloudUsage /> <CloudUsage />
<Divider /> <Divider />
<AccountMenu /> <AccountMenu />
+3 -1
View File
@@ -1340,5 +1340,7 @@
"unnamed": "unnamed", "unnamed": "unnamed",
"upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.", "upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.",
"will be moved to Trash": "{{title}} will be moved to Trash", "will be moved to Trash": "{{title}} will be moved to Trash",
"will delete member": "will delete member" "will delete member": "will delete member",
"com.affine.user-info.usage.ai": "AI Usage",
"com.affine.user-info.usage.cloud": "Cloud Storage"
} }