@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.
- package/CHANGELOG.md +50 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/changelog/v1.json +18 -0
- package/docs/self-hosting/advanced/model-list.mdx +3 -3
- package/docs/self-hosting/advanced/model-list.zh-CN.mdx +3 -3
- package/docs/self-hosting/environment-variables/model-provider.mdx +17 -3
- package/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx +16 -2
- package/package.json +1 -1
- package/packages/web-crawler/src/crawImpl/naive.ts +11 -3
- package/packages/web-crawler/src/urlRules.ts +6 -0
- 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/components/Branding/ProductLogo/Custom.tsx +4 -0
- package/src/config/modelProviders/openrouter.ts +7 -0
- package/src/const/settings/common.ts +0 -1
- package/src/database/schemas/user.ts +3 -3
- package/src/database/server/models/__tests__/plugin.test.ts +8 -8
- package/src/database/server/models/plugin.ts +20 -20
- package/src/features/User/UserPanel/ThemeButton.tsx +4 -4
- package/src/layout/GlobalProvider/AppTheme.tsx +5 -9
- package/src/libs/agent-runtime/openrouter/index.test.ts +10 -1
- package/src/services/plugin/client.test.ts +21 -21
- package/src/services/user/_deprecated.test.ts +1 -1
- package/src/services/user/client.test.ts +1 -1
- package/src/store/aiInfra/slices/aiProvider/__tests__/selectors.test.ts +249 -0
- 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
@@ -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 {
|
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.
|
44
|
-
where: eq(
|
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(
|
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(
|
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.
|
76
|
-
where: eq(
|
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.
|
96
|
-
where: eq(
|
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(
|
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.
|
114
|
-
where: eq(
|
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(
|
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.
|
132
|
-
where: eq(
|
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(
|
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.
|
151
|
-
where: eq(
|
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(
|
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.
|
169
|
-
where: eq(
|
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: {
|
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
|
|
@@ -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
|
|