@lobehub/chat 0.122.2 → 0.122.4

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 (79) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +8 -8
  3. package/README.zh-CN.md +8 -8
  4. package/docs/Deployment/Docker-Deployment.md +118 -0
  5. package/docs/Deployment/Docker-Deployment.zh-CN.md +118 -0
  6. package/package.json +1 -4
  7. package/src/app/api/config/route.ts +3 -1
  8. package/src/app/api/openai/chat/createChatCompletion.ts +1 -1
  9. package/src/app/api/openai/createBizOpenAI/index.ts +1 -1
  10. package/src/app/api/plugin/gateway/route.ts +31 -5
  11. package/src/app/chat/(mobile)/features/SessionHeader.tsx +2 -1
  12. package/src/app/chat/features/ChatHeader/SettingButton.tsx +1 -1
  13. package/src/app/chat/features/ChatHeader/ShareButton/ShareModal.tsx +2 -1
  14. package/src/app/chat/settings/(desktop)/index.tsx +9 -5
  15. package/src/app/chat/settings/features/SubmitAgentButton/SubmitAgentModal.tsx +2 -1
  16. package/src/app/settings/(mobile)/mobile/index.tsx +2 -1
  17. package/src/app/settings/common/Common.tsx +14 -3
  18. package/src/app/settings/features/ThemeSwatches/ThemeSwatchesNeutral.tsx +2 -1
  19. package/src/app/settings/features/ThemeSwatches/ThemeSwatchesPrimary.tsx +2 -1
  20. package/src/app/settings/llm/LLM/index.tsx +17 -10
  21. package/src/const/settings.ts +0 -1
  22. package/src/database/core/db.ts +41 -2
  23. package/src/database/core/migrations/migrateSettingsToUser/fixtures/input.json +55 -0
  24. package/src/database/core/migrations/migrateSettingsToUser/fixtures/output.json +60 -0
  25. package/src/database/core/migrations/migrateSettingsToUser/index.test.ts +13 -0
  26. package/src/database/core/migrations/migrateSettingsToUser/index.ts +39 -0
  27. package/src/database/core/schemas.ts +10 -0
  28. package/src/database/models/user.ts +54 -0
  29. package/src/database/schemas/user.ts +57 -0
  30. package/src/features/AgentSetting/AgentConfig/index.tsx +2 -2
  31. package/src/features/AvatarWithUpload/index.tsx +6 -2
  32. package/src/features/ChatInput/ActionBar/ModelSwitch.tsx +2 -2
  33. package/src/features/Conversation/Error/ApiKeyForm.tsx +3 -3
  34. package/src/features/Conversation/Error/InvalidAccess.tsx +5 -1
  35. package/src/features/Conversation/Extras/Translate.tsx +3 -4
  36. package/src/layout/GlobalLayout/AppTheme.tsx +4 -3
  37. package/src/layout/GlobalLayout/Locale.tsx +2 -1
  38. package/src/layout/GlobalLayout/StoreHydration.tsx +7 -2
  39. package/src/services/_header.ts +3 -2
  40. package/src/services/chat.ts +2 -1
  41. package/src/services/user.ts +31 -0
  42. package/src/store/chat/slices/message/selectors.test.ts +7 -2
  43. package/src/store/chat/slices/message/selectors.ts +2 -1
  44. package/src/store/global/hooks/index.ts +0 -1
  45. package/src/store/global/initialState.ts +3 -1
  46. package/src/store/global/selectors.ts +1 -0
  47. package/src/store/global/slices/common/action.test.ts +2 -79
  48. package/src/store/global/slices/common/action.ts +37 -61
  49. package/src/store/global/slices/common/initialState.ts +0 -34
  50. package/src/store/global/slices/common/selectors.ts +2 -7
  51. package/src/store/global/slices/preference/action.test.ts +92 -0
  52. package/src/store/global/slices/preference/action.ts +75 -0
  53. package/src/store/global/slices/preference/initialState.ts +45 -0
  54. package/src/store/global/slices/preference/selectors.ts +10 -0
  55. package/src/store/global/slices/settings/action.test.ts +68 -39
  56. package/src/store/global/slices/settings/action.ts +26 -50
  57. package/src/store/global/slices/settings/initialState.ts +8 -6
  58. package/src/store/global/slices/settings/selectors/__snapshots__/modelProvider.test.ts.snap +100 -0
  59. package/src/store/global/slices/settings/{__snapshots__ → selectors/__snapshots__}/selectors.test.ts.snap +1 -115
  60. package/src/store/global/slices/settings/selectors/index.ts +2 -0
  61. package/src/store/global/slices/settings/selectors/modelProvider.test.ts +100 -0
  62. package/src/store/global/slices/settings/selectors/modelProvider.ts +64 -0
  63. package/src/store/global/slices/settings/{selectors.test.ts → selectors/selectors.test.ts} +2 -97
  64. package/src/store/global/slices/settings/selectors/settings.ts +58 -0
  65. package/src/store/global/store.ts +11 -36
  66. package/src/store/session/slices/agent/action.ts +1 -1
  67. package/src/types/settings/base.ts +13 -0
  68. package/src/types/settings/index.ts +34 -0
  69. package/src/types/settings/modelProvider.ts +17 -0
  70. package/src/types/settings/tts.ts +10 -0
  71. package/src/utils/difference.ts +12 -0
  72. package/tests/utils.tsx +8 -0
  73. package/tsconfig.json +9 -8
  74. package/vitest.config.ts +1 -0
  75. package/src/store/global/hooks/useHydrated.ts +0 -18
  76. package/src/store/global/slices/settings/selectors.ts +0 -105
  77. package/src/types/settings.ts +0 -68
  78. /package/src/app/api/{openai/errorResponse.test.ts → errorResponse.test.ts} +0 -0
  79. /package/src/app/api/{openai/errorResponse.ts → errorResponse.ts} +0 -0
@@ -91,7 +91,8 @@ class ChatService {
91
91
 
92
92
  const res = await fetch(gatewayURL ?? PLUGINS_URLS.gateway, {
93
93
  body: JSON.stringify({ ...params, manifest }),
94
- headers: createHeadersWithPluginSettings(settings),
94
+ // TODO: we can have a better auth way
95
+ headers: createHeadersWithPluginSettings(settings, createHeaderWithOpenAI()),
95
96
  method: 'POST',
96
97
  signal: options?.signal,
97
98
  });
@@ -0,0 +1,31 @@
1
+ import { DeepPartial } from 'utility-types';
2
+
3
+ import { UserModel } from '@/database/models/user';
4
+ import { GlobalSettings } from '@/types/settings';
5
+
6
+ export interface UserConfig {
7
+ avatar?: string;
8
+ // settings: JSONPatch
9
+ settings: DeepPartial<GlobalSettings>;
10
+ }
11
+
12
+ class UserService {
13
+ getUserConfig = async () => {
14
+ const user = await UserModel.getUser();
15
+ return user as unknown as UserConfig;
16
+ };
17
+
18
+ updateUserSettings = async (patch: DeepPartial<GlobalSettings>) => {
19
+ return UserModel.updateSettings(patch);
20
+ };
21
+
22
+ resetUserSettings = async () => {
23
+ return UserModel.resetSettings();
24
+ };
25
+
26
+ updateAvatar(avatar: string) {
27
+ return UserModel.updateAvatar(avatar);
28
+ }
29
+ }
30
+
31
+ export const userService = new UserService();
@@ -1,9 +1,11 @@
1
+ import { act } from '@testing-library/react';
1
2
  import { describe, expect, it } from 'vitest';
2
3
 
3
4
  import { DEFAULT_INBOX_AVATAR } from '@/const/meta';
4
5
  import { INBOX_SESSION_ID } from '@/const/session';
5
6
  import { ChatStore } from '@/store/chat';
6
7
  import { initialState } from '@/store/chat/initialState';
8
+ import { useGlobalStore } from '@/store/global';
7
9
  import { useSessionStore } from '@/store/session';
8
10
  import { ChatMessage } from '@/types/message';
9
11
  import { MetaData } from '@/types/meta';
@@ -155,8 +157,11 @@ describe('chatSelectors', () => {
155
157
  });
156
158
  it('should slice the messages according to config, assuming historyCount is mocked to 2', async () => {
157
159
  const state = merge(initialStore, { messages: mockMessages });
158
-
159
- useSessionStore.getState().updateAgentConfig({ historyCount: 2, enableHistoryCount: true });
160
+ act(() => {
161
+ useGlobalStore.setState({
162
+ settings: { defaultAgent: { config: { historyCount: 2, enableHistoryCount: true } } },
163
+ });
164
+ });
160
165
 
161
166
  const chats = chatSelectors.currentChatsWithHistoryConfig(state);
162
167
 
@@ -4,6 +4,7 @@ import { t } from 'i18next';
4
4
  import { DEFAULT_INBOX_AVATAR, DEFAULT_USER_AVATAR } from '@/const/meta';
5
5
  import { INBOX_SESSION_ID } from '@/const/session';
6
6
  import { useGlobalStore } from '@/store/global';
7
+ import { commonSelectors } from '@/store/global/selectors';
7
8
  import { useSessionStore } from '@/store/session';
8
9
  import { agentSelectors } from '@/store/session/selectors';
9
10
  import { ChatMessage } from '@/types/message';
@@ -17,7 +18,7 @@ const getMeta = (message: ChatMessage) => {
17
18
  switch (message.role) {
18
19
  case 'user': {
19
20
  return {
20
- avatar: useGlobalStore.getState().settings.avatar || DEFAULT_USER_AVATAR,
21
+ avatar: commonSelectors.userAvatar(useGlobalStore.getState()) || DEFAULT_USER_AVATAR,
21
22
  };
22
23
  }
23
24
 
@@ -1,4 +1,3 @@
1
1
  export * from './useEffectAfterHydrated';
2
- export * from './useHydrated';
3
2
  export * from './useOnFinishHydrationGlobal';
4
3
  export * from './useSwitchSideBarOnInit';
@@ -1,11 +1,13 @@
1
1
  import { GlobalCommonState, initialCommonState } from './slices/common/initialState';
2
+ import { GlobalPreferenceState, initialPreferenceState } from './slices/preference/initialState';
2
3
  import { GlobalSettingsState, initialSettingsState } from './slices/settings/initialState';
3
4
 
4
5
  export { SettingsTabs, SidebarTabKey } from './slices/common/initialState';
5
6
 
6
- export type GlobalState = GlobalCommonState & GlobalSettingsState;
7
+ export type GlobalState = GlobalCommonState & GlobalSettingsState & GlobalPreferenceState;
7
8
 
8
9
  export const initialState: GlobalState = {
9
10
  ...initialCommonState,
10
11
  ...initialSettingsState,
12
+ ...initialPreferenceState,
11
13
  };
@@ -1,2 +1,3 @@
1
1
  export * from './slices/common/selectors';
2
+ export * from './slices/preference/selectors';
2
3
  export * from './slices/settings/selectors';
@@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
 
5
5
  import { globalService } from '@/services/global';
6
6
  import { useGlobalStore } from '@/store/global';
7
- import { type Guide, SidebarTabKey } from '@/store/global/slices/common/initialState';
7
+
8
+ import { SidebarTabKey } from './initialState';
8
9
 
9
10
  // Mock globalService
10
11
  vi.mock('@/services/global', () => ({
@@ -49,82 +50,4 @@ describe('createCommonSlice', () => {
49
50
  expect(result.current.sidebarKey).toEqual(SidebarTabKey.Market);
50
51
  });
51
52
  });
52
-
53
- describe('toggleChatSideBar', () => {
54
- it('should toggle chat sidebar', () => {
55
- const { result } = renderHook(() => useGlobalStore());
56
-
57
- act(() => {
58
- useGlobalStore.getState().updatePreference({ showChatSideBar: false });
59
- result.current.toggleChatSideBar();
60
- });
61
-
62
- expect(result.current.preference.showChatSideBar).toBe(true);
63
- });
64
-
65
- // Add more tests for other actions in createCommonSlice
66
- });
67
-
68
- describe('toggleExpandSessionGroup', () => {
69
- it('should toggle expand session group', () => {
70
- const { result } = renderHook(() => useGlobalStore());
71
- const groupId = 'group-id';
72
-
73
- act(() => {
74
- result.current.toggleExpandSessionGroup(groupId, true);
75
- });
76
-
77
- expect(result.current.preference.expandSessionGroupKeys).toContain(groupId);
78
- });
79
- });
80
-
81
- describe('toggleMobileTopic', () => {
82
- it('should toggle mobile topic', () => {
83
- const { result } = renderHook(() => useGlobalStore());
84
-
85
- act(() => {
86
- result.current.toggleMobileTopic();
87
- });
88
-
89
- expect(result.current.preference.mobileShowTopic).toBe(true);
90
- });
91
- });
92
-
93
- describe('toggleSystemRole', () => {
94
- it('should toggle system role', () => {
95
- const { result } = renderHook(() => useGlobalStore());
96
-
97
- act(() => {
98
- result.current.toggleSystemRole(true);
99
- });
100
-
101
- expect(result.current.preference.showSystemRole).toBe(true);
102
- });
103
- });
104
-
105
- describe('updateGuideState', () => {
106
- it('should update guide state', () => {
107
- const { result } = renderHook(() => useGlobalStore());
108
- const guide: Guide = { topic: true };
109
-
110
- act(() => {
111
- result.current.updateGuideState(guide);
112
- });
113
-
114
- expect(result.current.preference.guide).toEqual(guide);
115
- });
116
- });
117
-
118
- describe('updatePreference', () => {
119
- it('should update preference', () => {
120
- const { result } = renderHook(() => useGlobalStore());
121
- const preference = { inputHeight: 200 };
122
-
123
- act(() => {
124
- result.current.updatePreference(preference);
125
- });
126
-
127
- expect(result.current.preference.inputHeight).toEqual(200);
128
- });
129
- });
130
53
  });
@@ -1,97 +1,54 @@
1
- import { produce } from 'immer';
2
1
  import { gt } from 'semver';
3
- import useSWR, { SWRResponse } from 'swr';
2
+ import useSWR, { SWRResponse, mutate } from 'swr';
4
3
  import type { StateCreator } from 'zustand/vanilla';
5
4
 
6
5
  import { INBOX_SESSION_ID } from '@/const/session';
7
6
  import { SESSION_CHAT_URL } from '@/const/url';
8
7
  import { CURRENT_VERSION } from '@/const/version';
9
8
  import { globalService } from '@/services/global';
9
+ import { UserConfig, userService } from '@/services/user';
10
10
  import type { GlobalStore } from '@/store/global';
11
11
  import type { GlobalServerConfig } from '@/types/settings';
12
12
  import { merge } from '@/utils/merge';
13
13
  import { setNamespace } from '@/utils/storeDebug';
14
14
 
15
- import type { GlobalCommonState, GlobalPreference, Guide, SidebarTabKey } from './initialState';
15
+ import type { SidebarTabKey } from './initialState';
16
16
 
17
- const n = setNamespace('settings');
17
+ const n = setNamespace('common');
18
18
 
19
19
  /**
20
20
  * 设置操作
21
21
  */
22
22
  export interface CommonAction {
23
+ refreshUserConfig: () => Promise<void>;
23
24
  switchBackToChat: (sessionId?: string) => void;
24
- /**
25
- * 切换侧边栏选项
26
- * @param key - 选中的侧边栏选项
27
- */
28
25
  switchSideBar: (key: SidebarTabKey) => void;
29
- toggleChatSideBar: (visible?: boolean) => void;
30
- toggleExpandSessionGroup: (id: string, expand: boolean) => void;
31
- toggleMobileTopic: (visible?: boolean) => void;
32
- toggleSystemRole: (visible?: boolean) => void;
33
- updateGuideState: (guide: Partial<Guide>) => void;
34
- updatePreference: (preference: Partial<GlobalPreference>, action?: string) => void;
26
+ updateAvatar: (avatar: string) => Promise<void>;
35
27
  useCheckLatestVersion: () => SWRResponse<string>;
36
- useFetchGlobalConfig: () => SWRResponse;
28
+ useFetchServerConfig: () => SWRResponse;
29
+ useFetchUserConfig: (initServer: boolean) => SWRResponse<UserConfig | undefined>;
37
30
  }
38
31
 
32
+ const USER_CONFIG_FETCH_KEY = 'fetchUserConfig';
33
+
39
34
  export const createCommonSlice: StateCreator<
40
35
  GlobalStore,
41
36
  [['zustand/devtools', never]],
42
37
  [],
43
38
  CommonAction
44
39
  > = (set, get) => ({
40
+ refreshUserConfig: async () => {
41
+ await mutate([USER_CONFIG_FETCH_KEY, true]);
42
+ },
45
43
  switchBackToChat: (sessionId) => {
46
44
  get().router?.push(SESSION_CHAT_URL(sessionId || INBOX_SESSION_ID, get().isMobile));
47
45
  },
48
46
  switchSideBar: (key) => {
49
47
  set({ sidebarKey: key }, false, n('switchSideBar', key));
50
48
  },
51
- toggleChatSideBar: (newValue) => {
52
- const showChatSideBar =
53
- typeof newValue === 'boolean' ? newValue : !get().preference.showChatSideBar;
54
-
55
- get().updatePreference({ showChatSideBar }, n('toggleAgentPanel', newValue) as string);
56
- },
57
- toggleExpandSessionGroup: (id, expand) => {
58
- const { preference } = get();
59
- const nextExpandSessionGroup = produce(preference.expandSessionGroupKeys, (draft) => {
60
- if (expand) {
61
- if (draft.includes(id)) return;
62
- draft.push(id);
63
- } else {
64
- const index = draft.indexOf(id);
65
- if (index !== -1) draft.splice(index, 1);
66
- }
67
- });
68
- get().updatePreference({ expandSessionGroupKeys: nextExpandSessionGroup });
69
- },
70
- toggleMobileTopic: (newValue) => {
71
- const mobileShowTopic =
72
- typeof newValue === 'boolean' ? newValue : !get().preference.mobileShowTopic;
73
-
74
- get().updatePreference({ mobileShowTopic }, n('toggleMobileTopic', newValue) as string);
75
- },
76
- toggleSystemRole: (newValue) => {
77
- const showSystemRole =
78
- typeof newValue === 'boolean' ? newValue : !get().preference.mobileShowTopic;
79
-
80
- get().updatePreference({ showSystemRole }, n('toggleMobileTopic', newValue) as string);
81
- },
82
- updateGuideState: (guide) => {
83
- const { updatePreference } = get();
84
- const nextGuide = merge(get().preference.guide, guide);
85
- updatePreference({ guide: nextGuide });
86
- },
87
- updatePreference: (preference, action) => {
88
- set(
89
- produce((draft: GlobalCommonState) => {
90
- draft.preference = merge(draft.preference, preference);
91
- }),
92
- false,
93
- action,
94
- );
49
+ updateAvatar: async (avatar) => {
50
+ await userService.updateAvatar(avatar);
51
+ await get().refreshUserConfig();
95
52
  },
96
53
  useCheckLatestVersion: () =>
97
54
  useSWR('checkLatestVersion', globalService.getLatestVersion, {
@@ -102,11 +59,30 @@ export const createCommonSlice: StateCreator<
102
59
  set({ hasNewVersion: true, latestVersion: data }, false, n('checkLatestVersion'));
103
60
  },
104
61
  }),
105
- useFetchGlobalConfig: () =>
62
+ useFetchServerConfig: () =>
106
63
  useSWR<GlobalServerConfig>('fetchGlobalConfig', globalService.getGlobalConfig, {
107
64
  onSuccess: (data) => {
108
- if (data) set({ serverConfig: data });
65
+ if (data) {
66
+ const defaultSettings = merge(get().defaultSettings, { defaultAgent: data.defaultAgent });
67
+ set({ defaultSettings, serverConfig: data }, false, n('initGlobalConfig'));
68
+ }
109
69
  },
110
70
  revalidateOnFocus: false,
111
71
  }),
72
+ useFetchUserConfig: (initServer) =>
73
+ useSWR<UserConfig | undefined>(
74
+ [USER_CONFIG_FETCH_KEY, initServer],
75
+ async () => {
76
+ if (!initServer) return;
77
+ return userService.getUserConfig();
78
+ },
79
+ {
80
+ onSuccess: (data) => {
81
+ if (!data) return;
82
+
83
+ set({ avatar: data.avatar, settings: data.settings }, false, n('fetchUserConfig', data));
84
+ },
85
+ revalidateOnFocus: false,
86
+ },
87
+ ),
112
88
  });
@@ -1,6 +1,5 @@
1
1
  import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime';
2
2
 
3
- import { SessionDefaultGroup, SessionGroupId } from '@/types/session';
4
3
 
5
4
  export enum SidebarTabKey {
6
5
  Chat = 'chat',
@@ -20,32 +19,10 @@ export interface Guide {
20
19
  topic?: boolean;
21
20
  }
22
21
 
23
- export interface GlobalPreference {
24
- // which sessionGroup should expand
25
- expandSessionGroupKeys: SessionGroupId[];
26
- guide?: Guide;
27
- inputHeight: number;
28
- mobileShowTopic?: boolean;
29
- sessionsWidth: number;
30
-
31
- showChatSideBar?: boolean;
32
- showSessionPanel?: boolean;
33
- showSystemRole?: boolean;
34
- /**
35
- * whether to use cmd + enter to send message
36
- */
37
- useCmdEnterToSend?: boolean;
38
- }
39
-
40
22
  export interface GlobalCommonState {
41
23
  hasNewVersion?: boolean;
42
24
  isMobile?: boolean;
43
25
  latestVersion?: string;
44
- /**
45
- * 用户偏好的 UI 状态
46
- * @localStorage
47
- */
48
- preference: GlobalPreference;
49
26
  router?: AppRouterInstance;
50
27
  settingsTab: SettingsTabs;
51
28
  sidebarKey: SidebarTabKey;
@@ -53,17 +30,6 @@ export interface GlobalCommonState {
53
30
 
54
31
  export const initialCommonState: GlobalCommonState = {
55
32
  isMobile: false,
56
- preference: {
57
- expandSessionGroupKeys: [SessionDefaultGroup.Pinned, SessionDefaultGroup.Default],
58
- guide: {},
59
- inputHeight: 200,
60
- mobileShowTopic: false,
61
- sessionsWidth: 320,
62
- showChatSideBar: true,
63
- showSessionPanel: true,
64
- showSystemRole: false,
65
- useCmdEnterToSend: false,
66
- },
67
33
  settingsTab: SettingsTabs.Common,
68
34
  sidebarKey: SidebarTabKey.Chat,
69
35
  };
@@ -1,10 +1,5 @@
1
1
  import { GlobalStore } from '@/store/global';
2
2
 
3
- const sessionGroupKeys = (s: GlobalStore): string[] => s.preference.expandSessionGroupKeys || [];
4
-
5
- const useCmdEnterToSend = (s: GlobalStore): boolean => s.preference.useCmdEnterToSend || false;
6
-
7
- export const preferenceSelectors = {
8
- sessionGroupKeys,
9
- useCmdEnterToSend,
3
+ export const commonSelectors = {
4
+ userAvatar: (s: GlobalStore) => s.avatar || '',
10
5
  };
@@ -0,0 +1,92 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { useGlobalStore } from '@/store/global';
5
+
6
+ import { type Guide } from './initialState';
7
+
8
+ beforeEach(() => {
9
+ vi.clearAllMocks();
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ describe('createPreferenceSlice', () => {
17
+ describe('toggleChatSideBar', () => {
18
+ it('should toggle chat sidebar', () => {
19
+ const { result } = renderHook(() => useGlobalStore());
20
+
21
+ act(() => {
22
+ useGlobalStore.getState().updatePreference({ showChatSideBar: false });
23
+ result.current.toggleChatSideBar();
24
+ });
25
+
26
+ expect(result.current.preference.showChatSideBar).toBe(true);
27
+ });
28
+ });
29
+
30
+ describe('toggleExpandSessionGroup', () => {
31
+ it('should toggle expand session group', () => {
32
+ const { result } = renderHook(() => useGlobalStore());
33
+ const groupId = 'group-id';
34
+
35
+ act(() => {
36
+ result.current.toggleExpandSessionGroup(groupId, true);
37
+ });
38
+
39
+ expect(result.current.preference.expandSessionGroupKeys).toContain(groupId);
40
+ });
41
+ });
42
+
43
+ describe('toggleMobileTopic', () => {
44
+ it('should toggle mobile topic', () => {
45
+ const { result } = renderHook(() => useGlobalStore());
46
+
47
+ act(() => {
48
+ result.current.toggleMobileTopic();
49
+ });
50
+
51
+ expect(result.current.preference.mobileShowTopic).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe('toggleSystemRole', () => {
56
+ it('should toggle system role', () => {
57
+ const { result } = renderHook(() => useGlobalStore());
58
+
59
+ act(() => {
60
+ result.current.toggleSystemRole(true);
61
+ });
62
+
63
+ expect(result.current.preference.showSystemRole).toBe(true);
64
+ });
65
+ });
66
+
67
+ describe('updateGuideState', () => {
68
+ it('should update guide state', () => {
69
+ const { result } = renderHook(() => useGlobalStore());
70
+ const guide: Guide = { topic: true };
71
+
72
+ act(() => {
73
+ result.current.updateGuideState(guide);
74
+ });
75
+
76
+ expect(result.current.preference.guide).toEqual(guide);
77
+ });
78
+ });
79
+
80
+ describe('updatePreference', () => {
81
+ it('should update preference', () => {
82
+ const { result } = renderHook(() => useGlobalStore());
83
+ const preference = { inputHeight: 200 };
84
+
85
+ act(() => {
86
+ result.current.updatePreference(preference);
87
+ });
88
+
89
+ expect(result.current.preference.inputHeight).toEqual(200);
90
+ });
91
+ });
92
+ });
@@ -0,0 +1,75 @@
1
+ import { produce } from 'immer';
2
+ import type { StateCreator } from 'zustand/vanilla';
3
+
4
+ import type { GlobalStore } from '@/store/global';
5
+ import { merge } from '@/utils/merge';
6
+ import { setNamespace } from '@/utils/storeDebug';
7
+
8
+ import type { GlobalPreference, GlobalPreferenceState, Guide } from './initialState';
9
+
10
+ const n = setNamespace('preference');
11
+
12
+ /**
13
+ * 设置操作
14
+ */
15
+ export interface PreferenceAction {
16
+ toggleChatSideBar: (visible?: boolean) => void;
17
+ toggleExpandSessionGroup: (id: string, expand: boolean) => void;
18
+ toggleMobileTopic: (visible?: boolean) => void;
19
+ toggleSystemRole: (visible?: boolean) => void;
20
+ updateGuideState: (guide: Partial<Guide>) => void;
21
+ updatePreference: (preference: Partial<GlobalPreference>, action?: string) => void;
22
+ }
23
+
24
+ export const createPreferenceSlice: StateCreator<
25
+ GlobalStore,
26
+ [['zustand/devtools', never]],
27
+ [],
28
+ PreferenceAction
29
+ > = (set, get) => ({
30
+ toggleChatSideBar: (newValue) => {
31
+ const showChatSideBar =
32
+ typeof newValue === 'boolean' ? newValue : !get().preference.showChatSideBar;
33
+
34
+ get().updatePreference({ showChatSideBar }, n('toggleAgentPanel', newValue) as string);
35
+ },
36
+ toggleExpandSessionGroup: (id, expand) => {
37
+ const { preference } = get();
38
+ const nextExpandSessionGroup = produce(preference.expandSessionGroupKeys, (draft) => {
39
+ if (expand) {
40
+ if (draft.includes(id)) return;
41
+ draft.push(id);
42
+ } else {
43
+ const index = draft.indexOf(id);
44
+ if (index !== -1) draft.splice(index, 1);
45
+ }
46
+ });
47
+ get().updatePreference({ expandSessionGroupKeys: nextExpandSessionGroup });
48
+ },
49
+ toggleMobileTopic: (newValue) => {
50
+ const mobileShowTopic =
51
+ typeof newValue === 'boolean' ? newValue : !get().preference.mobileShowTopic;
52
+
53
+ get().updatePreference({ mobileShowTopic }, n('toggleMobileTopic', newValue) as string);
54
+ },
55
+ toggleSystemRole: (newValue) => {
56
+ const showSystemRole =
57
+ typeof newValue === 'boolean' ? newValue : !get().preference.mobileShowTopic;
58
+
59
+ get().updatePreference({ showSystemRole }, n('toggleMobileTopic', newValue) as string);
60
+ },
61
+ updateGuideState: (guide) => {
62
+ const { updatePreference } = get();
63
+ const nextGuide = merge(get().preference.guide, guide);
64
+ updatePreference({ guide: nextGuide });
65
+ },
66
+ updatePreference: (preference, action) => {
67
+ set(
68
+ produce((draft: GlobalPreferenceState) => {
69
+ draft.preference = merge(draft.preference, preference);
70
+ }),
71
+ false,
72
+ action,
73
+ );
74
+ },
75
+ });
@@ -0,0 +1,45 @@
1
+ import { SessionDefaultGroup, SessionGroupId } from '@/types/session';
2
+
3
+ export interface Guide {
4
+ // Topic 引导
5
+ topic?: boolean;
6
+ }
7
+
8
+ export interface GlobalPreference {
9
+ // which sessionGroup should expand
10
+ expandSessionGroupKeys: SessionGroupId[];
11
+ guide?: Guide;
12
+ inputHeight: number;
13
+ mobileShowTopic?: boolean;
14
+ sessionsWidth: number;
15
+
16
+ showChatSideBar?: boolean;
17
+ showSessionPanel?: boolean;
18
+ showSystemRole?: boolean;
19
+ /**
20
+ * whether to use cmd + enter to send message
21
+ */
22
+ useCmdEnterToSend?: boolean;
23
+ }
24
+
25
+ export interface GlobalPreferenceState {
26
+ /**
27
+ * 用户偏好的 UI 状态
28
+ * @localStorage
29
+ */
30
+ preference: GlobalPreference;
31
+ }
32
+
33
+ export const initialPreferenceState: GlobalPreferenceState = {
34
+ preference: {
35
+ expandSessionGroupKeys: [SessionDefaultGroup.Pinned, SessionDefaultGroup.Default],
36
+ guide: {},
37
+ inputHeight: 200,
38
+ mobileShowTopic: false,
39
+ sessionsWidth: 320,
40
+ showChatSideBar: true,
41
+ showSessionPanel: true,
42
+ showSystemRole: false,
43
+ useCmdEnterToSend: false,
44
+ },
45
+ };
@@ -0,0 +1,10 @@
1
+ import { GlobalStore } from '@/store/global';
2
+
3
+ const sessionGroupKeys = (s: GlobalStore): string[] => s.preference.expandSessionGroupKeys || [];
4
+
5
+ const useCmdEnterToSend = (s: GlobalStore): boolean => s.preference.useCmdEnterToSend || false;
6
+
7
+ export const preferenceSelectors = {
8
+ sessionGroupKeys,
9
+ useCmdEnterToSend,
10
+ };