@lobehub/chat 1.70.3 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/changelog/v1.json +18 -0
  5. package/docs/self-hosting/advanced/model-list.mdx +3 -3
  6. package/docs/self-hosting/advanced/model-list.zh-CN.mdx +3 -3
  7. package/docs/self-hosting/environment-variables/model-provider.mdx +17 -3
  8. package/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx +16 -2
  9. package/package.json +1 -1
  10. package/packages/web-crawler/src/crawImpl/naive.ts +11 -3
  11. package/packages/web-crawler/src/urlRules.ts +6 -0
  12. package/src/app/[variants]/(main)/(mobile)/me/(home)/features/Header.tsx +2 -2
  13. package/src/app/[variants]/(main)/settings/common/features/Theme/index.tsx +5 -3
  14. package/src/components/Branding/ProductLogo/Custom.tsx +4 -0
  15. package/src/config/modelProviders/openrouter.ts +7 -0
  16. package/src/const/settings/common.ts +0 -1
  17. package/src/database/schemas/user.ts +3 -3
  18. package/src/database/server/models/__tests__/plugin.test.ts +8 -8
  19. package/src/database/server/models/plugin.ts +20 -20
  20. package/src/features/User/UserPanel/ThemeButton.tsx +4 -4
  21. package/src/layout/GlobalProvider/AppTheme.tsx +5 -9
  22. package/src/libs/agent-runtime/openrouter/index.test.ts +10 -1
  23. package/src/services/plugin/client.test.ts +21 -21
  24. package/src/services/user/_deprecated.test.ts +1 -1
  25. package/src/services/user/client.test.ts +1 -1
  26. package/src/store/aiInfra/slices/aiProvider/__tests__/selectors.test.ts +249 -0
  27. package/src/store/global/action.test.ts +15 -0
  28. package/src/store/global/actions/__tests__/general.test.ts +221 -0
  29. package/src/store/global/actions/general.ts +10 -1
  30. package/src/store/global/initialState.ts +6 -0
  31. package/src/store/global/selectors/systemStatus.test.ts +209 -0
  32. package/src/store/global/selectors/systemStatus.ts +22 -21
  33. package/src/store/user/slices/settings/action.test.ts +1 -19
  34. package/src/store/user/slices/settings/action.ts +0 -5
  35. package/src/store/user/slices/settings/selectors/general.test.ts +5 -15
  36. package/src/store/user/slices/settings/selectors/general.ts +0 -6
  37. package/src/types/user/settings/general.ts +0 -2
@@ -3,7 +3,7 @@ import { eq } from 'drizzle-orm';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
 
5
5
  import { clientDB, initializeDB } from '@/database/client/db';
6
- import { installedPlugins, users } from '@/database/schemas';
6
+ import { userInstalledPlugins, users } from '@/database/schemas';
7
7
  import { LobeTool } from '@/types/tool';
8
8
  import { LobeToolCustomPlugin } from '@/types/tool/plugin';
9
9
 
@@ -40,8 +40,8 @@ describe('PluginService', () => {
40
40
  await pluginService.installPlugin(fakePlugin);
41
41
 
42
42
  // Assert
43
- const result = await clientDB.query.installedPlugins.findFirst({
44
- where: eq(installedPlugins.identifier, fakePlugin.identifier),
43
+ const result = await clientDB.query.userInstalledPlugins.findFirst({
44
+ where: eq(userInstalledPlugins.identifier, fakePlugin.identifier),
45
45
  });
46
46
  expect(result).toMatchObject(fakePlugin);
47
47
  });
@@ -52,7 +52,7 @@ describe('PluginService', () => {
52
52
  // Arrange
53
53
  const fakePlugins = [{ identifier: 'test-plugin', type: 'plugin' }] as LobeTool[];
54
54
  await clientDB
55
- .insert(installedPlugins)
55
+ .insert(userInstalledPlugins)
56
56
  .values([{ identifier: 'test-plugin', type: 'plugin', userId }]);
57
57
  // Act
58
58
  const data = await pluginService.getInstalledPlugins();
@@ -66,14 +66,14 @@ describe('PluginService', () => {
66
66
  it('should uninstall a plugin', async () => {
67
67
  // Arrange
68
68
  const identifier = 'test-plugin';
69
- await clientDB.insert(installedPlugins).values([{ identifier, type: 'plugin', userId }]);
69
+ await clientDB.insert(userInstalledPlugins).values([{ identifier, type: 'plugin', userId }]);
70
70
 
71
71
  // Act
72
72
  await pluginService.uninstallPlugin(identifier);
73
73
 
74
74
  // Assert
75
- const result = await clientDB.query.installedPlugins.findFirst({
76
- where: eq(installedPlugins.identifier, identifier),
75
+ const result = await clientDB.query.userInstalledPlugins.findFirst({
76
+ where: eq(userInstalledPlugins.identifier, identifier),
77
77
  });
78
78
  expect(result).toBe(undefined);
79
79
  });
@@ -92,8 +92,8 @@ describe('PluginService', () => {
92
92
  await pluginService.createCustomPlugin(customPlugin);
93
93
 
94
94
  // Assert
95
- const result = await clientDB.query.installedPlugins.findFirst({
96
- where: eq(installedPlugins.identifier, customPlugin.identifier),
95
+ const result = await clientDB.query.userInstalledPlugins.findFirst({
96
+ where: eq(userInstalledPlugins.identifier, customPlugin.identifier),
97
97
  });
98
98
  expect(result).toMatchObject(customPlugin);
99
99
  });
@@ -104,14 +104,14 @@ describe('PluginService', () => {
104
104
  // Arrange
105
105
  const identifier = 'plugin-id';
106
106
  const value = { customParams: { ab: '1' } } as unknown as LobeToolCustomPlugin;
107
- await clientDB.insert(installedPlugins).values([{ identifier, type: 'plugin', userId }]);
107
+ await clientDB.insert(userInstalledPlugins).values([{ identifier, type: 'plugin', userId }]);
108
108
 
109
109
  // Act
110
110
  await pluginService.updatePlugin(identifier, value);
111
111
 
112
112
  // Assert
113
- const result = await clientDB.query.installedPlugins.findFirst({
114
- where: eq(installedPlugins.identifier, identifier),
113
+ const result = await clientDB.query.userInstalledPlugins.findFirst({
114
+ where: eq(userInstalledPlugins.identifier, identifier),
115
115
  });
116
116
  expect(result).toMatchObject(value);
117
117
  });
@@ -122,14 +122,14 @@ describe('PluginService', () => {
122
122
  // Arrange
123
123
  const identifier = 'plugin-id';
124
124
  const manifest = { name: 'NewPluginManifest' } as unknown as LobeChatPluginManifest;
125
- await clientDB.insert(installedPlugins).values([{ identifier, type: 'plugin', userId }]);
125
+ await clientDB.insert(userInstalledPlugins).values([{ identifier, type: 'plugin', userId }]);
126
126
 
127
127
  // Act
128
128
  await pluginService.updatePluginManifest(identifier, manifest);
129
129
 
130
130
  // Assert
131
- const result = await clientDB.query.installedPlugins.findFirst({
132
- where: eq(installedPlugins.identifier, identifier),
131
+ const result = await clientDB.query.userInstalledPlugins.findFirst({
132
+ where: eq(userInstalledPlugins.identifier, identifier),
133
133
  });
134
134
  expect(result).toMatchObject({ manifest });
135
135
  });
@@ -138,7 +138,7 @@ describe('PluginService', () => {
138
138
  describe('removeAllPlugins', () => {
139
139
  it('should remove all plugins', async () => {
140
140
  // Arrange
141
- await clientDB.insert(installedPlugins).values([
141
+ await clientDB.insert(userInstalledPlugins).values([
142
142
  { identifier: '123', type: 'plugin', userId },
143
143
  { identifier: '234', type: 'plugin', userId },
144
144
  ]);
@@ -147,8 +147,8 @@ describe('PluginService', () => {
147
147
  await pluginService.removeAllPlugins();
148
148
 
149
149
  // Assert
150
- const result = await clientDB.query.installedPlugins.findMany({
151
- where: eq(installedPlugins.userId, userId),
150
+ const result = await clientDB.query.userInstalledPlugins.findMany({
151
+ where: eq(userInstalledPlugins.userId, userId),
152
152
  });
153
153
  expect(result.length).toEqual(0);
154
154
  });
@@ -159,14 +159,14 @@ describe('PluginService', () => {
159
159
  // Arrange
160
160
  const id = 'plugin-id';
161
161
  const settings = { color: 'blue' };
162
- await clientDB.insert(installedPlugins).values([{ identifier: id, type: 'plugin', userId }]);
162
+ await clientDB.insert(userInstalledPlugins).values([{ identifier: id, type: 'plugin', userId }]);
163
163
 
164
164
  // Act
165
165
  await pluginService.updatePluginSettings(id, settings);
166
166
 
167
167
  // Assert
168
- const result = await clientDB.query.installedPlugins.findFirst({
169
- where: eq(installedPlugins.identifier, id),
168
+ const result = await clientDB.query.userInstalledPlugins.findFirst({
169
+ where: eq(userInstalledPlugins.identifier, id),
170
170
  });
171
171
 
172
172
  expect(result).toMatchObject({ settings });
@@ -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: { themeMode: 'dark' } };
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: { themeMode: 'dark' } };
57
+ const settingsPatch: DeepPartial<UserSettings> = { general: { fontSize: 12 } };
58
58
 
59
59
  await clientService.updateUserSettings(settingsPatch);
60
60
 
@@ -0,0 +1,249 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { aiProviderSelectors } from '../selectors';
4
+
5
+ describe('aiProviderSelectors', () => {
6
+ const mockState: any = {
7
+ aiProviderList: [
8
+ { id: 'provider1', enabled: true, sort: 1 },
9
+ { id: 'provider2', enabled: false, sort: 2 },
10
+ { id: 'provider3', enabled: true, sort: 0 },
11
+ ],
12
+ aiProviderDetail: {
13
+ id: 'provider1',
14
+ keyVaults: {
15
+ baseURL: 'https://api.example.com',
16
+ apiKey: 'test-key',
17
+ },
18
+ },
19
+ aiProviderLoadingIds: ['loading-provider'],
20
+ aiProviderConfigUpdatingIds: ['updating-provider'],
21
+ activeAiProvider: 'provider1',
22
+ aiProviderRuntimeConfig: {
23
+ provider1: {
24
+ keyVaults: {
25
+ baseURL: 'https://api.example.com',
26
+ apiKey: 'test-key',
27
+ },
28
+ settings: {
29
+ searchMode: 'internal',
30
+ },
31
+ fetchOnClient: true,
32
+ },
33
+ provider2: {
34
+ keyVaults: {
35
+ baseURL: 'https://api2.example.com',
36
+ },
37
+ settings: {},
38
+ },
39
+ ollama: {
40
+ keyVaults: {},
41
+ settings: {},
42
+ fetchOnClient: true,
43
+ },
44
+ },
45
+ // Required by AIProviderStoreState
46
+ activeProviderModelList: [],
47
+ initAiProviderList: [],
48
+ providerSearchKeyword: '',
49
+ aiModelLoadingIds: [],
50
+ modelFetchingStatus: {},
51
+ modelRuntimeConfig: {},
52
+ modelSearchKeyword: '',
53
+ };
54
+
55
+ describe('enabledAiProviderList', () => {
56
+ it('should return enabled providers sorted by sort', () => {
57
+ const result = aiProviderSelectors.enabledAiProviderList(mockState);
58
+ expect(result).toEqual([
59
+ { id: 'provider3', enabled: true, sort: 0 },
60
+ { id: 'provider1', enabled: true, sort: 1 },
61
+ ]);
62
+ });
63
+ });
64
+
65
+ describe('disabledAiProviderList', () => {
66
+ it('should return disabled providers', () => {
67
+ const result = aiProviderSelectors.disabledAiProviderList(mockState);
68
+ expect(result).toEqual([{ id: 'provider2', enabled: false, sort: 2 }]);
69
+ });
70
+ });
71
+
72
+ describe('isProviderEnabled', () => {
73
+ it('should return true for enabled provider', () => {
74
+ expect(aiProviderSelectors.isProviderEnabled('provider1')(mockState)).toBe(true);
75
+ });
76
+
77
+ it('should return false for disabled provider', () => {
78
+ expect(aiProviderSelectors.isProviderEnabled('provider2')(mockState)).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe('isProviderLoading', () => {
83
+ it('should return true for loading provider', () => {
84
+ expect(aiProviderSelectors.isProviderLoading('loading-provider')(mockState)).toBe(true);
85
+ });
86
+
87
+ it('should return false for non-loading provider', () => {
88
+ expect(aiProviderSelectors.isProviderLoading('provider1')(mockState)).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe('activeProviderConfig', () => {
93
+ it('should return active provider config', () => {
94
+ expect(aiProviderSelectors.activeProviderConfig(mockState)).toEqual(
95
+ mockState.aiProviderDetail,
96
+ );
97
+ });
98
+ });
99
+
100
+ describe('isAiProviderConfigLoading', () => {
101
+ it('should return true if provider id does not match active provider', () => {
102
+ expect(aiProviderSelectors.isAiProviderConfigLoading('provider2')(mockState)).toBe(true);
103
+ });
104
+
105
+ it('should return false if provider id matches active provider', () => {
106
+ expect(aiProviderSelectors.isAiProviderConfigLoading('provider1')(mockState)).toBe(false);
107
+ });
108
+ });
109
+
110
+ describe('isActiveProviderEndpointNotEmpty', () => {
111
+ it('should return true when baseURL exists', () => {
112
+ expect(aiProviderSelectors.isActiveProviderEndpointNotEmpty(mockState)).toBe(true);
113
+ });
114
+
115
+ it('should return false when no endpoint info exists', () => {
116
+ const stateWithoutEndpoint = {
117
+ ...mockState,
118
+ aiProviderDetail: { keyVaults: {} },
119
+ };
120
+ expect(aiProviderSelectors.isActiveProviderEndpointNotEmpty(stateWithoutEndpoint)).toBe(
121
+ false,
122
+ );
123
+ });
124
+ });
125
+
126
+ describe('isActiveProviderApiKeyNotEmpty', () => {
127
+ it('should return true when apiKey exists', () => {
128
+ expect(aiProviderSelectors.isActiveProviderApiKeyNotEmpty(mockState)).toBe(true);
129
+ });
130
+
131
+ it('should return false when no api key exists', () => {
132
+ const stateWithoutApiKey = {
133
+ ...mockState,
134
+ aiProviderDetail: { keyVaults: {} },
135
+ };
136
+ expect(aiProviderSelectors.isActiveProviderApiKeyNotEmpty(stateWithoutApiKey)).toBe(false);
137
+ });
138
+ });
139
+
140
+ describe('providerConfigById', () => {
141
+ it('should return config for existing provider', () => {
142
+ expect(aiProviderSelectors.providerConfigById('provider1')(mockState)).toEqual(
143
+ mockState.aiProviderRuntimeConfig.provider1,
144
+ );
145
+ });
146
+
147
+ it('should return undefined for non-existing provider', () => {
148
+ expect(aiProviderSelectors.providerConfigById('non-existing')(mockState)).toBeUndefined();
149
+ });
150
+
151
+ it('should return undefined for empty id', () => {
152
+ expect(aiProviderSelectors.providerConfigById('')(mockState)).toBeUndefined();
153
+ });
154
+ });
155
+
156
+ describe('isProviderConfigUpdating', () => {
157
+ it('should return true for updating provider', () => {
158
+ expect(aiProviderSelectors.isProviderConfigUpdating('updating-provider')(mockState)).toBe(
159
+ true,
160
+ );
161
+ });
162
+
163
+ it('should return false for non-updating provider', () => {
164
+ expect(aiProviderSelectors.isProviderConfigUpdating('provider1')(mockState)).toBe(false);
165
+ });
166
+ });
167
+
168
+ describe('isProviderFetchOnClient', () => {
169
+ it('should return false if provider is in disable browser request list', () => {
170
+ expect(
171
+ aiProviderSelectors.isProviderFetchOnClient('provider-with-disabled-browser')(mockState),
172
+ ).toBe(false);
173
+ });
174
+
175
+ it('should follow user settings for whitelisted providers', () => {
176
+ expect(aiProviderSelectors.isProviderFetchOnClient('ollama')(mockState)).toBe(true);
177
+ });
178
+
179
+ it('should return false if no endpoint and api key', () => {
180
+ const state = {
181
+ ...mockState,
182
+ aiProviderRuntimeConfig: {
183
+ test: {
184
+ keyVaults: {},
185
+ settings: {},
186
+ },
187
+ },
188
+ };
189
+ expect(aiProviderSelectors.isProviderFetchOnClient('test')(state)).toBe(false);
190
+ });
191
+
192
+ it('should return true if only baseURL exists', () => {
193
+ const state = {
194
+ ...mockState,
195
+ aiProviderRuntimeConfig: {
196
+ test: {
197
+ keyVaults: { baseURL: 'http://test.com' },
198
+ settings: {},
199
+ },
200
+ },
201
+ };
202
+ expect(aiProviderSelectors.isProviderFetchOnClient('test')(state)).toBe(true);
203
+ });
204
+
205
+ it('should follow user settings if both endpoint and api key exist', () => {
206
+ expect(aiProviderSelectors.isProviderFetchOnClient('provider1')(mockState)).toBe(true);
207
+ });
208
+ });
209
+
210
+ describe('providerKeyVaults', () => {
211
+ it('should return key vaults for existing provider', () => {
212
+ expect(aiProviderSelectors.providerKeyVaults('provider1')(mockState)).toEqual(
213
+ mockState.aiProviderRuntimeConfig.provider1.keyVaults,
214
+ );
215
+ });
216
+
217
+ it('should return undefined for undefined provider', () => {
218
+ expect(aiProviderSelectors.providerKeyVaults(undefined)(mockState)).toBeUndefined();
219
+ });
220
+
221
+ it('should return undefined for non-existing provider', () => {
222
+ expect(aiProviderSelectors.providerKeyVaults('non-existing')(mockState)).toBeUndefined();
223
+ });
224
+ });
225
+
226
+ describe('isProviderHasBuiltinSearch', () => {
227
+ it('should return true if provider has search mode', () => {
228
+ expect(aiProviderSelectors.isProviderHasBuiltinSearch('provider1')(mockState)).toBe(true);
229
+ });
230
+
231
+ it('should return false if provider has no search mode', () => {
232
+ expect(aiProviderSelectors.isProviderHasBuiltinSearch('provider2')(mockState)).toBe(false);
233
+ });
234
+ });
235
+
236
+ describe('isProviderHasBuiltinSearchConfig', () => {
237
+ it('should return false if search mode is internal', () => {
238
+ expect(aiProviderSelectors.isProviderHasBuiltinSearchConfig('provider1')(mockState)).toBe(
239
+ false,
240
+ );
241
+ });
242
+
243
+ it('should return false if no search mode exists', () => {
244
+ expect(aiProviderSelectors.isProviderHasBuiltinSearchConfig('provider2')(mockState)).toBe(
245
+ false,
246
+ );
247
+ });
248
+ });
249
+ });
@@ -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