@lobehub/chat 1.70.4 → 1.70.5
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.
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +9 -0
- package/package.json +1 -1
- package/src/app/[variants]/(main)/(mobile)/me/(home)/features/Header.tsx +2 -2
- package/src/app/[variants]/(main)/settings/common/features/Theme/index.tsx +5 -3
- package/src/const/settings/common.ts +0 -1
- package/src/features/User/UserPanel/ThemeButton.tsx +4 -4
- package/src/layout/GlobalProvider/AppTheme.tsx +5 -9
- package/src/services/user/_deprecated.test.ts +1 -1
- package/src/services/user/client.test.ts +1 -1
- package/src/store/global/action.test.ts +15 -0
- package/src/store/global/actions/__tests__/general.test.ts +221 -0
- package/src/store/global/actions/general.ts +10 -1
- package/src/store/global/initialState.ts +6 -0
- package/src/store/global/selectors/systemStatus.test.ts +209 -0
- package/src/store/global/selectors/systemStatus.ts +22 -21
- package/src/store/user/slices/settings/action.test.ts +1 -19
- package/src/store/user/slices/settings/action.ts +0 -5
- package/src/store/user/slices/settings/selectors/general.test.ts +5 -15
- package/src/store/user/slices/settings/selectors/general.ts +0 -6
- package/src/types/user/settings/general.ts +0 -2
package/CHANGELOG.md
CHANGED
@@ -2,6 +2,31 @@
|
|
2
2
|
|
3
3
|
# Changelog
|
4
4
|
|
5
|
+
### [Version 1.70.5](https://github.com/lobehub/lobe-chat/compare/v1.70.4...v1.70.5)
|
6
|
+
|
7
|
+
<sup>Released on **2025-03-11**</sup>
|
8
|
+
|
9
|
+
#### 🐛 Bug Fixes
|
10
|
+
|
11
|
+
- **misc**: Refactor the theme implement.
|
12
|
+
|
13
|
+
<br/>
|
14
|
+
|
15
|
+
<details>
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
17
|
+
|
18
|
+
#### What's fixed
|
19
|
+
|
20
|
+
- **misc**: Refactor the theme implement, closes [#6844](https://github.com/lobehub/lobe-chat/issues/6844) ([e5c2161](https://github.com/lobehub/lobe-chat/commit/e5c2161))
|
21
|
+
|
22
|
+
</details>
|
23
|
+
|
24
|
+
<div align="right">
|
25
|
+
|
26
|
+
[](#readme-top)
|
27
|
+
|
28
|
+
</div>
|
29
|
+
|
5
30
|
### [Version 1.70.4](https://github.com/lobehub/lobe-chat/compare/v1.70.3...v1.70.4)
|
6
31
|
|
7
32
|
<sup>Released on **2025-03-11**</sup>
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@lobehub/chat",
|
3
|
-
"version": "1.70.
|
3
|
+
"version": "1.70.5",
|
4
4
|
"description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
5
5
|
"keywords": [
|
6
6
|
"framework",
|
@@ -7,12 +7,12 @@ import { Moon, Sun } from 'lucide-react';
|
|
7
7
|
import { memo } from 'react';
|
8
8
|
|
9
9
|
import { MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens';
|
10
|
-
import {
|
10
|
+
import { useGlobalStore } from '@/store/global';
|
11
11
|
import { mobileHeaderSticky } from '@/styles/mobileHeader';
|
12
12
|
|
13
13
|
const Header = memo(() => {
|
14
14
|
const theme = useTheme();
|
15
|
-
const switchThemeMode =
|
15
|
+
const switchThemeMode = useGlobalStore((s) => s.switchThemeMode);
|
16
16
|
|
17
17
|
return (
|
18
18
|
<MobileNavBar
|
@@ -12,8 +12,9 @@ import { FORM_STYLE } from '@/const/layoutTokens';
|
|
12
12
|
import { imageUrl } from '@/const/url';
|
13
13
|
import { Locales, localeOptions } from '@/locales/resources';
|
14
14
|
import { useGlobalStore } from '@/store/global';
|
15
|
+
import { systemStatusSelectors } from '@/store/global/selectors';
|
15
16
|
import { useUserStore } from '@/store/user';
|
16
|
-
import { settingsSelectors
|
17
|
+
import { settingsSelectors } from '@/store/user/selectors';
|
17
18
|
|
18
19
|
import { ThemeSwatchesNeutral, ThemeSwatchesPrimary } from './ThemeSwatches';
|
19
20
|
|
@@ -24,8 +25,9 @@ const Theme = memo(() => {
|
|
24
25
|
|
25
26
|
const [form] = Form.useForm();
|
26
27
|
const settings = useUserStore(settingsSelectors.currentSettings, isEqual);
|
27
|
-
const themeMode =
|
28
|
-
const [
|
28
|
+
const themeMode = useGlobalStore(systemStatusSelectors.themeMode);
|
29
|
+
const [setSettings] = useUserStore((s) => [s.setSettings]);
|
30
|
+
const [setThemeMode] = useGlobalStore((s) => [s.switchThemeMode]);
|
29
31
|
|
30
32
|
useSyncSettings(form);
|
31
33
|
const [switchLocale] = useGlobalStore((s) => [s.switchLocale]);
|
@@ -6,8 +6,8 @@ import { memo, useMemo } from 'react';
|
|
6
6
|
import { useTranslation } from 'react-i18next';
|
7
7
|
|
8
8
|
import Menu, { type MenuProps } from '@/components/Menu';
|
9
|
-
import {
|
10
|
-
import {
|
9
|
+
import { useGlobalStore } from '@/store/global';
|
10
|
+
import { systemStatusSelectors } from '@/store/global/selectors';
|
11
11
|
|
12
12
|
const themeIcons = {
|
13
13
|
auto: Monitor,
|
@@ -17,8 +17,8 @@ const themeIcons = {
|
|
17
17
|
|
18
18
|
const ThemeButton = memo<{ placement?: PopoverProps['placement'] }>(({ placement = 'right' }) => {
|
19
19
|
const theme = useTheme();
|
20
|
-
const [themeMode, switchThemeMode] =
|
21
|
-
|
20
|
+
const [themeMode, switchThemeMode] = useGlobalStore((s) => [
|
21
|
+
systemStatusSelectors.themeMode(s),
|
22
22
|
s.switchThemeMode,
|
23
23
|
]);
|
24
24
|
|
@@ -14,11 +14,9 @@ import Link from 'next/link';
|
|
14
14
|
import { ReactNode, memo, useEffect } from 'react';
|
15
15
|
|
16
16
|
import AntdStaticMethods from '@/components/AntdStaticMethods';
|
17
|
-
import {
|
18
|
-
|
19
|
-
|
20
|
-
LOBE_THEME_PRIMARY_COLOR,
|
21
|
-
} from '@/const/theme';
|
17
|
+
import { LOBE_THEME_NEUTRAL_COLOR, LOBE_THEME_PRIMARY_COLOR } from '@/const/theme';
|
18
|
+
import { useGlobalStore } from '@/store/global';
|
19
|
+
import { systemStatusSelectors } from '@/store/global/selectors';
|
22
20
|
import { useUserStore } from '@/store/user';
|
23
21
|
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
|
24
22
|
import { GlobalStyle } from '@/styles';
|
@@ -103,7 +101,7 @@ const AppTheme = memo<AppThemeProps>(
|
|
103
101
|
// console.debug('server:appearance', defaultAppearance);
|
104
102
|
// console.debug('server:primaryColor', defaultPrimaryColor);
|
105
103
|
// console.debug('server:neutralColor', defaultNeutralColor);
|
106
|
-
const themeMode =
|
104
|
+
const themeMode = useGlobalStore(systemStatusSelectors.themeMode);
|
107
105
|
const { styles, cx, theme } = useStyles();
|
108
106
|
const [primaryColor, neutralColor] = useUserStore((s) => [
|
109
107
|
userGeneralSettingsSelectors.primaryColor(s),
|
@@ -120,15 +118,13 @@ const AppTheme = memo<AppThemeProps>(
|
|
120
118
|
|
121
119
|
return (
|
122
120
|
<ThemeProvider
|
121
|
+
appearance={themeMode !== 'auto' ? themeMode : undefined}
|
123
122
|
className={cx(styles.app, styles.scrollbar, styles.scrollbarPolyfill)}
|
124
123
|
customTheme={{
|
125
124
|
neutralColor: neutralColor ?? defaultNeutralColor,
|
126
125
|
primaryColor: primaryColor ?? defaultPrimaryColor,
|
127
126
|
}}
|
128
127
|
defaultAppearance={defaultAppearance}
|
129
|
-
onAppearanceChange={(appearance) => {
|
130
|
-
setCookie(LOBE_THEME_APPEARANCE, appearance);
|
131
|
-
}}
|
132
128
|
theme={{
|
133
129
|
cssVar: true,
|
134
130
|
token: {
|
@@ -57,7 +57,7 @@ describe('ClientService', () => {
|
|
57
57
|
});
|
58
58
|
|
59
59
|
it('should update user settings correctly', async () => {
|
60
|
-
const settingsPatch: DeepPartial<UserSettings> = { general: {
|
60
|
+
const settingsPatch: DeepPartial<UserSettings> = { general: { fontSize: 12 } };
|
61
61
|
(UserModel.updateSettings as Mock).mockResolvedValue(undefined);
|
62
62
|
|
63
63
|
await clientService.updateUserSettings(settingsPatch);
|
@@ -54,7 +54,7 @@ describe('ClientService', () => {
|
|
54
54
|
});
|
55
55
|
|
56
56
|
it('should update user settings correctly', async () => {
|
57
|
-
const settingsPatch: DeepPartial<UserSettings> = { general: {
|
57
|
+
const settingsPatch: DeepPartial<UserSettings> = { general: { fontSize: 12 } };
|
58
58
|
|
59
59
|
await clientService.updateUserSettings(settingsPatch);
|
60
60
|
|
@@ -408,4 +408,19 @@ describe('createPreferenceSlice', () => {
|
|
408
408
|
expect(result.current.status.inputHeight).toEqual(300);
|
409
409
|
});
|
410
410
|
});
|
411
|
+
|
412
|
+
describe('switchThemeMode', () => {
|
413
|
+
it('should switch theme mode', async () => {
|
414
|
+
const { result } = renderHook(() => useGlobalStore());
|
415
|
+
|
416
|
+
// Perform the action
|
417
|
+
act(() => {
|
418
|
+
useGlobalStore.setState({ isStatusInit: true });
|
419
|
+
result.current.switchThemeMode('light');
|
420
|
+
});
|
421
|
+
|
422
|
+
// Assert that updateUserSettings was called with the correct theme mode
|
423
|
+
expect(result.current.status.themeMode).toEqual('light');
|
424
|
+
});
|
425
|
+
});
|
411
426
|
});
|
@@ -0,0 +1,221 @@
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
2
|
+
import { ThemeMode } from 'antd-style';
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
4
|
+
import { withSWR } from '~test-utils';
|
5
|
+
|
6
|
+
import { CURRENT_VERSION } from '@/const/version';
|
7
|
+
import { globalService } from '@/services/global';
|
8
|
+
import { useGlobalStore } from '@/store/global';
|
9
|
+
import { initialState } from '@/store/global/initialState';
|
10
|
+
import { switchLang } from '@/utils/client/switchLang';
|
11
|
+
|
12
|
+
vi.mock('@/utils/client/switchLang', () => ({
|
13
|
+
switchLang: vi.fn(),
|
14
|
+
}));
|
15
|
+
|
16
|
+
vi.mock('@/services/global', () => ({
|
17
|
+
globalService: {
|
18
|
+
getLatestVersion: vi.fn(),
|
19
|
+
},
|
20
|
+
}));
|
21
|
+
|
22
|
+
describe('generalActionSlice', () => {
|
23
|
+
beforeEach(() => {
|
24
|
+
vi.clearAllMocks();
|
25
|
+
useGlobalStore.setState(initialState);
|
26
|
+
});
|
27
|
+
|
28
|
+
afterEach(() => {
|
29
|
+
vi.restoreAllMocks();
|
30
|
+
});
|
31
|
+
|
32
|
+
describe('updateSystemStatus', () => {
|
33
|
+
it('should not update status when not initialized', () => {
|
34
|
+
const { result } = renderHook(() => useGlobalStore());
|
35
|
+
|
36
|
+
act(() => {
|
37
|
+
result.current.updateSystemStatus({ inputHeight: 200 });
|
38
|
+
});
|
39
|
+
|
40
|
+
expect(result.current.status).toEqual(initialState.status);
|
41
|
+
});
|
42
|
+
|
43
|
+
it('should update status when initialized', () => {
|
44
|
+
const { result } = renderHook(() => useGlobalStore());
|
45
|
+
|
46
|
+
act(() => {
|
47
|
+
useGlobalStore.setState({ isStatusInit: true });
|
48
|
+
result.current.updateSystemStatus({ inputHeight: 200 });
|
49
|
+
});
|
50
|
+
|
51
|
+
expect(result.current.status.inputHeight).toBe(200);
|
52
|
+
});
|
53
|
+
|
54
|
+
it('should not update if new status equals current status', () => {
|
55
|
+
const { result } = renderHook(() => useGlobalStore());
|
56
|
+
const saveToLocalStorageSpy = vi.spyOn(result.current.statusStorage, 'saveToLocalStorage');
|
57
|
+
|
58
|
+
act(() => {
|
59
|
+
useGlobalStore.setState({ isStatusInit: true });
|
60
|
+
result.current.updateSystemStatus({ inputHeight: initialState.status.inputHeight });
|
61
|
+
});
|
62
|
+
|
63
|
+
expect(saveToLocalStorageSpy).not.toHaveBeenCalled();
|
64
|
+
});
|
65
|
+
|
66
|
+
it('should save to localStorage when status is updated', () => {
|
67
|
+
const { result } = renderHook(() => useGlobalStore());
|
68
|
+
const saveToLocalStorageSpy = vi.spyOn(result.current.statusStorage, 'saveToLocalStorage');
|
69
|
+
|
70
|
+
act(() => {
|
71
|
+
useGlobalStore.setState({ isStatusInit: true });
|
72
|
+
result.current.updateSystemStatus({ inputHeight: 300 });
|
73
|
+
});
|
74
|
+
|
75
|
+
expect(saveToLocalStorageSpy).toHaveBeenCalledWith(
|
76
|
+
expect.objectContaining({ inputHeight: 300 }),
|
77
|
+
);
|
78
|
+
});
|
79
|
+
|
80
|
+
it('should merge nested objects correctly', () => {
|
81
|
+
const { result } = renderHook(() => useGlobalStore());
|
82
|
+
|
83
|
+
act(() => {
|
84
|
+
useGlobalStore.setState({ isStatusInit: true });
|
85
|
+
result.current.updateSystemStatus({
|
86
|
+
expandSessionGroupKeys: ['test1', 'test2'],
|
87
|
+
});
|
88
|
+
});
|
89
|
+
|
90
|
+
expect(result.current.status.expandSessionGroupKeys).toEqual(['test1', 'test2']);
|
91
|
+
});
|
92
|
+
});
|
93
|
+
|
94
|
+
describe('switchLocale', () => {
|
95
|
+
it('should update language in system status and call switchLang', () => {
|
96
|
+
const { result } = renderHook(() => useGlobalStore());
|
97
|
+
const locale = 'zh-CN';
|
98
|
+
|
99
|
+
act(() => {
|
100
|
+
useGlobalStore.setState({ isStatusInit: true });
|
101
|
+
result.current.switchLocale(locale);
|
102
|
+
});
|
103
|
+
|
104
|
+
expect(result.current.status.language).toBe(locale);
|
105
|
+
expect(switchLang).toHaveBeenCalledWith(locale);
|
106
|
+
});
|
107
|
+
|
108
|
+
it('should not update language if status is not initialized', () => {
|
109
|
+
const { result } = renderHook(() => useGlobalStore());
|
110
|
+
const locale = 'zh-CN';
|
111
|
+
|
112
|
+
act(() => {
|
113
|
+
result.current.switchLocale(locale);
|
114
|
+
});
|
115
|
+
|
116
|
+
expect(result.current.status.language).toBeUndefined();
|
117
|
+
});
|
118
|
+
});
|
119
|
+
|
120
|
+
describe('switchThemeMode', () => {
|
121
|
+
it('should update theme mode in system status', () => {
|
122
|
+
const { result } = renderHook(() => useGlobalStore());
|
123
|
+
const themeMode: ThemeMode = 'dark';
|
124
|
+
|
125
|
+
act(() => {
|
126
|
+
useGlobalStore.setState({ isStatusInit: true });
|
127
|
+
result.current.switchThemeMode(themeMode);
|
128
|
+
});
|
129
|
+
|
130
|
+
expect(result.current.status.themeMode).toBe(themeMode);
|
131
|
+
});
|
132
|
+
|
133
|
+
it('should not update theme mode if status is not initialized', () => {
|
134
|
+
const { result } = renderHook(() => useGlobalStore());
|
135
|
+
const themeMode: ThemeMode = 'dark';
|
136
|
+
|
137
|
+
act(() => {
|
138
|
+
result.current.switchThemeMode(themeMode);
|
139
|
+
});
|
140
|
+
|
141
|
+
expect(result.current.status.themeMode).toBe(initialState.status.themeMode);
|
142
|
+
});
|
143
|
+
|
144
|
+
it('should handle light theme mode', () => {
|
145
|
+
const { result } = renderHook(() => useGlobalStore());
|
146
|
+
|
147
|
+
act(() => {
|
148
|
+
useGlobalStore.setState({ isStatusInit: true });
|
149
|
+
result.current.switchThemeMode('light');
|
150
|
+
});
|
151
|
+
|
152
|
+
expect(result.current.status.themeMode).toBe('light');
|
153
|
+
});
|
154
|
+
});
|
155
|
+
|
156
|
+
describe('useCheckLatestVersion', () => {
|
157
|
+
it('should not fetch version when check is disabled', () => {
|
158
|
+
const getLatestVersionSpy = vi.spyOn(globalService, 'getLatestVersion');
|
159
|
+
|
160
|
+
renderHook(() => useGlobalStore().useCheckLatestVersion(false), {
|
161
|
+
wrapper: withSWR,
|
162
|
+
});
|
163
|
+
|
164
|
+
expect(getLatestVersionSpy).not.toHaveBeenCalled();
|
165
|
+
});
|
166
|
+
|
167
|
+
it('should set hasNewVersion when major version is newer', async () => {
|
168
|
+
const latestVersion = '999.0.0';
|
169
|
+
vi.spyOn(globalService, 'getLatestVersion').mockResolvedValueOnce(latestVersion);
|
170
|
+
|
171
|
+
const { result } = renderHook(() => useGlobalStore().useCheckLatestVersion(), {
|
172
|
+
wrapper: withSWR,
|
173
|
+
});
|
174
|
+
|
175
|
+
await act(async () => {
|
176
|
+
await result.current.data;
|
177
|
+
});
|
178
|
+
|
179
|
+
expect(useGlobalStore.getState().hasNewVersion).toBe(true);
|
180
|
+
expect(useGlobalStore.getState().latestVersion).toBe(latestVersion);
|
181
|
+
});
|
182
|
+
|
183
|
+
it('should not set hasNewVersion for same major.minor version', async () => {
|
184
|
+
const currentParts = CURRENT_VERSION.split('.');
|
185
|
+
const latestVersion = `${currentParts[0]}.${currentParts[1]}.999`;
|
186
|
+
vi.spyOn(globalService, 'getLatestVersion').mockResolvedValueOnce(latestVersion);
|
187
|
+
|
188
|
+
const { result } = renderHook(() => useGlobalStore().useCheckLatestVersion(), {
|
189
|
+
wrapper: withSWR,
|
190
|
+
});
|
191
|
+
|
192
|
+
await act(async () => {
|
193
|
+
await result.current.data;
|
194
|
+
});
|
195
|
+
|
196
|
+
// Reset hasNewVersion and latestVersion
|
197
|
+
useGlobalStore.setState({ hasNewVersion: undefined, latestVersion: undefined });
|
198
|
+
|
199
|
+
expect(useGlobalStore.getState().hasNewVersion).toBeUndefined();
|
200
|
+
expect(useGlobalStore.getState().latestVersion).toBeUndefined();
|
201
|
+
});
|
202
|
+
|
203
|
+
it('should not set hasNewVersion when version is invalid', async () => {
|
204
|
+
vi.spyOn(globalService, 'getLatestVersion').mockResolvedValueOnce('invalid.version');
|
205
|
+
|
206
|
+
const { result } = renderHook(() => useGlobalStore().useCheckLatestVersion(), {
|
207
|
+
wrapper: withSWR,
|
208
|
+
});
|
209
|
+
|
210
|
+
await act(async () => {
|
211
|
+
await result.current.data;
|
212
|
+
});
|
213
|
+
|
214
|
+
// Reset hasNewVersion and latestVersion
|
215
|
+
useGlobalStore.setState({ hasNewVersion: undefined, latestVersion: undefined });
|
216
|
+
|
217
|
+
expect(useGlobalStore.getState().hasNewVersion).toBeUndefined();
|
218
|
+
expect(useGlobalStore.getState().latestVersion).toBeUndefined();
|
219
|
+
});
|
220
|
+
});
|
221
|
+
});
|
@@ -1,13 +1,16 @@
|
|
1
|
+
import { ThemeMode } from 'antd-style';
|
1
2
|
import isEqual from 'fast-deep-equal';
|
2
3
|
import { gt, parse, valid } from 'semver';
|
3
4
|
import { SWRResponse } from 'swr';
|
4
5
|
import type { StateCreator } from 'zustand/vanilla';
|
5
6
|
|
7
|
+
import { LOBE_THEME_APPEARANCE } from '@/const/theme';
|
6
8
|
import { CURRENT_VERSION } from '@/const/version';
|
7
9
|
import { useOnlyFetchOnceSWR } from '@/libs/swr';
|
8
10
|
import { globalService } from '@/services/global';
|
9
11
|
import type { SystemStatus } from '@/store/global/initialState';
|
10
12
|
import { LocaleMode } from '@/types/locale';
|
13
|
+
import { setCookie } from '@/utils/client/cookie';
|
11
14
|
import { switchLang } from '@/utils/client/switchLang';
|
12
15
|
import { merge } from '@/utils/merge';
|
13
16
|
import { setNamespace } from '@/utils/storeDebug';
|
@@ -18,6 +21,7 @@ const n = setNamespace('g');
|
|
18
21
|
|
19
22
|
export interface GlobalGeneralAction {
|
20
23
|
switchLocale: (locale: LocaleMode) => void;
|
24
|
+
switchThemeMode: (themeMode: ThemeMode) => void;
|
21
25
|
updateSystemStatus: (status: Partial<SystemStatus>, action?: any) => void;
|
22
26
|
useCheckLatestVersion: (enabledCheck?: boolean) => SWRResponse<string>;
|
23
27
|
useInitSystemStatus: () => SWRResponse;
|
@@ -34,15 +38,20 @@ export const generalActionSlice: StateCreator<
|
|
34
38
|
|
35
39
|
switchLang(locale);
|
36
40
|
},
|
41
|
+
switchThemeMode: (themeMode) => {
|
42
|
+
get().updateSystemStatus({ themeMode });
|
43
|
+
|
44
|
+
setCookie(LOBE_THEME_APPEARANCE, themeMode === 'auto' ? undefined : themeMode);
|
45
|
+
},
|
37
46
|
updateSystemStatus: (status, action) => {
|
38
47
|
// Status cannot be modified when it is not initialized
|
39
48
|
if (!get().isStatusInit) return;
|
40
49
|
|
41
50
|
const nextStatus = merge(get().status, status);
|
51
|
+
|
42
52
|
if (isEqual(get().status, nextStatus)) return;
|
43
53
|
|
44
54
|
set({ status: nextStatus }, false, action || n('updateSystemStatus'));
|
45
|
-
|
46
55
|
get().statusStorage.saveToLocalStorage(nextStatus);
|
47
56
|
},
|
48
57
|
|
@@ -1,3 +1,4 @@
|
|
1
|
+
import type { ThemeMode } from 'antd-style';
|
1
2
|
import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime';
|
2
3
|
|
3
4
|
import { DatabaseLoadingState } from '@/types/clientDB';
|
@@ -61,6 +62,10 @@ export interface SystemStatus {
|
|
61
62
|
showFilePanel?: boolean;
|
62
63
|
showSessionPanel?: boolean;
|
63
64
|
showSystemRole?: boolean;
|
65
|
+
/**
|
66
|
+
* theme mode
|
67
|
+
*/
|
68
|
+
themeMode?: ThemeMode;
|
64
69
|
threadInputHeight: number;
|
65
70
|
zenMode?: boolean;
|
66
71
|
}
|
@@ -96,6 +101,7 @@ export const INITIAL_STATUS = {
|
|
96
101
|
showFilePanel: true,
|
97
102
|
showSessionPanel: true,
|
98
103
|
showSystemRole: false,
|
104
|
+
themeMode: 'auto',
|
99
105
|
threadInputHeight: 200,
|
100
106
|
zenMode: false,
|
101
107
|
} satisfies SystemStatus;
|
@@ -0,0 +1,209 @@
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
2
|
+
|
3
|
+
import { DatabaseLoadingState } from '@/types/clientDB';
|
4
|
+
import { merge } from '@/utils/merge';
|
5
|
+
|
6
|
+
import { GlobalState, INITIAL_STATUS, initialState } from '../initialState';
|
7
|
+
import { systemStatusSelectors } from './systemStatus';
|
8
|
+
|
9
|
+
// Mock version constants
|
10
|
+
vi.mock('@/const/version', () => ({
|
11
|
+
isServerMode: false,
|
12
|
+
isUsePgliteDB: true,
|
13
|
+
}));
|
14
|
+
|
15
|
+
describe('systemStatusSelectors', () => {
|
16
|
+
describe('sessionGroupKeys', () => {
|
17
|
+
it('should return expandSessionGroupKeys from status', () => {
|
18
|
+
const s: GlobalState = merge(initialState, {
|
19
|
+
status: {
|
20
|
+
expandSessionGroupKeys: ['group1', 'group2'],
|
21
|
+
},
|
22
|
+
});
|
23
|
+
expect(systemStatusSelectors.sessionGroupKeys(s)).toEqual(['group1', 'group2']);
|
24
|
+
});
|
25
|
+
|
26
|
+
it('should return initial value if not set', () => {
|
27
|
+
const s: GlobalState = merge(initialState, {
|
28
|
+
status: {
|
29
|
+
expandSessionGroupKeys: undefined,
|
30
|
+
},
|
31
|
+
});
|
32
|
+
expect(systemStatusSelectors.sessionGroupKeys(s)).toEqual(
|
33
|
+
INITIAL_STATUS.expandSessionGroupKeys,
|
34
|
+
);
|
35
|
+
});
|
36
|
+
});
|
37
|
+
|
38
|
+
describe('basic selectors', () => {
|
39
|
+
const s: GlobalState = merge(initialState, {
|
40
|
+
status: {
|
41
|
+
showSystemRole: true,
|
42
|
+
mobileShowTopic: true,
|
43
|
+
mobileShowPortal: true,
|
44
|
+
showChatSideBar: true,
|
45
|
+
showSessionPanel: true,
|
46
|
+
showFilePanel: true,
|
47
|
+
hidePWAInstaller: true,
|
48
|
+
isShowCredit: true,
|
49
|
+
zenMode: false,
|
50
|
+
sessionsWidth: 300,
|
51
|
+
portalWidth: 500,
|
52
|
+
filePanelWidth: 400,
|
53
|
+
inputHeight: 150,
|
54
|
+
threadInputHeight: 100,
|
55
|
+
},
|
56
|
+
});
|
57
|
+
|
58
|
+
it('should return correct values for basic selectors', () => {
|
59
|
+
expect(systemStatusSelectors.showSystemRole(s)).toBe(true);
|
60
|
+
expect(systemStatusSelectors.mobileShowTopic(s)).toBe(true);
|
61
|
+
expect(systemStatusSelectors.mobileShowPortal(s)).toBe(true);
|
62
|
+
expect(systemStatusSelectors.showChatSideBar(s)).toBe(true);
|
63
|
+
expect(systemStatusSelectors.showSessionPanel(s)).toBe(true);
|
64
|
+
expect(systemStatusSelectors.showFilePanel(s)).toBe(true);
|
65
|
+
expect(systemStatusSelectors.hidePWAInstaller(s)).toBe(true);
|
66
|
+
expect(systemStatusSelectors.isShowCredit(s)).toBe(true);
|
67
|
+
expect(systemStatusSelectors.showChatHeader(s)).toBe(true);
|
68
|
+
expect(systemStatusSelectors.inZenMode(s)).toBe(false);
|
69
|
+
expect(systemStatusSelectors.sessionWidth(s)).toBe(300);
|
70
|
+
expect(systemStatusSelectors.portalWidth(s)).toBe(500);
|
71
|
+
expect(systemStatusSelectors.filePanelWidth(s)).toBe(400);
|
72
|
+
expect(systemStatusSelectors.inputHeight(s)).toBe(150);
|
73
|
+
expect(systemStatusSelectors.threadInputHeight(s)).toBe(100);
|
74
|
+
});
|
75
|
+
|
76
|
+
it('should handle zen mode effects', () => {
|
77
|
+
const zenState = merge(s, {
|
78
|
+
status: { zenMode: true },
|
79
|
+
});
|
80
|
+
expect(systemStatusSelectors.showChatSideBar(zenState)).toBe(false);
|
81
|
+
expect(systemStatusSelectors.showSessionPanel(zenState)).toBe(false);
|
82
|
+
expect(systemStatusSelectors.showChatHeader(zenState)).toBe(false);
|
83
|
+
});
|
84
|
+
|
85
|
+
it('should return default portal width if not set', () => {
|
86
|
+
const noPortalWidth = merge(initialState, {
|
87
|
+
status: { portalWidth: undefined },
|
88
|
+
});
|
89
|
+
expect(systemStatusSelectors.portalWidth(noPortalWidth)).toBe(400);
|
90
|
+
});
|
91
|
+
});
|
92
|
+
|
93
|
+
describe('theme mode', () => {
|
94
|
+
it('should return the correct theme', () => {
|
95
|
+
const s: GlobalState = merge(initialState, {
|
96
|
+
status: {
|
97
|
+
themeMode: 'light',
|
98
|
+
},
|
99
|
+
});
|
100
|
+
expect(systemStatusSelectors.themeMode(s)).toBe('light');
|
101
|
+
});
|
102
|
+
|
103
|
+
it('should return auto if not set', () => {
|
104
|
+
const s: GlobalState = merge(initialState, {
|
105
|
+
status: {
|
106
|
+
themeMode: undefined,
|
107
|
+
},
|
108
|
+
});
|
109
|
+
expect(systemStatusSelectors.themeMode(s)).toBe('auto');
|
110
|
+
});
|
111
|
+
});
|
112
|
+
|
113
|
+
describe('pglite status selectors', () => {
|
114
|
+
describe('isPgliteNotEnabled', () => {
|
115
|
+
it('should return true when conditions are met', () => {
|
116
|
+
const s: GlobalState = {
|
117
|
+
...initialState,
|
118
|
+
isStatusInit: true,
|
119
|
+
status: {
|
120
|
+
...initialState.status,
|
121
|
+
isEnablePglite: false,
|
122
|
+
},
|
123
|
+
};
|
124
|
+
expect(systemStatusSelectors.isPgliteNotEnabled(s)).toBe(true);
|
125
|
+
});
|
126
|
+
|
127
|
+
it('should return false when isStatusInit is false', () => {
|
128
|
+
const s: GlobalState = {
|
129
|
+
...initialState,
|
130
|
+
isStatusInit: false,
|
131
|
+
status: {
|
132
|
+
...initialState.status,
|
133
|
+
isEnablePglite: false,
|
134
|
+
},
|
135
|
+
};
|
136
|
+
expect(systemStatusSelectors.isPgliteNotEnabled(s)).toBe(false);
|
137
|
+
});
|
138
|
+
});
|
139
|
+
|
140
|
+
describe('isPgliteNotInited', () => {
|
141
|
+
it('should return true when pglite is enabled but not ready', () => {
|
142
|
+
const s: GlobalState = {
|
143
|
+
...initialState,
|
144
|
+
isStatusInit: true,
|
145
|
+
status: {
|
146
|
+
...initialState.status,
|
147
|
+
isEnablePglite: true,
|
148
|
+
},
|
149
|
+
initClientDBStage: DatabaseLoadingState.Initializing,
|
150
|
+
};
|
151
|
+
expect(systemStatusSelectors.isPgliteNotInited(s)).toBe(true);
|
152
|
+
});
|
153
|
+
|
154
|
+
it('should return false when pglite is ready', () => {
|
155
|
+
const s: GlobalState = {
|
156
|
+
...initialState,
|
157
|
+
isStatusInit: true,
|
158
|
+
status: {
|
159
|
+
...initialState.status,
|
160
|
+
isEnablePglite: true,
|
161
|
+
},
|
162
|
+
initClientDBStage: DatabaseLoadingState.Ready,
|
163
|
+
};
|
164
|
+
expect(systemStatusSelectors.isPgliteNotInited(s)).toBe(false);
|
165
|
+
});
|
166
|
+
|
167
|
+
it('should return false when pglite is not enabled', () => {
|
168
|
+
const s: GlobalState = {
|
169
|
+
...initialState,
|
170
|
+
isStatusInit: true,
|
171
|
+
status: {
|
172
|
+
...initialState.status,
|
173
|
+
isEnablePglite: false,
|
174
|
+
},
|
175
|
+
initClientDBStage: DatabaseLoadingState.Initializing,
|
176
|
+
};
|
177
|
+
expect(systemStatusSelectors.isPgliteNotInited(s)).toBe(false);
|
178
|
+
});
|
179
|
+
});
|
180
|
+
|
181
|
+
describe('isPgliteInited', () => {
|
182
|
+
it('should return true when pglite is enabled and ready', () => {
|
183
|
+
const s: GlobalState = {
|
184
|
+
...initialState,
|
185
|
+
isStatusInit: true,
|
186
|
+
status: {
|
187
|
+
...initialState.status,
|
188
|
+
isEnablePglite: true,
|
189
|
+
},
|
190
|
+
initClientDBStage: DatabaseLoadingState.Ready,
|
191
|
+
};
|
192
|
+
expect(systemStatusSelectors.isPgliteInited(s)).toBe(true);
|
193
|
+
});
|
194
|
+
|
195
|
+
it('should return false when not ready', () => {
|
196
|
+
const s: GlobalState = {
|
197
|
+
...initialState,
|
198
|
+
isStatusInit: true,
|
199
|
+
status: {
|
200
|
+
...initialState.status,
|
201
|
+
isEnablePglite: true,
|
202
|
+
},
|
203
|
+
initClientDBStage: DatabaseLoadingState.Initializing,
|
204
|
+
};
|
205
|
+
expect(systemStatusSelectors.isPgliteInited(s)).toBe(false);
|
206
|
+
});
|
207
|
+
});
|
208
|
+
});
|
209
|
+
});
|
@@ -1,38 +1,38 @@
|
|
1
1
|
import { isServerMode, isUsePgliteDB } from '@/const/version';
|
2
|
-
import { GlobalStore } from '@/store/global';
|
3
2
|
import { DatabaseLoadingState } from '@/types/clientDB';
|
4
3
|
|
5
4
|
import { GlobalState, INITIAL_STATUS } from '../initialState';
|
6
5
|
|
7
6
|
export const systemStatus = (s: GlobalState) => s.status;
|
8
7
|
|
9
|
-
const sessionGroupKeys = (s:
|
8
|
+
const sessionGroupKeys = (s: GlobalState): string[] =>
|
10
9
|
s.status.expandSessionGroupKeys || INITIAL_STATUS.expandSessionGroupKeys;
|
11
10
|
|
12
|
-
const showSystemRole = (s:
|
13
|
-
const mobileShowTopic = (s:
|
14
|
-
const mobileShowPortal = (s:
|
15
|
-
const showChatSideBar = (s:
|
16
|
-
const showSessionPanel = (s:
|
17
|
-
const showFilePanel = (s:
|
18
|
-
const hidePWAInstaller = (s:
|
19
|
-
const isShowCredit = (s:
|
11
|
+
const showSystemRole = (s: GlobalState) => s.status.showSystemRole;
|
12
|
+
const mobileShowTopic = (s: GlobalState) => s.status.mobileShowTopic;
|
13
|
+
const mobileShowPortal = (s: GlobalState) => s.status.mobileShowPortal;
|
14
|
+
const showChatSideBar = (s: GlobalState) => !s.status.zenMode && s.status.showChatSideBar;
|
15
|
+
const showSessionPanel = (s: GlobalState) => !s.status.zenMode && s.status.showSessionPanel;
|
16
|
+
const showFilePanel = (s: GlobalState) => s.status.showFilePanel;
|
17
|
+
const hidePWAInstaller = (s: GlobalState) => s.status.hidePWAInstaller;
|
18
|
+
const isShowCredit = (s: GlobalState) => s.status.isShowCredit;
|
19
|
+
const themeMode = (s: GlobalState) => s.status.themeMode || 'auto';
|
20
20
|
|
21
|
-
const showChatHeader = (s:
|
22
|
-
const inZenMode = (s:
|
23
|
-
const sessionWidth = (s:
|
24
|
-
const portalWidth = (s:
|
25
|
-
const filePanelWidth = (s:
|
26
|
-
const inputHeight = (s:
|
27
|
-
const threadInputHeight = (s:
|
21
|
+
const showChatHeader = (s: GlobalState) => !s.status.zenMode;
|
22
|
+
const inZenMode = (s: GlobalState) => s.status.zenMode;
|
23
|
+
const sessionWidth = (s: GlobalState) => s.status.sessionsWidth;
|
24
|
+
const portalWidth = (s: GlobalState) => s.status.portalWidth || 400;
|
25
|
+
const filePanelWidth = (s: GlobalState) => s.status.filePanelWidth;
|
26
|
+
const inputHeight = (s: GlobalState) => s.status.inputHeight;
|
27
|
+
const threadInputHeight = (s: GlobalState) => s.status.threadInputHeight;
|
28
28
|
|
29
|
-
const isPgliteNotEnabled = (s:
|
29
|
+
const isPgliteNotEnabled = (s: GlobalState) =>
|
30
30
|
isUsePgliteDB && !isServerMode && s.isStatusInit && !s.status.isEnablePglite;
|
31
31
|
|
32
32
|
/**
|
33
33
|
* 当且仅当 client db 模式,且 pglite 未初始化完成时返回 true
|
34
34
|
*/
|
35
|
-
const isPgliteNotInited = (s:
|
35
|
+
const isPgliteNotInited = (s: GlobalState) =>
|
36
36
|
isUsePgliteDB &&
|
37
37
|
s.isStatusInit &&
|
38
38
|
s.status.isEnablePglite &&
|
@@ -41,14 +41,14 @@ const isPgliteNotInited = (s: GlobalStore) =>
|
|
41
41
|
/**
|
42
42
|
* 当且仅当 client db 模式,且 pglite 初始化完成时返回 true
|
43
43
|
*/
|
44
|
-
const isPgliteInited = (s:
|
44
|
+
const isPgliteInited = (s: GlobalState): boolean =>
|
45
45
|
(s.isStatusInit &&
|
46
46
|
s.status.isEnablePglite &&
|
47
47
|
s.initClientDBStage === DatabaseLoadingState.Ready) ||
|
48
48
|
false;
|
49
49
|
|
50
50
|
// 这个变量控制 clientdb 是否完成初始化,正常来说,只有 pgliteDB 模式下,才会存在变化,其他时候都是 true
|
51
|
-
const isDBInited = (s:
|
51
|
+
const isDBInited = (s: GlobalState): boolean => (isUsePgliteDB ? isPgliteInited(s) : true);
|
52
52
|
|
53
53
|
export const systemStatusSelectors = {
|
54
54
|
filePanelWidth,
|
@@ -71,5 +71,6 @@ export const systemStatusSelectors = {
|
|
71
71
|
showSessionPanel,
|
72
72
|
showSystemRole,
|
73
73
|
systemStatus,
|
74
|
+
themeMode,
|
74
75
|
threadInputHeight,
|
75
76
|
};
|
@@ -70,7 +70,7 @@ describe('SettingsAction', () => {
|
|
70
70
|
describe('setSettings', () => {
|
71
71
|
it('should set partial settings', async () => {
|
72
72
|
const { result } = renderHook(() => useUserStore());
|
73
|
-
const partialSettings: DeepPartial<UserSettings> = { general: {
|
73
|
+
const partialSettings: DeepPartial<UserSettings> = { general: { fontSize: 12 } };
|
74
74
|
|
75
75
|
// Perform the action
|
76
76
|
await act(async () => {
|
@@ -85,24 +85,6 @@ describe('SettingsAction', () => {
|
|
85
85
|
});
|
86
86
|
});
|
87
87
|
|
88
|
-
describe('switchThemeMode', () => {
|
89
|
-
it('should switch theme mode', async () => {
|
90
|
-
const { result } = renderHook(() => useUserStore());
|
91
|
-
const themeMode = 'light';
|
92
|
-
|
93
|
-
// Perform the action
|
94
|
-
await act(async () => {
|
95
|
-
await result.current.switchThemeMode(themeMode);
|
96
|
-
});
|
97
|
-
|
98
|
-
// Assert that updateUserSettings was called with the correct theme mode
|
99
|
-
expect(userService.updateUserSettings).toHaveBeenCalledWith(
|
100
|
-
{ general: { themeMode } },
|
101
|
-
expect.any(AbortSignal),
|
102
|
-
);
|
103
|
-
});
|
104
|
-
});
|
105
|
-
|
106
88
|
describe('updateDefaultAgent', () => {
|
107
89
|
it('should update default agent settings', async () => {
|
108
90
|
const { result } = renderHook(() => useUserStore());
|
@@ -1,4 +1,3 @@
|
|
1
|
-
import { ThemeMode } from 'antd-style';
|
2
1
|
import isEqual from 'fast-deep-equal';
|
3
2
|
import { DeepPartial } from 'utility-types';
|
4
3
|
import type { StateCreator } from 'zustand/vanilla';
|
@@ -24,7 +23,6 @@ export interface UserSettingsAction {
|
|
24
23
|
internal_createSignal: () => AbortController;
|
25
24
|
resetSettings: () => Promise<void>;
|
26
25
|
setSettings: (settings: DeepPartial<UserSettings>) => Promise<void>;
|
27
|
-
switchThemeMode: (themeMode: ThemeMode) => Promise<void>;
|
28
26
|
updateDefaultAgent: (agent: DeepPartial<LobeAgentSettings>) => Promise<void>;
|
29
27
|
updateGeneralConfig: (settings: Partial<UserGeneralConfig>) => Promise<void>;
|
30
28
|
updateKeyVaults: (settings: Partial<UserKeyVaults>) => Promise<void>;
|
@@ -92,9 +90,6 @@ export const createSettingsSlice: StateCreator<
|
|
92
90
|
await userService.updateUserSettings(diffs, abortController.signal);
|
93
91
|
await get().refreshUserState();
|
94
92
|
},
|
95
|
-
switchThemeMode: async (themeMode) => {
|
96
|
-
await get().updateGeneralConfig({ themeMode });
|
97
|
-
},
|
98
93
|
updateDefaultAgent: async (defaultAgent) => {
|
99
94
|
await get().setSettings({ defaultAgent });
|
100
95
|
},
|
@@ -5,27 +5,17 @@ import { merge } from '@/utils/merge';
|
|
5
5
|
import { userGeneralSettingsSelectors } from './general';
|
6
6
|
|
7
7
|
describe('settingsSelectors', () => {
|
8
|
-
describe('
|
9
|
-
it('should return the
|
8
|
+
describe('fontSize', () => {
|
9
|
+
it('should return the fontSize', () => {
|
10
10
|
const s: UserState = merge(initialState, {
|
11
11
|
settings: {
|
12
|
-
general: {
|
12
|
+
general: { fontSize: 12 },
|
13
13
|
},
|
14
14
|
});
|
15
15
|
|
16
|
-
const result = userGeneralSettingsSelectors.
|
16
|
+
const result = userGeneralSettingsSelectors.fontSize(s as UserStore);
|
17
17
|
|
18
|
-
expect(result).toBe(
|
19
|
-
});
|
20
|
-
it('should return the auto if not set the themeMode', () => {
|
21
|
-
const s: UserState = merge(initialState, {
|
22
|
-
settings: {
|
23
|
-
general: { themeMode: undefined },
|
24
|
-
},
|
25
|
-
});
|
26
|
-
const result = userGeneralSettingsSelectors.currentThemeMode(s as UserStore);
|
27
|
-
|
28
|
-
expect(result).toBe('auto');
|
18
|
+
expect(result).toBe(12);
|
29
19
|
});
|
30
20
|
});
|
31
21
|
});
|
@@ -3,18 +3,12 @@ import { currentSettings } from './settings';
|
|
3
3
|
|
4
4
|
const generalConfig = (s: UserStore) => currentSettings(s).general || {};
|
5
5
|
|
6
|
-
const currentThemeMode = (s: UserStore) => {
|
7
|
-
const themeMode = generalConfig(s).themeMode;
|
8
|
-
return themeMode || 'auto';
|
9
|
-
};
|
10
|
-
|
11
6
|
const neutralColor = (s: UserStore) => generalConfig(s).neutralColor;
|
12
7
|
const primaryColor = (s: UserStore) => generalConfig(s).primaryColor;
|
13
8
|
const fontSize = (s: UserStore) => generalConfig(s).fontSize;
|
14
9
|
|
15
10
|
export const userGeneralSettingsSelectors = {
|
16
11
|
config: generalConfig,
|
17
|
-
currentThemeMode,
|
18
12
|
fontSize,
|
19
13
|
neutralColor,
|
20
14
|
primaryColor,
|
@@ -1,9 +1,7 @@
|
|
1
1
|
import type { NeutralColors, PrimaryColors } from '@lobehub/ui';
|
2
|
-
import type { ThemeMode } from 'antd-style';
|
3
2
|
|
4
3
|
export interface UserGeneralConfig {
|
5
4
|
fontSize: number;
|
6
5
|
neutralColor?: NeutralColors;
|
7
6
|
primaryColor?: PrimaryColors;
|
8
|
-
themeMode: ThemeMode;
|
9
7
|
}
|