@lobehub/chat 0.161.9 → 0.161.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -1
  3. package/src/app/(main)/_layout/Desktop.tsx +2 -2
  4. package/src/app/(main)/settings/llm/components/ProviderConfig/index.tsx +5 -2
  5. package/src/app/(main)/settings/llm/components/ProviderModelList/CustomModelOption.tsx +6 -7
  6. package/src/app/(main)/settings/llm/components/ProviderModelList/{ModelConfigModal.tsx → ModelConfigModal/Form.tsx} +19 -63
  7. package/src/app/(main)/settings/llm/components/ProviderModelList/ModelConfigModal/index.tsx +78 -0
  8. package/src/app/(main)/settings/llm/components/ProviderModelList/Option.tsx +35 -11
  9. package/src/app/(main)/settings/llm/components/ProviderModelList/index.tsx +15 -18
  10. package/src/app/layout.tsx +2 -0
  11. package/src/components/ModelProviderIcon/index.tsx +2 -2
  12. package/src/components/ModelSelect/index.tsx +5 -14
  13. package/src/const/layoutTokens.ts +1 -0
  14. package/src/features/PWAInstall/index.tsx +22 -0
  15. package/src/features/User/UserPanel/PanelContent.tsx +1 -1
  16. package/src/hooks/usePWAInstall.test.ts +78 -0
  17. package/src/hooks/usePWAInstall.ts +23 -2
  18. package/src/hooks/usePlatform.test.ts +82 -0
  19. package/src/hooks/usePlatform.ts +19 -2
  20. package/src/hooks/useSyncData.ts +3 -1
  21. package/src/layout/GlobalProvider/StoreInitialization.tsx +17 -9
  22. package/src/layout/GlobalProvider/index.tsx +1 -1
  23. package/src/locales/default/components.ts +1 -0
  24. package/src/services/message/client.test.ts +0 -24
  25. package/src/services/message/client.ts +0 -5
  26. package/src/services/message/type.ts +0 -1
  27. package/src/services/user/client.test.ts +100 -0
  28. package/src/services/user/client.ts +16 -14
  29. package/src/services/user/index.ts +0 -2
  30. package/src/services/user/type.ts +2 -4
  31. package/src/store/user/initialState.ts +10 -1
  32. package/src/store/user/selectors.ts +3 -7
  33. package/src/store/user/slices/auth/action.test.ts +5 -87
  34. package/src/store/user/slices/auth/action.ts +3 -58
  35. package/src/store/user/slices/auth/initialState.ts +2 -1
  36. package/src/store/user/slices/common/action.test.ts +196 -20
  37. package/src/store/user/slices/common/action.ts +55 -26
  38. package/src/store/user/slices/common/initialState.ts +9 -0
  39. package/src/store/user/slices/modelList/action.test.ts +363 -0
  40. package/src/store/user/slices/{settings/actions/llm.ts → modelList/action.ts} +66 -60
  41. package/src/store/user/slices/modelList/initialState.ts +15 -0
  42. package/src/store/user/slices/modelList/selectors/index.ts +2 -0
  43. package/src/store/user/slices/{settings → modelList}/selectors/modelConfig.test.ts +3 -2
  44. package/src/store/user/slices/{settings → modelList}/selectors/modelConfig.ts +1 -1
  45. package/src/store/user/slices/{settings → modelList}/selectors/modelProvider.test.ts +7 -7
  46. package/src/store/user/slices/{settings → modelList}/selectors/modelProvider.ts +2 -4
  47. package/src/store/user/slices/preference/action.test.ts +0 -52
  48. package/src/store/user/slices/preference/action.ts +1 -17
  49. package/src/store/user/slices/preference/initialState.ts +0 -5
  50. package/src/store/user/slices/preference/selectors.test.ts +2 -2
  51. package/src/store/user/slices/preference/selectors.ts +1 -1
  52. package/src/store/user/slices/settings/{actions/general.ts → action.ts} +5 -5
  53. package/src/store/user/slices/settings/initialState.ts +0 -12
  54. package/src/store/user/slices/settings/selectors/index.ts +0 -3
  55. package/src/store/user/slices/sync/action.test.ts +19 -5
  56. package/src/store/user/slices/sync/action.ts +9 -6
  57. package/src/store/user/slices/{settings/selectors/sync.ts → sync/selectors.ts} +2 -2
  58. package/src/store/user/store.ts +5 -2
  59. package/src/types/serverConfig.ts +3 -1
  60. package/src/types/user/index.ts +13 -0
  61. package/src/utils/parseModels.test.ts +121 -1
  62. package/src/utils/parseModels.ts +9 -4
  63. package/src/utils/platform.test.ts +83 -0
  64. package/src/utils/platform.ts +33 -2
  65. package/src/hooks/useIsPWA.ts +0 -13
  66. package/src/store/user/slices/settings/actions/index.ts +0 -18
  67. package/src/store/user/slices/settings/actions/llm.test.ts +0 -136
  68. package/src/utils/matchMedia.ts +0 -10
  69. /package/src/app/(main)/settings/llm/components/ProviderModelList/{MaxTokenSlider.tsx → ModelConfigModal/MaxTokenSlider.tsx} +0 -0
  70. /package/src/store/user/slices/{settings → modelList}/reducers/customModelCard.test.ts +0 -0
  71. /package/src/store/user/slices/{settings → modelList}/reducers/customModelCard.ts +0 -0
  72. /package/src/store/user/slices/settings/{actions/general.test.ts → action.test.ts} +0 -0
  73. /package/src/store/user/slices/settings/selectors/__snapshots__/{selectors.test.ts.snap → settings.test.ts.snap} +0 -0
  74. /package/src/store/user/slices/settings/selectors/{selectors.test.ts → settings.test.ts} +0 -0
@@ -40,56 +40,4 @@ describe('createPreferenceSlice', () => {
40
40
  expect(result.current.preference.hideSyncAlert).toEqual(true);
41
41
  });
42
42
  });
43
-
44
- describe('useInitPreference', () => {
45
- it('should return false when userId is empty', async () => {
46
- const { result } = renderHook(() => useUserStore());
47
-
48
- vi.spyOn(userService, 'getPreference').mockResolvedValueOnce({} as any);
49
-
50
- const { result: prefernce } = renderHook(() => result.current.useInitPreference(), {
51
- wrapper: withSWR,
52
- });
53
-
54
- await waitFor(() => {
55
- expect(prefernce.current.data).toEqual({});
56
- expect(result.current.isPreferenceInit).toBeTruthy();
57
- });
58
- });
59
- it('should return default preference when local storage is empty', async () => {
60
- const { result } = renderHook(() => useUserStore());
61
-
62
- vi.spyOn(userService, 'getPreference').mockResolvedValueOnce({} as any);
63
-
64
- renderHook(() => result.current.useInitPreference(), {
65
- wrapper: withSWR,
66
- });
67
-
68
- await waitFor(() => {
69
- expect(result.current.preference).toEqual(DEFAULT_PREFERENCE);
70
- expect(result.current.isPreferenceInit).toBeTruthy();
71
- });
72
- });
73
-
74
- it('should return saved preference when local storage has data', async () => {
75
- const { result } = renderHook(() => useUserStore());
76
- const savedPreference: UserPreference = {
77
- ...DEFAULT_PREFERENCE,
78
- hideSyncAlert: true,
79
- guide: { topic: false, moveSettingsToAvatar: true },
80
- };
81
-
82
- vi.spyOn(userService, 'getPreference').mockResolvedValueOnce(savedPreference);
83
-
84
- const { result: prefernce } = renderHook(() => result.current.useInitPreference(), {
85
- wrapper: withSWR,
86
- });
87
-
88
- await waitFor(() => {
89
- expect(prefernce.current.data).toEqual(savedPreference);
90
- expect(result.current.isPreferenceInit).toBeTruthy();
91
- expect(result.current.preference).toEqual(savedPreference);
92
- });
93
- });
94
- });
95
43
  });
@@ -1,8 +1,5 @@
1
- import { SWRResponse } from 'swr';
2
1
  import type { StateCreator } from 'zustand/vanilla';
3
2
 
4
- import { DEFAULT_PREFERENCE } from '@/const/user';
5
- import { useClientDataSWR } from '@/libs/swr';
6
3
  import { userService } from '@/services/user';
7
4
  import type { UserStore } from '@/store/user';
8
5
  import { UserGuide, UserPreference } from '@/types/user';
@@ -14,7 +11,6 @@ const n = setNamespace('preference');
14
11
  export interface PreferenceAction {
15
12
  updateGuideState: (guide: Partial<UserGuide>) => Promise<void>;
16
13
  updatePreference: (preference: Partial<UserPreference>, action?: any) => Promise<void>;
17
- useInitPreference: () => SWRResponse;
18
14
  }
19
15
 
20
16
  export const createPreferenceSlice: StateCreator<
@@ -28,6 +24,7 @@ export const createPreferenceSlice: StateCreator<
28
24
  const nextGuide = merge(get().preference.guide, guide);
29
25
  await updatePreference({ guide: nextGuide });
30
26
  },
27
+
31
28
  updatePreference: async (preference, action) => {
32
29
  const nextPreference = merge(get().preference, preference);
33
30
 
@@ -35,17 +32,4 @@ export const createPreferenceSlice: StateCreator<
35
32
 
36
33
  await userService.updatePreference(nextPreference);
37
34
  },
38
-
39
- useInitPreference: () =>
40
- useClientDataSWR<UserPreference>('initUserPreference', userService.getPreference, {
41
- onSuccess: (preference) => {
42
- const isEmpty = Object.keys(preference).length === 0;
43
-
44
- set(
45
- { isPreferenceInit: true, preference: isEmpty ? DEFAULT_PREFERENCE : preference },
46
- false,
47
- n('initPreference'),
48
- );
49
- },
50
- }),
51
35
  });
@@ -1,18 +1,13 @@
1
1
  import { DEFAULT_PREFERENCE } from '@/const/user';
2
2
  import { UserPreference } from '@/types/user';
3
- import { AsyncLocalStorage } from '@/utils/localStorage';
4
3
 
5
4
  export interface UserPreferenceState {
6
- isPreferenceInit: boolean;
7
5
  /**
8
6
  * the user preference, which only store in local storage
9
7
  */
10
8
  preference: UserPreference;
11
- preferenceStorage: AsyncLocalStorage<UserPreference>;
12
9
  }
13
10
 
14
11
  export const initialPreferenceState: UserPreferenceState = {
15
- isPreferenceInit: false,
16
12
  preference: DEFAULT_PREFERENCE,
17
- preferenceStorage: new AsyncLocalStorage('LOBE_PREFERENCE'),
18
13
  };
@@ -72,10 +72,10 @@ describe('preferenceSelectors', () => {
72
72
 
73
73
  describe('isPreferenceInit', () => {
74
74
  it('should return the value of isPreferenceInit state', () => {
75
- store.isPreferenceInit = true;
75
+ store.isUserStateInit = true;
76
76
  expect(preferenceSelectors.isPreferenceInit(store)).toBe(true);
77
77
 
78
- store.isPreferenceInit = false;
78
+ store.isUserStateInit = false;
79
79
  expect(preferenceSelectors.isPreferenceInit(store)).toBe(false);
80
80
  });
81
81
  });
@@ -8,7 +8,7 @@ const hideSyncAlert = (s: UserStore) => s.preference.hideSyncAlert;
8
8
 
9
9
  const hideSettingsMoveGuide = (s: UserStore) => s.preference.guide?.moveSettingsToAvatar;
10
10
 
11
- const isPreferenceInit = (s: UserStore) => s.isPreferenceInit;
11
+ const isPreferenceInit = (s: UserStore) => s.isUserStateInit;
12
12
 
13
13
  export const preferenceSelectors = {
14
14
  hideSettingsMoveGuide,
@@ -12,7 +12,7 @@ import { switchLang } from '@/utils/client/switchLang';
12
12
  import { difference } from '@/utils/difference';
13
13
  import { merge } from '@/utils/merge';
14
14
 
15
- export interface GeneralSettingsAction {
15
+ export interface UserSettingsAction {
16
16
  importAppSettings: (settings: GlobalSettings) => Promise<void>;
17
17
  resetSettings: () => Promise<void>;
18
18
  setSettings: (settings: DeepPartial<GlobalSettings>) => Promise<void>;
@@ -22,11 +22,11 @@ export interface GeneralSettingsAction {
22
22
  updateDefaultAgent: (agent: DeepPartial<LobeAgentSettings>) => Promise<void>;
23
23
  }
24
24
 
25
- export const generalSettingsSlice: StateCreator<
25
+ export const createSettingsSlice: StateCreator<
26
26
  UserStore,
27
27
  [['zustand/devtools', never]],
28
28
  [],
29
- GeneralSettingsAction
29
+ UserSettingsAction
30
30
  > = (set, get) => ({
31
31
  importAppSettings: async (importAppSettings) => {
32
32
  const { setSettings } = get();
@@ -37,7 +37,7 @@ export const generalSettingsSlice: StateCreator<
37
37
  },
38
38
  resetSettings: async () => {
39
39
  await userService.resetUserSettings();
40
- await get().refreshUserConfig();
40
+ await get().refreshUserState();
41
41
  },
42
42
  setSettings: async (settings) => {
43
43
  const { settings: prevSetting, defaultSettings } = get();
@@ -49,7 +49,7 @@ export const generalSettingsSlice: StateCreator<
49
49
  const diffs = difference(nextSettings, defaultSettings);
50
50
 
51
51
  await userService.updateUserSettings(diffs);
52
- await get().refreshUserConfig();
52
+ await get().refreshUserState();
53
53
  },
54
54
  setTranslationSystemAgent: async (provider, model) => {
55
55
  await get().setSettings({
@@ -1,26 +1,14 @@
1
1
  import { DeepPartial } from 'utility-types';
2
2
 
3
- import { DEFAULT_MODEL_PROVIDER_LIST } from '@/config/modelProviders';
4
3
  import { DEFAULT_SETTINGS } from '@/const/settings';
5
- import { ModelProviderCard } from '@/types/llm';
6
- import { GlobalServerConfig } from '@/types/serverConfig';
7
4
  import { GlobalSettings } from '@/types/settings';
8
5
 
9
6
  export interface UserSettingsState {
10
- defaultModelProviderList: ModelProviderCard[];
11
7
  defaultSettings: GlobalSettings;
12
- editingCustomCardModel?: { id: string; provider: string } | undefined;
13
- modelProviderList: ModelProviderCard[];
14
- serverConfig: GlobalServerConfig;
15
8
  settings: DeepPartial<GlobalSettings>;
16
9
  }
17
10
 
18
11
  export const initialSettingsState: UserSettingsState = {
19
- defaultModelProviderList: DEFAULT_MODEL_PROVIDER_LIST,
20
12
  defaultSettings: DEFAULT_SETTINGS,
21
- modelProviderList: DEFAULT_MODEL_PROVIDER_LIST,
22
- serverConfig: {
23
- telemetry: {},
24
- },
25
13
  settings: {},
26
14
  };
@@ -1,5 +1,2 @@
1
- export { modelConfigSelectors } from './modelConfig';
2
- export { modelProviderSelectors } from './modelProvider';
3
1
  export { settingsSelectors } from './settings';
4
- export { syncSettingsSelectors } from './sync';
5
2
  export { systemAgentSelectors } from './systemAgent';
@@ -107,9 +107,17 @@ describe('createSyncSlice', () => {
107
107
 
108
108
  describe('useEnabledSync', () => {
109
109
  it('should return false when userId is empty', async () => {
110
- const { result } = renderHook(() => useUserStore().useEnabledSync(true, undefined, vi.fn()), {
111
- wrapper: withSWR,
112
- });
110
+ const { result } = renderHook(
111
+ () =>
112
+ useUserStore().useEnabledSync(true, {
113
+ userEnableSync: true,
114
+ userId: undefined,
115
+ onEvent: vi.fn(),
116
+ }),
117
+ {
118
+ wrapper: withSWR,
119
+ },
120
+ );
113
121
 
114
122
  await waitFor(() => expect(result.current.data).toBe(false));
115
123
  });
@@ -118,7 +126,13 @@ describe('createSyncSlice', () => {
118
126
  const disableSyncSpy = vi.spyOn(syncService, 'disableSync').mockResolvedValueOnce(false);
119
127
 
120
128
  const { result } = renderHook(
121
- () => useUserStore().useEnabledSync(false, 'user-id', vi.fn()),
129
+ () =>
130
+ useUserStore().useEnabledSync(true, {
131
+ userEnableSync: false,
132
+ userId: 'user-id',
133
+ onEvent: vi.fn(),
134
+ }),
135
+
122
136
  { wrapper: withSWR },
123
137
  );
124
138
 
@@ -137,7 +151,7 @@ describe('createSyncSlice', () => {
137
151
  result.current.triggerEnableSync = triggerEnableSyncSpy;
138
152
 
139
153
  const { result: swrResult } = renderHook(
140
- () => result.current.useEnabledSync(true, userId, onEvent),
154
+ () => result.current.useEnabledSync(true, { userEnableSync: true, userId, onEvent }),
141
155
  {
142
156
  wrapper: withSWR,
143
157
  },
@@ -8,7 +8,7 @@ import { browserInfo } from '@/utils/platform';
8
8
  import { setNamespace } from '@/utils/storeDebug';
9
9
 
10
10
  import { userProfileSelectors } from '../auth/selectors';
11
- import { syncSettingsSelectors } from '../settings/selectors';
11
+ import { syncSettingsSelectors } from './selectors';
12
12
 
13
13
  const n = setNamespace('sync');
14
14
 
@@ -19,9 +19,12 @@ export interface SyncAction {
19
19
  refreshConnection: (onEvent: OnSyncEvent) => Promise<void>;
20
20
  triggerEnableSync: (userId: string, onEvent: OnSyncEvent) => Promise<boolean>;
21
21
  useEnabledSync: (
22
- userEnableSync: boolean,
23
- userId: string | undefined,
24
- onEvent: OnSyncEvent,
22
+ systemEnable: boolean | undefined,
23
+ params: {
24
+ onEvent: OnSyncEvent;
25
+ userEnableSync: boolean;
26
+ userId: string | undefined;
27
+ },
25
28
  ) => SWRResponse;
26
29
  }
27
30
 
@@ -72,9 +75,9 @@ export const createSyncSlice: StateCreator<
72
75
  });
73
76
  },
74
77
 
75
- useEnabledSync: (userEnableSync, userId, onEvent) =>
78
+ useEnabledSync: (systemEnable, { userEnableSync, userId, onEvent }) =>
76
79
  useSWR<boolean>(
77
- ['enableSync', userEnableSync, userId],
80
+ systemEnable ? ['enableSync', userEnableSync, userId] : null,
78
81
  async () => {
79
82
  // if user don't enable sync or no userId ,don't start sync
80
83
  if (!userId) return false;
@@ -1,5 +1,5 @@
1
- import { UserStore } from '../../../store';
2
- import { currentSettings } from './settings';
1
+ import { UserStore } from '../../store';
2
+ import { currentSettings } from '../settings/selectors/settings';
3
3
 
4
4
  const webrtcConfig = (s: UserStore) => currentSettings(s).sync.webrtc;
5
5
  const webrtcChannelName = (s: UserStore) => webrtcConfig(s).channelName;
@@ -8,16 +8,18 @@ import { isDev } from '@/utils/env';
8
8
  import { type UserState, initialState } from './initialState';
9
9
  import { type UserAuthAction, createAuthSlice } from './slices/auth/action';
10
10
  import { type CommonAction, createCommonSlice } from './slices/common/action';
11
+ import { type ModelListAction, createModelListSlice } from './slices/modelList/action';
11
12
  import { type PreferenceAction, createPreferenceSlice } from './slices/preference/action';
12
- import { type SettingsAction, createSettingsSlice } from './slices/settings/actions';
13
+ import { type UserSettingsAction, createSettingsSlice } from './slices/settings/action';
13
14
  import { type SyncAction, createSyncSlice } from './slices/sync/action';
14
15
 
15
16
  // =============== 聚合 createStoreFn ============ //
16
17
 
17
18
  export type UserStore = SyncAction &
18
19
  UserState &
19
- SettingsAction &
20
+ UserSettingsAction &
20
21
  PreferenceAction &
22
+ ModelListAction &
21
23
  UserAuthAction &
22
24
  CommonAction;
23
25
 
@@ -28,6 +30,7 @@ const createStore: StateCreator<UserStore, [['zustand/devtools', never]]> = (...
28
30
  ...createPreferenceSlice(...parameters),
29
31
  ...createAuthSlice(...parameters),
30
32
  ...createCommonSlice(...parameters),
33
+ ...createModelListSlice(...parameters),
31
34
  });
32
35
 
33
36
  // =============== 实装 useStore ============ //
@@ -13,12 +13,14 @@ export interface ServerModelProviderConfig {
13
13
  serverModelCards?: ChatModelCard[];
14
14
  }
15
15
 
16
+ export type ServerLanguageModel = Partial<Record<GlobalLLMProviderKey, ServerModelProviderConfig>>;
17
+
16
18
  export interface GlobalServerConfig {
17
19
  defaultAgent?: DeepPartial<GlobalDefaultAgent>;
18
20
  enableUploadFileToServer?: boolean;
19
21
  enabledAccessCode?: boolean;
20
22
  enabledOAuthSSO?: boolean;
21
- languageModel?: Partial<Record<GlobalLLMProviderKey, ServerModelProviderConfig>>;
23
+ languageModel?: ServerLanguageModel;
22
24
  telemetry: {
23
25
  langfuse?: boolean;
24
26
  };
@@ -1,3 +1,7 @@
1
+ import { DeepPartial } from 'utility-types';
2
+
3
+ import { GlobalSettings } from '@/types/settings';
4
+
1
5
  export interface LobeUser {
2
6
  avatar?: string;
3
7
  email?: string | null;
@@ -27,3 +31,12 @@ export interface UserPreference {
27
31
  */
28
32
  useCmdEnterToSend?: boolean;
29
33
  }
34
+
35
+ export interface UserInitializationState {
36
+ avatar?: string;
37
+ canEnableTrace?: boolean;
38
+ isOnboard?: boolean;
39
+ preference: UserPreference;
40
+ settings: DeepPartial<GlobalSettings>;
41
+ userId: string;
42
+ }
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- import { parseModelString } from './parseModels';
3
+ import { LOBE_DEFAULT_MODEL_LIST, OpenAIProviderCard } from '@/config/modelProviders';
4
+ import { ChatModelCard } from '@/types/llm';
5
+
6
+ import { parseModelString, transformToChatModelCards } from './parseModels';
4
7
 
5
8
  describe('parseModelString', () => {
6
9
  it('custom deletion, addition, and renaming of models', () => {
@@ -67,6 +70,29 @@ describe('parseModelString', () => {
67
70
  ]);
68
71
  });
69
72
 
73
+ it('should have file with builtin models like gpt-4-0125-preview', () => {
74
+ const result = parseModelString(
75
+ '-all,+gpt-4-0125-preview=ChatGPT-4<128000:fc:file>,+gpt-4-turbo-2024-04-09=ChatGPT-4 Vision<128000:fc:vision:file>',
76
+ );
77
+ expect(result.add).toEqual([
78
+ {
79
+ displayName: 'ChatGPT-4',
80
+ files: true,
81
+ functionCall: true,
82
+ id: 'gpt-4-0125-preview',
83
+ tokens: 128000,
84
+ },
85
+ {
86
+ displayName: 'ChatGPT-4 Vision',
87
+ files: true,
88
+ functionCall: true,
89
+ id: 'gpt-4-turbo-2024-04-09',
90
+ tokens: 128000,
91
+ vision: true,
92
+ },
93
+ ]);
94
+ });
95
+
70
96
  it('should handle empty extension capability value', () => {
71
97
  const result = parseModelString('model1<1024:>');
72
98
  expect(result.add[0]).toEqual({ id: 'model1', tokens: 1024 });
@@ -168,3 +194,97 @@ describe('parseModelString', () => {
168
194
  });
169
195
  });
170
196
  });
197
+
198
+ describe('transformToChatModelCards', () => {
199
+ const defaultChatModels: ChatModelCard[] = [
200
+ { id: 'model1', displayName: 'Model 1', enabled: true },
201
+ { id: 'model2', displayName: 'Model 2', enabled: false },
202
+ ];
203
+
204
+ it('should return undefined when modelString is empty', () => {
205
+ const result = transformToChatModelCards({
206
+ modelString: '',
207
+ defaultChatModels,
208
+ });
209
+ expect(result).toBeUndefined();
210
+ });
211
+
212
+ it('should remove all models when removeAll is true', () => {
213
+ const result = transformToChatModelCards({
214
+ modelString: '-all',
215
+ defaultChatModels,
216
+ });
217
+ expect(result).toEqual([]);
218
+ });
219
+
220
+ it('should remove specified models', () => {
221
+ const result = transformToChatModelCards({
222
+ modelString: '-model1',
223
+ defaultChatModels,
224
+ });
225
+ expect(result).toEqual([{ id: 'model2', displayName: 'Model 2', enabled: false }]);
226
+ });
227
+
228
+ it('should add a new known model', () => {
229
+ const knownModel = LOBE_DEFAULT_MODEL_LIST[0];
230
+ const result = transformToChatModelCards({
231
+ modelString: `${knownModel.id}`,
232
+ defaultChatModels,
233
+ });
234
+ expect(result).toContainEqual({
235
+ ...knownModel,
236
+ displayName: knownModel.displayName || knownModel.id,
237
+ enabled: true,
238
+ });
239
+ });
240
+
241
+ it('should update an existing known model', () => {
242
+ const knownModel = LOBE_DEFAULT_MODEL_LIST[0];
243
+ const result = transformToChatModelCards({
244
+ modelString: `+${knownModel.id}=Updated Model`,
245
+ defaultChatModels: [knownModel],
246
+ });
247
+ expect(result![0]).toEqual({ ...knownModel, displayName: 'Updated Model', enabled: true });
248
+ });
249
+
250
+ it('should add a new custom model', () => {
251
+ const result = transformToChatModelCards({
252
+ modelString: '+custom_model=Custom Model',
253
+ defaultChatModels,
254
+ });
255
+ expect(result).toContainEqual({
256
+ id: 'custom_model',
257
+ displayName: 'Custom Model',
258
+ enabled: true,
259
+ });
260
+ });
261
+
262
+ it('should have file with builtin models like gpt-4-0125-preview', () => {
263
+ const result = transformToChatModelCards({
264
+ modelString:
265
+ '-all,+gpt-4-0125-preview=ChatGPT-4<128000:fc:file>,+gpt-4-turbo-2024-04-09=ChatGPT-4 Vision<128000:fc:vision:file>',
266
+ defaultChatModels: OpenAIProviderCard.chatModels,
267
+ });
268
+
269
+ expect(result).toEqual([
270
+ {
271
+ displayName: 'ChatGPT-4',
272
+ files: true,
273
+ functionCall: true,
274
+ enabled: true,
275
+ id: 'gpt-4-0125-preview',
276
+ tokens: 128000,
277
+ },
278
+ {
279
+ description: 'GPT-4 Turbo 视觉版 (240409)',
280
+ displayName: 'ChatGPT-4 Vision',
281
+ files: true,
282
+ functionCall: true,
283
+ enabled: true,
284
+ id: 'gpt-4-turbo-2024-04-09',
285
+ tokens: 128000,
286
+ vision: true,
287
+ },
288
+ ]);
289
+ });
290
+ });
@@ -114,17 +114,22 @@ export const transformToChatModelCards = ({
114
114
 
115
115
  // if the model is known, update it based on the known model
116
116
  if (knownModel) {
117
- const modelInList = draft.find((model) => model.id === toAddModel.id);
117
+ const index = draft.findIndex((model) => model.id === toAddModel.id);
118
+ const modelInList = draft[index];
118
119
 
119
120
  // if the model is already in chatModels, update it
120
121
  if (modelInList) {
121
- // if (modelInList.hidden) delete modelInList.hidden;
122
- modelInList.enabled = true;
123
- if (toAddModel.displayName) modelInList.displayName = toAddModel.displayName;
122
+ draft[index] = {
123
+ ...modelInList,
124
+ ...toAddModel,
125
+ displayName: toAddModel.displayName || modelInList.displayName || modelInList.id,
126
+ enabled: true,
127
+ };
124
128
  } else {
125
129
  // if the model is not in chatModels, add it
126
130
  draft.push({
127
131
  ...knownModel,
132
+ ...toAddModel,
128
133
  displayName: toAddModel.displayName || knownModel.displayName || knownModel.id,
129
134
  enabled: true,
130
135
  });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { isSonomaOrLaterSafari } from './platform';
4
+
5
+ describe('isSonomaOrLaterSafari', () => {
6
+ beforeEach(() => {
7
+ // 重置 navigator 对象
8
+ vi.stubGlobal('navigator', {
9
+ userAgent:
10
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15',
11
+ maxTouchPoints: 0,
12
+ });
13
+ });
14
+
15
+ it('should return false when userAgent is not Macintosh', () => {
16
+ vi.stubGlobal('navigator', { userAgent: 'Windows NT 10.0; Win64; x64' });
17
+ expect(isSonomaOrLaterSafari()).toBe(false);
18
+ });
19
+
20
+ it('should return false when navigator.maxTouchPoints > 0', () => {
21
+ Object.defineProperty(navigator, 'maxTouchPoints', { value: 1 });
22
+ expect(isSonomaOrLaterSafari()).toBe(false);
23
+ });
24
+
25
+ it('should return false when Safari version < 17', () => {
26
+ vi.stubGlobal('navigator', {
27
+ userAgent:
28
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15',
29
+ });
30
+ expect(isSonomaOrLaterSafari()).toBe(false);
31
+ });
32
+
33
+ it('should return false when audio codec check fails', () => {
34
+ vi.stubGlobal('navigator', {
35
+ userAgent:
36
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
37
+ });
38
+ vi.spyOn(document, 'createElement').mockReturnValueOnce({
39
+ canPlayType: vi.fn().mockReturnValue(''),
40
+ } as any);
41
+ vi.stubGlobal(
42
+ 'OffscreenCanvas',
43
+ class {
44
+ getContext = vi.fn().mockReturnValueOnce(null);
45
+ },
46
+ );
47
+ expect(isSonomaOrLaterSafari()).toBe(false);
48
+ });
49
+
50
+ it('should return false when WebGL check fails', () => {
51
+ vi.stubGlobal('navigator', {
52
+ userAgent:
53
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
54
+ });
55
+ vi.spyOn(document, 'createElement').mockReturnValueOnce({
56
+ canPlayType: vi.fn().mockReturnValue('maybe'),
57
+ } as any);
58
+ vi.stubGlobal(
59
+ 'OffscreenCanvas',
60
+ class {
61
+ getContext = vi.fn().mockReturnValueOnce(null);
62
+ },
63
+ );
64
+ expect(isSonomaOrLaterSafari()).toBe(false);
65
+ });
66
+
67
+ it('should return true when all checks pass', () => {
68
+ vi.stubGlobal('navigator', {
69
+ userAgent:
70
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
71
+ });
72
+ vi.spyOn(document, 'createElement').mockReturnValueOnce({
73
+ canPlayType: vi.fn().mockReturnValue('maybe'),
74
+ } as any);
75
+ vi.stubGlobal(
76
+ 'OffscreenCanvas',
77
+ class {
78
+ getContext = vi.fn().mockReturnValueOnce({});
79
+ },
80
+ );
81
+ expect(isSonomaOrLaterSafari()).toBe(true);
82
+ });
83
+ });
@@ -1,7 +1,9 @@
1
1
  import UAParser from 'ua-parser-js';
2
2
 
3
- const getParser = () => {
4
- if (typeof window === 'undefined') return new UAParser('Node');
3
+ import { isOnServerSide } from '@/utils/env';
4
+
5
+ export const getParser = () => {
6
+ if (isOnServerSide) return new UAParser('Node');
5
7
 
6
8
  let ua = navigator.userAgent;
7
9
  return new UAParser(ua);
@@ -22,3 +24,32 @@ export const browserInfo = {
22
24
  };
23
25
 
24
26
  export const isMacOS = () => getPlatform() === 'Mac OS';
27
+
28
+ export const isInStandaloneMode = () => {
29
+ if (isOnServerSide) return false;
30
+ return (
31
+ window.matchMedia('(display-mode: standalone)').matches ||
32
+ ('standalone' in navigator && (navigator as any).standalone === true)
33
+ );
34
+ };
35
+
36
+ export const isSonomaOrLaterSafari = () => {
37
+ if (isOnServerSide) return false;
38
+
39
+ // refs: https://github.com/khmyznikov/pwa-install/blob/0904788b9d0e34399846f6cb7dbb5efeabb62c20/src/utils.ts#L24
40
+ const userAgent = navigator.userAgent.toLowerCase();
41
+ if (navigator.maxTouchPoints || !/macintosh/.test(userAgent)) return false;
42
+
43
+ // check safari version >= 17
44
+ const version = /version\/(\d{2})\./.exec(userAgent);
45
+ if (!version || !version[1] || !(parseInt(version[1]) >= 17)) return false;
46
+
47
+ try {
48
+ // hacky way to detect Sonoma
49
+ const audioCheck = document.createElement('audio').canPlayType('audio/wav; codecs="1"');
50
+ const webGLCheck = new OffscreenCanvas(1, 1).getContext('webgl');
51
+ return Boolean(audioCheck) && Boolean(webGLCheck);
52
+ } catch {
53
+ return false;
54
+ }
55
+ };