mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-01 09:30:01 +08:00
feat(core): add ai-usage info in sidebar user avatar menu (#7294)
This commit is contained in:
@@ -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> / </span>
|
<span className={styles.usageLabelTitle}>
|
||||||
<span>{maxFormatted}</span>
|
{t['com.affine.user-info.usage.cloud']()}
|
||||||
|
</span>
|
||||||
|
<span>{usedFormatted}</span>
|
||||||
|
<span> / </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> / </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 />
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user