@lobehub/chat 0.152.10 → 0.152.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/locales/ar/common.json +14 -0
  3. package/locales/bg-BG/common.json +14 -0
  4. package/locales/de-DE/common.json +14 -0
  5. package/locales/en-US/common.json +14 -0
  6. package/locales/es-ES/common.json +14 -0
  7. package/locales/fr-FR/common.json +14 -0
  8. package/locales/it-IT/common.json +14 -0
  9. package/locales/ja-JP/common.json +14 -0
  10. package/locales/ko-KR/common.json +14 -0
  11. package/locales/nl-NL/common.json +14 -0
  12. package/locales/pl-PL/common.json +14 -0
  13. package/locales/pt-BR/common.json +14 -0
  14. package/locales/ru-RU/common.json +14 -0
  15. package/locales/tr-TR/common.json +14 -0
  16. package/locales/vi-VN/common.json +14 -0
  17. package/locales/zh-CN/common.json +14 -0
  18. package/locales/zh-TW/common.json +14 -0
  19. package/next.config.mjs +7 -0
  20. package/package.json +1 -1
  21. package/src/app/(main)/(mobile)/me/page.tsx +2 -2
  22. package/src/app/(main)/@nav/_layout/Desktop/Avatar.test.tsx +55 -0
  23. package/src/app/(main)/@nav/_layout/Desktop/Avatar.tsx +44 -2
  24. package/src/app/(main)/@nav/_layout/Desktop/BottomActions.tsx +4 -126
  25. package/src/app/(main)/@nav/_layout/Desktop/index.tsx +1 -1
  26. package/src/app/(main)/settings/common/features/Common.tsx +6 -6
  27. package/src/features/AvatarWithUpload/index.tsx +8 -44
  28. package/src/features/DataImporter/index.tsx +11 -1
  29. package/src/features/User/UserAvatar.tsx +67 -0
  30. package/src/features/User/UserInfo.tsx +41 -0
  31. package/src/features/User/UserPanel/LangButton.tsx +57 -0
  32. package/src/features/User/UserPanel/Popover.tsx +35 -0
  33. package/src/features/User/UserPanel/ThemeButton.tsx +70 -0
  34. package/src/features/User/UserPanel/UserInfo.tsx +35 -0
  35. package/src/features/User/UserPanel/index.tsx +62 -0
  36. package/src/features/User/UserPanel/useMenu.tsx +158 -0
  37. package/src/features/User/UserPanel/useNewVersion.tsx +12 -0
  38. package/src/layout/AuthProvider/NextAuth/UserUpdater.tsx +35 -0
  39. package/src/layout/AuthProvider/NextAuth/index.tsx +8 -1
  40. package/src/locales/default/common.ts +14 -0
  41. package/src/store/user/slices/auth/initialState.ts +6 -8
  42. package/src/store/user/slices/auth/selectors.ts +4 -1
  43. package/src/store/user/slices/preference/action.test.ts +41 -3
  44. package/src/store/user/slices/preference/action.ts +8 -2
  45. package/src/store/user/slices/preference/initialState.ts +14 -5
  46. package/src/store/user/slices/preference/selectors.test.ts +82 -0
  47. package/src/store/user/slices/preference/selectors.ts +4 -0
  48. package/src/store/user/slices/settings/actions/general.ts +8 -0
  49. package/src/types/user.ts +9 -0
  50. package/src/app/(main)/settings/page.tsx +0 -7
@@ -0,0 +1,158 @@
1
+ import { DiscordIcon, Icon } from '@lobehub/ui';
2
+ import { Badge } from 'antd';
3
+ import {
4
+ Book,
5
+ Feather,
6
+ HardDriveDownload,
7
+ HardDriveUpload,
8
+ LifeBuoy,
9
+ Mail,
10
+ Settings2,
11
+ } from 'lucide-react';
12
+ import Link from 'next/link';
13
+ import { PropsWithChildren, useCallback } from 'react';
14
+ import { useTranslation } from 'react-i18next';
15
+ import { Flexbox } from 'react-layout-kit';
16
+
17
+ import { type MenuProps } from '@/components/Menu';
18
+ import { DISCORD, DOCUMENTS, EMAIL_SUPPORT, GITHUB_ISSUES } from '@/const/url';
19
+ import DataImporter from '@/features/DataImporter';
20
+ import { configService } from '@/services/config';
21
+
22
+ import { useNewVersion } from './useNewVersion';
23
+
24
+ export const useMenu = () => {
25
+ const hasNewVersion = useNewVersion();
26
+ const { t } = useTranslation(['common', 'setting']);
27
+
28
+ const NewVersionBadge = useCallback(
29
+ ({ children, showBadge }: PropsWithChildren & { showBadge?: boolean }) => {
30
+ if (!showBadge) return children;
31
+ return (
32
+ <Flexbox align={'center'} distribution={'space-between'} gap={8} horizontal width={'100%'}>
33
+ <span>{children}</span>
34
+ <Badge count={t('upgradeVersion.hasNew')} />
35
+ </Flexbox>
36
+ );
37
+ },
38
+ [t],
39
+ );
40
+
41
+ const exports: MenuProps['items'] = [
42
+ {
43
+ icon: <Icon icon={HardDriveUpload} />,
44
+ key: 'import',
45
+ label: <DataImporter>{t('import')}</DataImporter>,
46
+ },
47
+ {
48
+ children: [
49
+ {
50
+ key: 'allAgent',
51
+ label: t('exportType.allAgent'),
52
+ onClick: configService.exportAgents,
53
+ },
54
+ {
55
+ key: 'allAgentWithMessage',
56
+ label: t('exportType.allAgentWithMessage'),
57
+ onClick: configService.exportSessions,
58
+ },
59
+ {
60
+ key: 'globalSetting',
61
+ label: t('exportType.globalSetting'),
62
+ onClick: configService.exportSettings,
63
+ },
64
+ {
65
+ type: 'divider',
66
+ },
67
+ {
68
+ key: 'all',
69
+ label: t('exportType.all'),
70
+ onClick: configService.exportAll,
71
+ },
72
+ ],
73
+ icon: <Icon icon={HardDriveDownload} />,
74
+ key: 'export',
75
+ label: t('export'),
76
+ },
77
+ {
78
+ type: 'divider',
79
+ },
80
+ ];
81
+
82
+ const settings: MenuProps['items'] = [
83
+ {
84
+ icon: <Icon icon={Settings2} />,
85
+ key: 'setting',
86
+ label: (
87
+ <Link href={'/settings'}>
88
+ <NewVersionBadge showBadge={hasNewVersion}>{t('userPanel.setting')}</NewVersionBadge>
89
+ </Link>
90
+ ),
91
+ },
92
+ {
93
+ type: 'divider',
94
+ },
95
+ ];
96
+
97
+ const helps: MenuProps['items'] = [
98
+ {
99
+ icon: <Icon icon={DiscordIcon} />,
100
+ key: 'discord',
101
+ label: (
102
+ <Link href={DISCORD} target={'_blank'}>
103
+ {t('userPanel.discord')}
104
+ </Link>
105
+ ),
106
+ },
107
+ {
108
+ children: [
109
+ {
110
+ icon: <Icon icon={Book} />,
111
+ key: 'docs',
112
+ label: (
113
+ <Link href={DOCUMENTS} target={'_blank'}>
114
+ {t('userPanel.docs')}
115
+ </Link>
116
+ ),
117
+ },
118
+ {
119
+ icon: <Icon icon={Feather} />,
120
+ key: 'feedback',
121
+ label: (
122
+ <Link href={GITHUB_ISSUES} target={'_blank'}>
123
+ {t('userPanel.feedback')}
124
+ </Link>
125
+ ),
126
+ },
127
+ {
128
+ icon: <Icon icon={Mail} />,
129
+ key: 'email',
130
+ label: (
131
+ <Link href={`mailto:${EMAIL_SUPPORT}`} target={'_blank'}>
132
+ {t('userPanel.email')}
133
+ </Link>
134
+ ),
135
+ },
136
+ ],
137
+ icon: <Icon icon={LifeBuoy} />,
138
+ key: 'help',
139
+ label: t('userPanel.help'),
140
+ },
141
+ ];
142
+
143
+ const mainItems = [
144
+ {
145
+ type: 'divider',
146
+ },
147
+ ...settings,
148
+ ...exports,
149
+ ...helps,
150
+ {
151
+ type: 'divider',
152
+ },
153
+ ].filter(Boolean) as MenuProps['items'];
154
+
155
+ return {
156
+ mainItems,
157
+ };
158
+ };
@@ -0,0 +1,12 @@
1
+ import { useGlobalStore } from '@/store/global';
2
+
3
+ export const useNewVersion = () => {
4
+ const [hasNewVersion, useCheckLatestVersion] = useGlobalStore((s) => [
5
+ s.hasNewVersion,
6
+ s.useCheckLatestVersion,
7
+ ]);
8
+
9
+ useCheckLatestVersion();
10
+
11
+ return hasNewVersion;
12
+ };
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import { useSession } from 'next-auth/react';
4
+ import { memo } from 'react';
5
+ import { createStoreUpdater } from 'zustand-utils';
6
+
7
+ import { useUserStore } from '@/store/user';
8
+ import { LobeUser } from '@/types/user';
9
+
10
+ // update the user data into the context
11
+ const UserUpdater = memo(() => {
12
+ const { data: session, status } = useSession();
13
+ const isSignedIn = (status === 'authenticated' && session && !!session.user) || false;
14
+
15
+ const nextUser = session?.user;
16
+ const useStoreUpdater = createStoreUpdater(useUserStore);
17
+
18
+ const lobeUser = {
19
+ avatar: nextUser?.image,
20
+ email: nextUser?.email,
21
+ fullName: nextUser?.name,
22
+ id: nextUser?.id,
23
+ } as LobeUser;
24
+
25
+ useStoreUpdater('isLoaded', true);
26
+ useStoreUpdater('user', lobeUser);
27
+ useStoreUpdater('isSignedIn', isSignedIn);
28
+
29
+ useStoreUpdater('nextSession', session);
30
+ useStoreUpdater('nextUser', nextUser);
31
+
32
+ return null;
33
+ });
34
+
35
+ export default UserUpdater;
@@ -3,8 +3,15 @@ import { PropsWithChildren } from 'react';
3
3
 
4
4
  import { API_ENDPOINTS } from '@/services/_url';
5
5
 
6
+ import UserUpdater from './UserUpdater';
7
+
6
8
  const NextAuth = ({ children }: PropsWithChildren) => {
7
- return <SessionProvider basePath={API_ENDPOINTS.oauth}>{children}</SessionProvider>;
9
+ return (
10
+ <SessionProvider basePath={API_ENDPOINTS.oauth}>
11
+ {children}
12
+ <UserUpdater />
13
+ </SessionProvider>
14
+ );
8
15
  };
9
16
 
10
17
  export default NextAuth;
@@ -152,4 +152,18 @@ export default {
152
152
  hasNew: '有可用更新',
153
153
  newVersion: '有新版本可用:{{version}}',
154
154
  },
155
+ userPanel: {
156
+ billing: '账单管理',
157
+ defaultNickname: '社区版用户',
158
+ discord: '社区支持',
159
+ docs: '使用文档',
160
+ email: '邮件支持',
161
+ feedback: '反馈与建议',
162
+ help: '帮助中心',
163
+ moveGuide: '设置按钮搬到这里啦',
164
+ plans: '订阅方案',
165
+ profile: '账户管理',
166
+ setting: '应用设置',
167
+ usages: '用量统计',
168
+ },
155
169
  };
@@ -1,18 +1,16 @@
1
- export interface LobeUser {
2
- avatar?: string;
3
- firstName?: string | null;
4
- fullName?: string | null;
5
- id: string;
6
- latestName?: string | null;
7
- username?: string | null;
8
- }
1
+ import { Session, User } from '@auth/core/types';
2
+
3
+ import { LobeUser } from '@/types/user';
9
4
 
10
5
  export interface UserAuthState {
11
6
  /**
12
7
  * @deprecated
13
8
  */
14
9
  avatar?: string;
10
+ isLoaded?: boolean;
15
11
  isSignedIn?: boolean;
12
+ nextSession?: Session;
13
+ nextUser?: User;
16
14
  user?: LobeUser;
17
15
  userId?: string;
18
16
  }
@@ -1,6 +1,9 @@
1
1
  import { UserStore } from '@/store/user';
2
+ import { LobeUser } from '@/types/user';
2
3
 
3
4
  export const userProfileSelectors = {
4
- userAvatar: (s: UserStore): string => s.avatar || '',
5
+ userAvatar: (s: UserStore): string => s.user?.avatar || s.avatar || '',
5
6
  userId: (s: UserStore) => s.userId,
7
+ userProfile: (s: UserStore): LobeUser | null | undefined => s.user,
8
+ username: (s: UserStore): string | null | undefined => s.user?.username,
6
9
  };
@@ -2,10 +2,9 @@ import { act, renderHook, waitFor } from '@testing-library/react';
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
3
  import { withSWR } from '~test-utils';
4
4
 
5
- import { globalService } from '@/services/global';
6
5
  import { useUserStore } from '@/store/user';
7
6
 
8
- import { type Guide } from './initialState';
7
+ import { DEFAULT_PREFERENCE, type Guide, UserPreference } from './initialState';
9
8
 
10
9
  beforeEach(() => {
11
10
  vi.clearAllMocks();
@@ -25,7 +24,7 @@ describe('createPreferenceSlice', () => {
25
24
  result.current.updateGuideState(guide);
26
25
  });
27
26
 
28
- expect(result.current.preference.guide).toEqual(guide);
27
+ expect(result.current.preference.guide!.topic).toBeTruthy();
29
28
  });
30
29
  });
31
30
 
@@ -58,5 +57,44 @@ describe('createPreferenceSlice', () => {
58
57
  expect(result.current.isPreferenceInit).toBeTruthy();
59
58
  });
60
59
  });
60
+ it('should return default preference when local storage is empty', async () => {
61
+ const { result } = renderHook(() => useUserStore());
62
+
63
+ vi.spyOn(result.current.preferenceStorage, 'getFromLocalStorage').mockResolvedValueOnce(
64
+ {} as any,
65
+ );
66
+
67
+ renderHook(() => result.current.useInitPreference(), {
68
+ wrapper: withSWR,
69
+ });
70
+
71
+ await waitFor(() => {
72
+ expect(result.current.preference).toEqual(DEFAULT_PREFERENCE);
73
+ expect(result.current.isPreferenceInit).toBeTruthy();
74
+ });
75
+ });
76
+
77
+ it('should return saved preference when local storage has data', async () => {
78
+ const { result } = renderHook(() => useUserStore());
79
+ const savedPreference: UserPreference = {
80
+ ...DEFAULT_PREFERENCE,
81
+ hideSyncAlert: true,
82
+ guide: { topic: false, moveSettingsToAvatar: true },
83
+ };
84
+
85
+ vi.spyOn(result.current.preferenceStorage, 'getFromLocalStorage').mockResolvedValueOnce(
86
+ savedPreference,
87
+ );
88
+
89
+ const { result: prefernce } = renderHook(() => result.current.useInitPreference(), {
90
+ wrapper: withSWR,
91
+ });
92
+
93
+ await waitFor(() => {
94
+ expect(prefernce.current.data).toEqual(savedPreference);
95
+ expect(result.current.isPreferenceInit).toBeTruthy();
96
+ expect(result.current.preference).toEqual(savedPreference);
97
+ });
98
+ });
61
99
  });
62
100
  });
@@ -6,7 +6,7 @@ import type { UserStore } from '@/store/user';
6
6
  import { merge } from '@/utils/merge';
7
7
  import { setNamespace } from '@/utils/storeDebug';
8
8
 
9
- import type { Guide, UserPreference } from './initialState';
9
+ import { DEFAULT_PREFERENCE, Guide, UserPreference } from './initialState';
10
10
 
11
11
  const n = setNamespace('preference');
12
12
 
@@ -41,7 +41,13 @@ export const createPreferenceSlice: StateCreator<
41
41
  () => get().preferenceStorage.getFromLocalStorage(),
42
42
  {
43
43
  onSuccess: (preference) => {
44
- set({ isPreferenceInit: true, preference }, false, n('initPreference'));
44
+ const isEmpty = Object.keys(preference).length === 0;
45
+
46
+ set(
47
+ { isPreferenceInit: true, preference: isEmpty ? DEFAULT_PREFERENCE : preference },
48
+ false,
49
+ n('initPreference'),
50
+ );
45
51
  },
46
52
  },
47
53
  ),
@@ -1,6 +1,11 @@
1
1
  import { AsyncLocalStorage } from '@/utils/localStorage';
2
2
 
3
3
  export interface Guide {
4
+ /**
5
+ * Move the settings button to the avatar dropdown
6
+ */
7
+ moveSettingsToAvatar?: boolean;
8
+
4
9
  // Topic 引导
5
10
  topic?: boolean;
6
11
  }
@@ -24,12 +29,16 @@ export interface UserPreferenceState {
24
29
  preferenceStorage: AsyncLocalStorage<UserPreference>;
25
30
  }
26
31
 
32
+ export const DEFAULT_PREFERENCE: UserPreference = {
33
+ guide: {
34
+ moveSettingsToAvatar: true,
35
+ },
36
+ telemetry: null,
37
+ useCmdEnterToSend: false,
38
+ };
39
+
27
40
  export const initialPreferenceState: UserPreferenceState = {
28
41
  isPreferenceInit: false,
29
- preference: {
30
- guide: {},
31
- telemetry: null,
32
- useCmdEnterToSend: false,
33
- },
42
+ preference: DEFAULT_PREFERENCE,
34
43
  preferenceStorage: new AsyncLocalStorage('LOBE_PREFERENCE'),
35
44
  };
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { UserStore } from '@/store/user';
4
+
5
+ import { initialPreferenceState } from './initialState';
6
+ import { preferenceSelectors } from './selectors';
7
+
8
+ describe('preferenceSelectors', () => {
9
+ let store: UserStore;
10
+
11
+ beforeEach(() => {
12
+ store = {
13
+ ...initialPreferenceState,
14
+ } as unknown as UserStore;
15
+ });
16
+
17
+ describe('useCmdEnterToSend', () => {
18
+ it('should return the value of useCmdEnterToSend preference', () => {
19
+ store.preference.useCmdEnterToSend = true;
20
+ expect(preferenceSelectors.useCmdEnterToSend(store)).toBe(true);
21
+
22
+ store.preference.useCmdEnterToSend = false;
23
+ expect(preferenceSelectors.useCmdEnterToSend(store)).toBe(false);
24
+ });
25
+
26
+ it('should return false if useCmdEnterToSend preference is undefined', () => {
27
+ store.preference.useCmdEnterToSend = undefined;
28
+ expect(preferenceSelectors.useCmdEnterToSend(store)).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe('userAllowTrace', () => {
33
+ it('should return the value of telemetry preference', () => {
34
+ store.preference.telemetry = true;
35
+ expect(preferenceSelectors.userAllowTrace(store)).toBe(true);
36
+
37
+ store.preference.telemetry = false;
38
+ expect(preferenceSelectors.userAllowTrace(store)).toBe(false);
39
+
40
+ store.preference.telemetry = null;
41
+ expect(preferenceSelectors.userAllowTrace(store)).toBe(null);
42
+ });
43
+ });
44
+
45
+ describe('hideSyncAlert', () => {
46
+ it('should return the value of hideSyncAlert preference', () => {
47
+ store.preference.hideSyncAlert = true;
48
+ expect(preferenceSelectors.hideSyncAlert(store)).toBe(true);
49
+
50
+ store.preference.hideSyncAlert = false;
51
+ expect(preferenceSelectors.hideSyncAlert(store)).toBe(false);
52
+
53
+ store.preference.hideSyncAlert = undefined;
54
+ expect(preferenceSelectors.hideSyncAlert(store)).toBeUndefined();
55
+ });
56
+ });
57
+
58
+ describe('hideSettingsMoveGuide', () => {
59
+ it('should return the value of moveSettingsToAvatar guide preference', () => {
60
+ store.preference.guide = { moveSettingsToAvatar: true };
61
+ expect(preferenceSelectors.hideSettingsMoveGuide(store)).toBe(true);
62
+
63
+ store.preference.guide = { moveSettingsToAvatar: false };
64
+ expect(preferenceSelectors.hideSettingsMoveGuide(store)).toBe(false);
65
+ });
66
+
67
+ it('should return undefined if guide preference is undefined', () => {
68
+ store.preference.guide = undefined;
69
+ expect(preferenceSelectors.hideSettingsMoveGuide(store)).toBeUndefined();
70
+ });
71
+ });
72
+
73
+ describe('isPreferenceInit', () => {
74
+ it('should return the value of isPreferenceInit state', () => {
75
+ store.isPreferenceInit = true;
76
+ expect(preferenceSelectors.isPreferenceInit(store)).toBe(true);
77
+
78
+ store.isPreferenceInit = false;
79
+ expect(preferenceSelectors.isPreferenceInit(store)).toBe(false);
80
+ });
81
+ });
82
+ });
@@ -5,9 +5,13 @@ const useCmdEnterToSend = (s: UserStore): boolean => s.preference.useCmdEnterToS
5
5
  const userAllowTrace = (s: UserStore) => s.preference.telemetry;
6
6
 
7
7
  const hideSyncAlert = (s: UserStore) => s.preference.hideSyncAlert;
8
+
9
+ const hideSettingsMoveGuide = (s: UserStore) => s.preference.guide?.moveSettingsToAvatar;
10
+
8
11
  const isPreferenceInit = (s: UserStore) => s.isPreferenceInit;
9
12
 
10
13
  export const preferenceSelectors = {
14
+ hideSettingsMoveGuide,
11
15
  hideSyncAlert,
12
16
  isPreferenceInit,
13
17
  useCmdEnterToSend,
@@ -5,8 +5,10 @@ import type { StateCreator } from 'zustand/vanilla';
5
5
 
6
6
  import { userService } from '@/services/user';
7
7
  import type { UserStore } from '@/store/user';
8
+ import { LocaleMode } from '@/types/locale';
8
9
  import { LobeAgentSettings } from '@/types/session';
9
10
  import { GlobalSettings } from '@/types/settings';
11
+ import { switchLang } from '@/utils/client/switchLang';
10
12
  import { difference } from '@/utils/difference';
11
13
  import { merge } from '@/utils/merge';
12
14
 
@@ -14,6 +16,7 @@ export interface GeneralSettingsAction {
14
16
  importAppSettings: (settings: GlobalSettings) => Promise<void>;
15
17
  resetSettings: () => Promise<void>;
16
18
  setSettings: (settings: DeepPartial<GlobalSettings>) => Promise<void>;
19
+ switchLocale: (locale: LocaleMode) => Promise<void>;
17
20
  switchThemeMode: (themeMode: ThemeMode) => Promise<void>;
18
21
  updateDefaultAgent: (agent: DeepPartial<LobeAgentSettings>) => Promise<void>;
19
22
  }
@@ -47,6 +50,11 @@ export const generalSettingsSlice: StateCreator<
47
50
  await userService.updateUserSettings(diffs);
48
51
  await get().refreshUserConfig();
49
52
  },
53
+ switchLocale: async (locale) => {
54
+ await get().setSettings({ language: locale });
55
+
56
+ switchLang(locale);
57
+ },
50
58
  switchThemeMode: async (themeMode) => {
51
59
  await get().setSettings({ themeMode });
52
60
  },
@@ -0,0 +1,9 @@
1
+ export interface LobeUser {
2
+ avatar?: string;
3
+ email?: string | null;
4
+ firstName?: string | null;
5
+ fullName?: string | null;
6
+ id: string;
7
+ latestName?: string | null;
8
+ username?: string | null;
9
+ }
@@ -1,7 +0,0 @@
1
- import { redirect } from 'next/navigation';
2
-
3
- const Page = () => {
4
- return redirect('/settings/common');
5
- };
6
-
7
- export default Page;