From 615e79635799107d79d3501b716439835d4f4861 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Tue, 30 Jan 2024 22:38:25 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20refactor=20the?= =?UTF-8?q?=20setting=20storage=20from=20localStorage=20to=20indexedDB=20(?= =?UTF-8?q?#1180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🚧 wip: add user schema * ♻️ refactor: refactor the copy with new api * ♻️ refactor: refactor a preference slice * ♻️ refactor: refactor the settings store * ✅ test: fix test * 🎨 chore: clean code * 🎨 chore: clean code * 🎨 chore: clean code * 🎨 chore: clean code * 🐛 fix: fix update default agent * 🐛 fix: fix update default agent --- package.json | 3 - src/app/api/config/route.ts | 4 +- .../chat/(mobile)/features/SessionHeader.tsx | 3 +- .../ChatHeader/ShareButton/ShareModal.tsx | 3 +- .../SubmitAgentButton/SubmitAgentModal.tsx | 3 +- src/app/settings/(mobile)/mobile/index.tsx | 3 +- src/app/settings/common/Common.tsx | 17 ++- .../ThemeSwatches/ThemeSwatchesNeutral.tsx | 3 +- .../ThemeSwatches/ThemeSwatchesPrimary.tsx | 3 +- src/app/settings/llm/LLM/index.tsx | 27 ++-- src/const/settings.ts | 1 - src/database/core/db.ts | 43 +++++- .../migrateSettingsToUser/fixtures/input.json | 55 ++++++++ .../fixtures/output.json | 60 ++++++++ .../migrateSettingsToUser/index.test.ts | 13 ++ .../migrations/migrateSettingsToUser/index.ts | 39 ++++++ src/database/core/schemas.ts | 10 ++ src/database/models/user.ts | 54 +++++++ src/database/schemas/user.ts | 57 ++++++++ .../AgentSetting/AgentConfig/index.tsx | 4 +- src/features/AvatarWithUpload/index.tsx | 8 +- .../ChatInput/ActionBar/ModelSwitch.tsx | 4 +- .../Conversation/Error/ApiKeyForm.tsx | 6 +- .../Conversation/Error/InvalidAccess.tsx | 6 +- .../Conversation/Extras/Translate.tsx | 7 +- src/layout/GlobalLayout/AppTheme.tsx | 7 +- src/layout/GlobalLayout/Locale.tsx | 3 +- src/layout/GlobalLayout/StoreHydration.tsx | 9 +- src/services/_header.ts | 5 +- src/services/user.ts | 31 ++++ .../chat/slices/message/selectors.test.ts | 9 +- src/store/chat/slices/message/selectors.ts | 3 +- src/store/global/hooks/index.ts | 1 - src/store/global/hooks/useHydrated.ts | 18 --- src/store/global/initialState.ts | 4 +- src/store/global/selectors.ts | 1 + src/store/global/slices/common/action.test.ts | 81 +---------- src/store/global/slices/common/action.ts | 98 +++++-------- .../global/slices/common/initialState.ts | 34 ----- src/store/global/slices/common/selectors.ts | 9 +- .../global/slices/preference/action.test.ts | 92 ++++++++++++ src/store/global/slices/preference/action.ts | 75 ++++++++++ .../global/slices/preference/initialState.ts | 45 ++++++ .../global/slices/preference/selectors.ts | 10 ++ .../global/slices/settings/action.test.ts | 107 ++++++++------ src/store/global/slices/settings/action.ts | 76 ++++------ .../global/slices/settings/initialState.ts | 14 +- src/store/global/slices/settings/selectors.ts | 105 -------------- .../__snapshots__/modelProvider.test.ts.snap | 100 +++++++++++++ .../__snapshots__/selectors.test.ts.snap | 132 ++++++++++++++++++ .../global/slices/settings/selectors/index.ts | 2 + .../settings/selectors/modelProvider.test.ts | 100 +++++++++++++ .../settings/selectors/modelProvider.ts | 64 +++++++++ .../{ => selectors}/selectors.test.ts | 99 +------------ .../slices/settings/selectors/settings.ts | 58 ++++++++ src/store/global/store.ts | 47 ++----- src/store/session/slices/agent/action.ts | 2 +- src/types/settings.ts | 68 --------- src/types/settings/base.ts | 13 ++ src/types/settings/index.ts | 34 +++++ src/types/settings/modelProvider.ts | 17 +++ src/types/settings/tts.ts | 10 ++ src/utils/difference.ts | 12 ++ tests/utils.tsx | 8 ++ tsconfig.json | 17 +-- vitest.config.ts | 1 + 66 files changed, 1395 insertions(+), 662 deletions(-) create mode 100644 src/database/core/migrations/migrateSettingsToUser/fixtures/input.json create mode 100644 src/database/core/migrations/migrateSettingsToUser/fixtures/output.json create mode 100644 src/database/core/migrations/migrateSettingsToUser/index.test.ts create mode 100644 src/database/core/migrations/migrateSettingsToUser/index.ts create mode 100644 src/database/models/user.ts create mode 100644 src/database/schemas/user.ts create mode 100644 src/services/user.ts delete mode 100644 src/store/global/hooks/useHydrated.ts create mode 100644 src/store/global/slices/preference/action.test.ts create mode 100644 src/store/global/slices/preference/action.ts create mode 100644 src/store/global/slices/preference/initialState.ts create mode 100644 src/store/global/slices/preference/selectors.ts delete mode 100644 src/store/global/slices/settings/selectors.ts create mode 100644 src/store/global/slices/settings/selectors/__snapshots__/modelProvider.test.ts.snap create mode 100644 src/store/global/slices/settings/selectors/__snapshots__/selectors.test.ts.snap create mode 100644 src/store/global/slices/settings/selectors/index.ts create mode 100644 src/store/global/slices/settings/selectors/modelProvider.test.ts create mode 100644 src/store/global/slices/settings/selectors/modelProvider.ts rename src/store/global/slices/settings/{ => selectors}/selectors.test.ts (66%) create mode 100644 src/store/global/slices/settings/selectors/settings.ts delete mode 100644 src/types/settings.ts create mode 100644 src/types/settings/base.ts create mode 100644 src/types/settings/index.ts create mode 100644 src/types/settings/modelProvider.ts create mode 100644 src/types/settings/tts.ts create mode 100644 src/utils/difference.ts create mode 100644 tests/utils.tsx diff --git a/package.json b/package.json index d3a210c8ff29f..47e91f4a7992e 100644 --- a/package.json +++ b/package.json @@ -86,14 +86,12 @@ "antd-style": "^3", "brotli-wasm": "^2", "chroma-js": "^2", - "copy-to-clipboard": "^3", "dayjs": "^1", "dexie": "^3", "fast-deep-equal": "^3", "gpt-tokenizer": "^2", "i18next": "^23", "i18next-browser-languagedetector": "^7", - "i18next-resources-for-ts": "^1", "i18next-resources-to-backend": "^1", "idb-keyval": "^6", "immer": "^10", @@ -111,7 +109,6 @@ "react-dom": "^18", "react-hotkeys-hook": "^4", "react-i18next": "^14", - "react-intersection-observer": "^9", "react-layout-kit": "^1", "react-lazy-load": "^4", "react-virtuoso": "^4.6.2", diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts index 61db882bc305e..c19a9c9686670 100644 --- a/src/app/api/config/route.ts +++ b/src/app/api/config/route.ts @@ -9,7 +9,9 @@ export const runtime = 'edge'; export const GET = async () => { const { CUSTOM_MODELS } = getServerConfig(); - const config: GlobalServerConfig = { customModelName: CUSTOM_MODELS }; + const config: GlobalServerConfig = { + customModelName: CUSTOM_MODELS, + }; return new Response(JSON.stringify(config)); }; diff --git a/src/app/chat/(mobile)/features/SessionHeader.tsx b/src/app/chat/(mobile)/features/SessionHeader.tsx index 6f2366360b0b2..f0aa2ca6b18f6 100644 --- a/src/app/chat/(mobile)/features/SessionHeader.tsx +++ b/src/app/chat/(mobile)/features/SessionHeader.tsx @@ -6,6 +6,7 @@ import { memo } from 'react'; import { MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens'; import { useGlobalStore } from '@/store/global'; +import { commonSelectors } from '@/store/global/selectors'; import { useSessionStore } from '@/store/session'; export const useStyles = createStyles(({ css, token }) => ({ @@ -21,7 +22,7 @@ export const useStyles = createStyles(({ css, token }) => ({ const Header = memo(() => { const [createSession] = useSessionStore((s) => [s.createSession]); const router = useRouter(); - const avatar = useGlobalStore((st) => st.settings.avatar); + const avatar = useGlobalStore(commonSelectors.userAvatar); return ( } diff --git a/src/app/chat/features/ChatHeader/ShareButton/ShareModal.tsx b/src/app/chat/features/ChatHeader/ShareButton/ShareModal.tsx index 408575c46fb24..2f11193881fea 100644 --- a/src/app/chat/features/ChatHeader/ShareButton/ShareModal.tsx +++ b/src/app/chat/features/ChatHeader/ShareButton/ShareModal.tsx @@ -7,6 +7,7 @@ import { Flexbox } from 'react-layout-kit'; import { FORM_STYLE } from '@/const/layoutTokens'; import { useChatStore } from '@/store/chat'; import { useGlobalStore } from '@/store/global'; +import { commonSelectors } from '@/store/global/selectors'; import Preview from './Preview'; import { FieldType, ImageType } from './type'; @@ -48,7 +49,7 @@ const ShareModal = memo(({ onCancel, open }) => { const [fieldValue, setFieldValue] = useState(DEFAULT_FIELD_VALUE); const [tab, setTab] = useState(Tab.Screenshot); const { t } = useTranslation('chat'); - const avatar = useGlobalStore((s) => s.settings.avatar); + const avatar = useGlobalStore(commonSelectors.userAvatar); const [shareLoading, shareToShareGPT] = useChatStore((s) => [s.shareLoading, s.shareToShareGPT]); const { loading, onDownload, title } = useScreenshot(fieldValue.imageType); diff --git a/src/app/chat/settings/features/SubmitAgentButton/SubmitAgentModal.tsx b/src/app/chat/settings/features/SubmitAgentButton/SubmitAgentModal.tsx index b1ea68a378bb9..7f963faa07f13 100644 --- a/src/app/chat/settings/features/SubmitAgentButton/SubmitAgentModal.tsx +++ b/src/app/chat/settings/features/SubmitAgentButton/SubmitAgentModal.tsx @@ -11,6 +11,7 @@ import { Flexbox } from 'react-layout-kit'; import { AGENTS_INDEX_GITHUB_ISSUE } from '@/const/url'; import AgentInfo from '@/features/AgentInfo'; import { useGlobalStore } from '@/store/global'; +import { settingsSelectors } from '@/store/global/selectors'; import { useSessionStore } from '@/store/session'; import { agentSelectors } from '@/store/session/selectors'; @@ -20,7 +21,7 @@ const SubmitAgentModal = memo(({ open, onCancel }) => { const systemRole = useSessionStore(agentSelectors.currentAgentSystemRole); const theme = useTheme(); const meta = useSessionStore(agentSelectors.currentAgentMeta, isEqual); - const language = useGlobalStore((s) => s.settings.language); + const language = useGlobalStore((s) => settingsSelectors.currentSettings(s).language); const isMetaPass = Boolean( meta && meta.title && meta.description && (meta.tags as string[])?.length > 0 && meta.avatar, diff --git a/src/app/settings/(mobile)/mobile/index.tsx b/src/app/settings/(mobile)/mobile/index.tsx index 0c25ac84e1753..ac26eb4203231 100644 --- a/src/app/settings/(mobile)/mobile/index.tsx +++ b/src/app/settings/(mobile)/mobile/index.tsx @@ -9,6 +9,7 @@ import { CURRENT_VERSION } from '@/const/version'; import AvatarWithUpload from '@/features/AvatarWithUpload'; import { useGlobalStore, useSwitchSideBarOnInit } from '@/store/global'; import { SidebarTabKey } from '@/store/global/initialState'; +import { commonSelectors } from '@/store/global/selectors'; import List from '../../features/SideBar/List'; import AvatarBanner from '../features/AvatarBanner'; @@ -28,7 +29,7 @@ const useStyles = createStyles(({ css, token }) => ({ const Setting = memo(() => { useSwitchSideBarOnInit(SidebarTabKey.Setting); - const avatar = useGlobalStore((s) => s.settings.avatar); + const avatar = useGlobalStore(commonSelectors.userAvatar); const { styles } = useStyles(); return ( diff --git a/src/app/settings/common/Common.tsx b/src/app/settings/common/Common.tsx index a07c5e8d908c9..706709508b6b8 100644 --- a/src/app/settings/common/Common.tsx +++ b/src/app/settings/common/Common.tsx @@ -1,9 +1,8 @@ import { Form, type ItemGroup, SelectWithImg, SliderWithInput } from '@lobehub/ui'; import { Form as AntForm, App, Button, Input, Select } from 'antd'; import isEqual from 'fast-deep-equal'; -import { debounce } from 'lodash-es'; import { AppWindow, Monitor, Moon, Palette, Sun } from 'lucide-react'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { FORM_STYLE } from '@/const/layoutTokens'; @@ -222,12 +221,24 @@ const Common = memo(({ showAccessCodeConfig }) => { title: t('settingSystem.title'), }; + useEffect(() => { + const unsubscribe = useGlobalStore.subscribe( + (s) => s.settings, + (settings) => { + form.setFieldsValue(settings); + }, + ); + return () => { + unsubscribe(); + }; + }, []); + return (
); diff --git a/src/app/settings/features/ThemeSwatches/ThemeSwatchesNeutral.tsx b/src/app/settings/features/ThemeSwatches/ThemeSwatchesNeutral.tsx index 2f433193bee06..5be247955bae0 100644 --- a/src/app/settings/features/ThemeSwatches/ThemeSwatchesNeutral.tsx +++ b/src/app/settings/features/ThemeSwatches/ThemeSwatchesNeutral.tsx @@ -8,10 +8,11 @@ import { import { memo } from 'react'; import { useGlobalStore } from '@/store/global'; +import { settingsSelectors } from '@/store/global/selectors'; const ThemeSwatchesNeutral = memo(() => { const [neutralColor, setSettings] = useGlobalStore((s) => [ - s.settings.neutralColor, + settingsSelectors.currentSettings(s).neutralColor, s.setSettings, ]); diff --git a/src/app/settings/features/ThemeSwatches/ThemeSwatchesPrimary.tsx b/src/app/settings/features/ThemeSwatches/ThemeSwatchesPrimary.tsx index eaf7e2ec82ea3..941a891604538 100644 --- a/src/app/settings/features/ThemeSwatches/ThemeSwatchesPrimary.tsx +++ b/src/app/settings/features/ThemeSwatches/ThemeSwatchesPrimary.tsx @@ -8,10 +8,11 @@ import { import { memo } from 'react'; import { useGlobalStore } from '@/store/global'; +import { settingsSelectors } from '@/store/global/selectors'; const ThemeSwatchesPrimary = memo(() => { const [primaryColor, setSettings] = useGlobalStore((s) => [ - s.settings.primaryColor, + settingsSelectors.currentSettings(s).primaryColor, s.setSettings, ]); diff --git a/src/app/settings/llm/LLM/index.tsx b/src/app/settings/llm/LLM/index.tsx index deadc55ac33d9..9504881c7d93d 100644 --- a/src/app/settings/llm/LLM/index.tsx +++ b/src/app/settings/llm/LLM/index.tsx @@ -3,12 +3,12 @@ import { Form as AntForm, AutoComplete, Input, Switch } from 'antd'; import { createStyles } from 'antd-style'; import { debounce } from 'lodash-es'; import { Webhook } from 'lucide-react'; -import { memo } from 'react'; +import { memo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { FORM_STYLE } from '@/const/layoutTokens'; -import { useEffectAfterGlobalHydrated, useGlobalStore } from '@/store/global'; -import { settingsSelectors } from '@/store/global/selectors'; +import { useGlobalStore } from '@/store/global'; +import { modelProviderSelectors } from '@/store/global/selectors'; import Checker from './Checker'; @@ -35,16 +35,23 @@ const LLM = memo(() => { const { t } = useTranslation('setting'); const [form] = AntForm.useForm(); const { styles } = useStyles(); - const [setSettings] = useGlobalStore((s) => [s.setSettings]); + const [useAzure, setSettings] = useGlobalStore((s) => [ + modelProviderSelectors.enableAzure(s), + s.setSettings, + ]); - useEffectAfterGlobalHydrated((store) => { - const settings = settingsSelectors.currentSettings(store.getState()); - - form.setFieldsValue(settings); + useEffect(() => { + const unsubscribe = useGlobalStore.subscribe( + (s) => s.settings, + (settings) => { + form.setFieldsValue(settings); + }, + ); + return () => { + unsubscribe(); + }; }, []); - const useAzure = useGlobalStore((s) => s.settings.languageModel.openAI.useAzure); - const openAI: ItemGroup = { children: [ { diff --git a/src/const/settings.ts b/src/const/settings.ts index 82fdde3b62ffb..56189600d9ed2 100644 --- a/src/const/settings.ts +++ b/src/const/settings.ts @@ -11,7 +11,6 @@ import { } from '@/types/settings'; export const DEFAULT_BASE_SETTINGS: GlobalBaseSettings = { - avatar: '', fontSize: 14, language: 'auto', password: '', diff --git a/src/database/core/db.ts b/src/database/core/db.ts index 600f4a445e4a3..ac431f8018e43 100644 --- a/src/database/core/db.ts +++ b/src/database/core/db.ts @@ -1,14 +1,16 @@ import Dexie, { Transaction } from 'dexie'; -import { DBModel, LOBE_CHAT_LOCAL_DB_NAME } from '@/database/core/types/db'; import { DB_File } from '@/database/schemas/files'; import { DB_Message } from '@/database/schemas/message'; import { DB_Plugin } from '@/database/schemas/plugin'; import { DB_Session } from '@/database/schemas/session'; import { DB_SessionGroup } from '@/database/schemas/sessionGroup'; import { DB_Topic } from '@/database/schemas/topic'; +import { DB_User } from '@/database/schemas/user'; -import { dbSchemaV1, dbSchemaV2, dbSchemaV3, dbSchemaV4 } from './schemas'; +import { migrateSettingsToUser } from './migrations/migrateSettingsToUser'; +import { dbSchemaV1, dbSchemaV2, dbSchemaV3, dbSchemaV4, dbSchemaV5 } from './schemas'; +import { DBModel, LOBE_CHAT_LOCAL_DB_NAME } from './types/db'; interface LobeDBSchemaMap { files: DB_File; @@ -17,6 +19,7 @@ interface LobeDBSchemaMap { sessionGroups: DB_SessionGroup; sessions: DB_Session; topics: DB_Topic; + users: DB_User; } // Define a local DB @@ -27,6 +30,7 @@ export class LocalDB extends Dexie { public topics: LobeDBTable<'topics'>; public plugins: LobeDBTable<'plugins'>; public sessionGroups: LobeDBTable<'sessionGroups'>; + public users: LobeDBTable<'users'>; constructor() { super(LOBE_CHAT_LOCAL_DB_NAME); @@ -37,12 +41,17 @@ export class LocalDB extends Dexie { .stores(dbSchemaV4) .upgrade((trans) => this.upgradeToV4(trans)); + this.version(5) + .stores(dbSchemaV5) + .upgrade((trans) => this.upgradeToV5(trans)); + this.files = this.table('files'); this.sessions = this.table('sessions'); this.messages = this.table('messages'); this.topics = this.table('topics'); this.plugins = this.table('plugins'); this.sessionGroups = this.table('sessionGroups'); + this.users = this.table('users'); } /** @@ -59,6 +68,36 @@ export class LocalDB extends Dexie { session.group = 'default'; }); }; + + /** + * 2024.01.29 + * settings from localStorage to indexedDB + */ + upgradeToV5 = async (trans: Transaction) => { + const users = trans.table('users'); + + // if no user, create one + if ((await users.count()) === 0) { + const data = localStorage.getItem('LOBE_SETTINGS'); + + if (data) { + let json; + + try { + json = JSON.parse(data); + } catch { + /* empty */ + } + + if (!json?.state?.settings) return; + + const settings = json.state.settings; + + const user = migrateSettingsToUser(settings); + await users.add(user); + } + } + }; } export const LocalDBInstance = new LocalDB(); diff --git a/src/database/core/migrations/migrateSettingsToUser/fixtures/input.json b/src/database/core/migrations/migrateSettingsToUser/fixtures/input.json new file mode 100644 index 0000000000000..3ca07ed774bc5 --- /dev/null +++ b/src/database/core/migrations/migrateSettingsToUser/fixtures/input.json @@ -0,0 +1,55 @@ +{ + "avatar": "", + "defaultAgent": { + "config": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1, + "model": "gpt-3.5-turbo-1106", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.7, + "top_p": 0.7 + }, + "plugins": ["website-crawler"], + "systemRole": "", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { "openai": "alloy" } + }, + "enableHistoryCount": true + }, + "meta": {} + }, + "fontSize": 14, + "language": "auto", + "languageModel": { + "openAI": { + "OPENAI_API_KEY": "dfdf", + "models": [ + "gpt-3.5-turbo", + "gpt-3.5-turbo-1106", + "gpt-3.5-turbo-16k", + "gpt-4", + "gpt-4-32k", + "gpt-4-1106-preview", + "gpt-4-vision-preview" + ], + "customModelName": "+glm-4,+glm-4v", + "endpoint": "sdaddsd" + }, + "zhipu": { "ZHIPU_API_KEY": "", "enabled": false } + }, + "password": "", + "themeMode": "auto", + "tool": { "dalle": { "autoGenerate": false } }, + "tts": { + "openAI": { "sttModel": "whisper-1", "ttsModel": "tts-1" }, + "sttAutoStop": true, + "sttServer": "openai" + } +} diff --git a/src/database/core/migrations/migrateSettingsToUser/fixtures/output.json b/src/database/core/migrations/migrateSettingsToUser/fixtures/output.json new file mode 100644 index 0000000000000..a366875c66934 --- /dev/null +++ b/src/database/core/migrations/migrateSettingsToUser/fixtures/output.json @@ -0,0 +1,60 @@ +{ + "avatar": "", + "settings": { + "defaultAgent": { + "config": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1, + "model": "gpt-3.5-turbo-1106", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.7, + "top_p": 0.7 + }, + "plugins": ["website-crawler"], + "systemRole": "", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { + "openai": "alloy" + } + }, + "enableHistoryCount": true + }, + "meta": {} + }, + "fontSize": 14, + "language": "auto", + "languageModel": { + "openai": { + "OPENAI_API_KEY": "dfdf", + "models": [ + "gpt-3.5-turbo", + "gpt-3.5-turbo-1106", + "gpt-3.5-turbo-16k", + "gpt-4", + "gpt-4-32k", + "gpt-4-1106-preview", + "gpt-4-vision-preview" + ], + "customModelName": "+glm-4,+glm-4v", + "endpoint": "sdaddsd" + } + }, + "password": "", + "themeMode": "auto", + "tts": { + "openAI": { + "sttModel": "whisper-1", + "ttsModel": "tts-1" + }, + "sttAutoStop": true, + "sttServer": "openai" + } + } +} diff --git a/src/database/core/migrations/migrateSettingsToUser/index.test.ts b/src/database/core/migrations/migrateSettingsToUser/index.test.ts new file mode 100644 index 0000000000000..9067f561dfae3 --- /dev/null +++ b/src/database/core/migrations/migrateSettingsToUser/index.test.ts @@ -0,0 +1,13 @@ +import { describe, expect } from 'vitest'; + +import input from './fixtures/input.json'; +import outputData from './fixtures/output.json'; +import { V4Settings, migrateSettingsToUser } from './index'; + +describe('migrateSettingsFromLocalStorage', () => { + it('from localStorage to indexedDB', () => { + const output = migrateSettingsToUser(input as V4Settings); + + expect(output).toEqual(outputData); + }); +}); diff --git a/src/database/core/migrations/migrateSettingsToUser/index.ts b/src/database/core/migrations/migrateSettingsToUser/index.ts new file mode 100644 index 0000000000000..13d48d20a2126 --- /dev/null +++ b/src/database/core/migrations/migrateSettingsToUser/index.ts @@ -0,0 +1,39 @@ +import type { NeutralColors, PrimaryColors } from '@lobehub/ui'; +import type { ThemeMode } from 'antd-style'; + +import { DB_Settings, DB_User } from '@/database/schemas/user'; +import { LocaleMode } from '@/types/locale'; +import { GlobalDefaultAgent, GlobalLLMConfig, GlobalTTSConfig, GlobalTool } from '@/types/settings'; + +export interface V4Settings { + avatar: string; + defaultAgent: GlobalDefaultAgent; + fontSize: number; + language: LocaleMode; + languageModel: GlobalLLMConfig; + neutralColor?: NeutralColors; + password: string; + primaryColor?: PrimaryColors; + themeMode: ThemeMode; + tool: GlobalTool; + tts: GlobalTTSConfig; +} + +export const migrateSettingsToUser = (settings: V4Settings): DB_User => { + const dbSettings: DB_Settings = { + defaultAgent: settings.defaultAgent, + fontSize: settings.fontSize, + language: settings.language, + languageModel: { + openai: settings.languageModel.openAI, + }, + password: settings.password, + themeMode: settings.themeMode, + tts: settings.tts, + }; + + return { + avatar: settings.avatar, + settings: dbSettings, + }; +}; diff --git a/src/database/core/schemas.ts b/src/database/core/schemas.ts index cfe7adf22c15e..1d48c8b8dc694 100644 --- a/src/database/core/schemas.ts +++ b/src/database/core/schemas.ts @@ -44,3 +44,13 @@ export const dbSchemaV4 = { sessions: '&id, type, group, pinned, meta.title, meta.description, meta.tags, createdAt, updatedAt', }; + +// ************************************** // +// ******* Version 5 - 2024-01-29 ******* // +// ************************************** // +// - Added `users` table + +export const dbSchemaV5 = { + ...dbSchemaV4, + users: '++id', +}; diff --git a/src/database/models/user.ts b/src/database/models/user.ts new file mode 100644 index 0000000000000..6ed4e4bdb72c4 --- /dev/null +++ b/src/database/models/user.ts @@ -0,0 +1,54 @@ +import { DeepPartial } from 'utility-types'; + +import { BaseModel } from '@/database/core'; +import { GlobalSettings } from '@/types/settings'; + +import { DB_User, DB_UserSchema } from '../schemas/user'; + +class _UserModel extends BaseModel { + constructor() { + super('users', DB_UserSchema); + } + + getUser = async (): Promise => { + const hasUSer = !!(await this.table.count()); + + if (!hasUSer) await this.table.put({}); + + const list = await this.table.toArray(); + + return list[0]; + }; + + create = async (user: DB_User) => { + return this.table.put(user); + }; + + private update = async (id: number, value: DeepPartial) => { + return this.table.update(id, value); + }; + + clear() { + return this.table.clear(); + } + + async updateSettings(settings: DeepPartial) { + const user = await this.getUser(); + + return this.update(user.id, { settings: settings as any }); + } + + async resetSettings() { + const user = await this.getUser(); + + return this.update(user.id, { avatar: undefined, settings: undefined }); + } + + async updateAvatar(avatar: string) { + const user = await this.getUser(); + + return this.update(user.id, { avatar }); + } +} + +export const UserModel = new _UserModel(); diff --git a/src/database/schemas/user.ts b/src/database/schemas/user.ts new file mode 100644 index 0000000000000..8a2ff65728392 --- /dev/null +++ b/src/database/schemas/user.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +import { AgentSchema } from '@/database/schemas/session'; +import { LobeMetaDataSchema } from '@/types/meta'; + +const modelProviderSchema = z.object({ + openai: z.object({ + OPENAI_API_KEY: z.string().optional(), + azureApiVersion: z.string().optional(), + customModelName: z.string().optional(), + endpoint: z.string().optional(), + models: z.array(z.string()).optional(), + useAzure: z.boolean().optional(), + }), + // zhipu: z.object({ + // ZHIPU_API_KEY: z.string().optional(), + // enabled: z.boolean().default(false), + // }), +}); + +const settingsSchema = z.object({ + defaultAgent: z.object({ + config: AgentSchema, + meta: LobeMetaDataSchema, + }), + fontSize: z.number().default(14), + language: z.string(), + languageModel: modelProviderSchema.partial(), + password: z.string(), + themeMode: z.string(), + tts: z.object({ + openAI: z.object({ + sttModel: z.string(), + ttsModel: z.string(), + }), + sttAutoStop: z.boolean(), + sttServer: z.string(), + }), +}); + +// const patchSchema = z.array( +// z.object({ +// op: z.string(), +// path: z.string(), +// value: z.any(), +// }), +// ); + +export const DB_UserSchema = z.object({ + avatar: z.string().optional(), + settings: settingsSchema.partial(), + // settings: patchSchema, +}); + +export type DB_User = z.infer; + +export type DB_Settings = z.infer; diff --git a/src/features/AgentSetting/AgentConfig/index.tsx b/src/features/AgentSetting/AgentConfig/index.tsx index f4ec8078d3d4a..61677f61c493b 100644 --- a/src/features/AgentSetting/AgentConfig/index.tsx +++ b/src/features/AgentSetting/AgentConfig/index.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'; import { FORM_STYLE } from '@/const/layoutTokens'; import { useGlobalStore } from '@/store/global'; -import { settingsSelectors } from '@/store/global/selectors'; +import { modelProviderSelectors } from '@/store/global/selectors'; import { useStore } from '../store'; @@ -21,7 +21,7 @@ const AgentConfig = memo(() => { const config = useStore((s) => s.config, isEqual); - const modelList = useGlobalStore(settingsSelectors.modelList); + const modelList = useGlobalStore(modelProviderSelectors.modelList); useEffect(() => { form.setFieldsValue(config); diff --git a/src/features/AvatarWithUpload/index.tsx b/src/features/AvatarWithUpload/index.tsx index 77f05688b5294..a9438b4c4fc7d 100644 --- a/src/features/AvatarWithUpload/index.tsx +++ b/src/features/AvatarWithUpload/index.tsx @@ -5,6 +5,7 @@ import Avatar from 'next/image'; import { CSSProperties, memo } from 'react'; import { useGlobalStore } from '@/store/global'; +import { commonSelectors } from '@/store/global/selectors'; import { imageToBase64 } from '@/utils/imageToBase64'; import { createUploadImageHandler } from '@/utils/uploadFIle'; @@ -36,15 +37,18 @@ interface AvatarWithUploadProps { const AvatarWithUpload = memo( ({ size = 40, compressSize = 256, style, id }) => { - const [avatar, setSettings] = useGlobalStore((st) => [st.settings.avatar, st.setSettings]); const { styles } = useStyle(); + const [avatar, updateAvatar] = useGlobalStore((s) => [ + commonSelectors.userAvatar(s), + s.updateAvatar, + ]); const handleUploadAvatar = createUploadImageHandler((avatar) => { const img = new Image(); img.src = avatar; img.addEventListener('load', () => { const webpBase64 = imageToBase64({ img, size: compressSize }); - setSettings({ avatar: webpBase64 }); + updateAvatar(webpBase64); }); }); diff --git a/src/features/ChatInput/ActionBar/ModelSwitch.tsx b/src/features/ChatInput/ActionBar/ModelSwitch.tsx index 96e5de5ca4433..275c87fbd207c 100644 --- a/src/features/ChatInput/ActionBar/ModelSwitch.tsx +++ b/src/features/ChatInput/ActionBar/ModelSwitch.tsx @@ -6,7 +6,7 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { useGlobalStore } from '@/store/global'; -import { settingsSelectors } from '@/store/global/selectors'; +import { modelProviderSelectors } from '@/store/global/selectors'; import { useSessionStore } from '@/store/session'; import { agentSelectors } from '@/store/session/selectors'; import { LanguageModel } from '@/types/llm'; @@ -18,7 +18,7 @@ const ModelSwitch = memo(() => { return [agentSelectors.currentAgentModel(s), s.updateAgentConfig]; }); - const modelList = useGlobalStore(settingsSelectors.modelList, isEqual); + const modelList = useGlobalStore(modelProviderSelectors.modelList, isEqual); return ( (({ id }) => { const [showProxy, setShow] = useState(false); const [apiKey, proxyUrl, setConfig] = useGlobalStore((s) => [ - settingsSelectors.openAIAPI(s), - settingsSelectors.openAIProxyUrl(s), + modelProviderSelectors.openAIAPI(s), + modelProviderSelectors.openAIProxyUrl(s), s.setOpenAIConfig, ]); diff --git a/src/features/Conversation/Error/InvalidAccess.tsx b/src/features/Conversation/Error/InvalidAccess.tsx index 84833362bacf5..434012ec93ee8 100644 --- a/src/features/Conversation/Error/InvalidAccess.tsx +++ b/src/features/Conversation/Error/InvalidAccess.tsx @@ -7,6 +7,7 @@ import { Flexbox } from 'react-layout-kit'; import { useChatStore } from '@/store/chat'; import { useGlobalStore } from '@/store/global'; +import { settingsSelectors } from '@/store/global/selectors'; import { RenderErrorMessage } from '../types'; import APIKeyForm from './ApiKeyForm'; @@ -20,7 +21,10 @@ enum Tab { const InvalidAccess: RenderErrorMessage['Render'] = memo(({ id }) => { const { t } = useTranslation('error'); const [mode, setMode] = useState(Tab.Password); - const [password, setSettings] = useGlobalStore((s) => [s.settings.password, s.setSettings]); + const [password, setSettings] = useGlobalStore((s) => [ + settingsSelectors.currentSettings(s).password, + s.setSettings, + ]); const [resend, deleteMessage] = useChatStore((s) => [s.resendMessage, s.deleteMessage]); return ( diff --git a/src/features/Conversation/Extras/Translate.tsx b/src/features/Conversation/Extras/Translate.tsx index a8f2a015148f7..56133c0e89531 100644 --- a/src/features/Conversation/Extras/Translate.tsx +++ b/src/features/Conversation/Extras/Translate.tsx @@ -1,7 +1,6 @@ -import { ActionIcon, Icon, Markdown, Tag } from '@lobehub/ui'; +import { ActionIcon, Icon, Markdown, Tag, copyToClipboard } from '@lobehub/ui'; import { App } from 'antd'; import { createStyles } from 'antd-style'; -import copy from 'copy-to-clipboard'; import { ChevronDown, ChevronUp, ChevronsRight, CopyIcon, TrashIcon } from 'lucide-react'; import { memo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -41,8 +40,8 @@ const Translate = memo(({ content = '', from, to, id, loading }) { - copy(content); + onClick={async () => { + await copyToClipboard(content); message.success(t('copySuccess')); }} size={'small'} diff --git a/src/layout/GlobalLayout/AppTheme.tsx b/src/layout/GlobalLayout/AppTheme.tsx index 052bf262ddb77..b58848b6fcc91 100644 --- a/src/layout/GlobalLayout/AppTheme.tsx +++ b/src/layout/GlobalLayout/AppTheme.tsx @@ -9,6 +9,7 @@ import { LOBE_THEME_PRIMARY_COLOR, } from '@/const/theme'; import { useGlobalStore } from '@/store/global'; +import { settingsSelectors } from '@/store/global/selectors'; import { GlobalStyle } from '@/styles'; import { setCookie } from '@/utils/cookie'; @@ -24,11 +25,11 @@ const AppTheme = memo( // console.debug('server:appearance', defaultAppearance); // console.debug('server:primaryColor', defaultPrimaryColor); // console.debug('server:neutralColor', defaultNeutralColor); - const themeMode = useGlobalStore((s) => s.settings.themeMode); + const themeMode = useGlobalStore((s) => settingsSelectors.currentSettings(s).themeMode); const [primaryColor, neutralColor] = useGlobalStore((s) => [ - s.settings.primaryColor, - s.settings.neutralColor, + settingsSelectors.currentSettings(s).primaryColor, + settingsSelectors.currentSettings(s).neutralColor, ]); useEffect(() => { diff --git a/src/layout/GlobalLayout/Locale.tsx b/src/layout/GlobalLayout/Locale.tsx index 106fa667f9cdc..c1e2a38452c4d 100644 --- a/src/layout/GlobalLayout/Locale.tsx +++ b/src/layout/GlobalLayout/Locale.tsx @@ -5,6 +5,7 @@ import useSWR from 'swr'; import { createI18nNext } from '@/locales/create'; import { normalizeLocale } from '@/locales/resources'; import { useOnFinishHydrationGlobal } from '@/store/global'; +import { settingsSelectors } from '@/store/global/selectors'; import { isOnServerSide } from '@/utils/env'; import { switchLang } from '@/utils/switchLang'; @@ -48,7 +49,7 @@ const Locale = memo(({ children, defaultLang }) => { } useOnFinishHydrationGlobal((s) => { - if (s.settings.language === 'auto') { + if (settingsSelectors.currentSettings(s).language === 'auto') { switchLang('auto'); } }, []); diff --git a/src/layout/GlobalLayout/StoreHydration.tsx b/src/layout/GlobalLayout/StoreHydration.tsx index 45ac37e149558..9020e8fa2e16b 100644 --- a/src/layout/GlobalLayout/StoreHydration.tsx +++ b/src/layout/GlobalLayout/StoreHydration.tsx @@ -5,8 +5,13 @@ import { memo, useEffect } from 'react'; import { useEffectAfterGlobalHydrated, useGlobalStore } from '@/store/global'; const StoreHydration = memo(() => { - const useFetchGlobalConfig = useGlobalStore((s) => s.useFetchGlobalConfig); - useFetchGlobalConfig(); + const [useFetchServerConfig, useFetchUserConfig] = useGlobalStore((s) => [ + s.useFetchServerConfig, + s.useFetchUserConfig, + ]); + const { isLoading } = useFetchServerConfig(); + + useFetchUserConfig(!isLoading); useEffect(() => { // refs: https://github.com/pmndrs/zustand/blob/main/docs/integrations/persisting-store-data.md#hashydrated diff --git a/src/services/_header.ts b/src/services/_header.ts index 53ebfb5e3b6c6..baf4d1f8c3593 100644 --- a/src/services/_header.ts +++ b/src/services/_header.ts @@ -6,10 +6,11 @@ import { USE_AZURE_OPENAI, } from '@/const/fetch'; import { useGlobalStore } from '@/store/global'; +import { modelProviderSelectors, settingsSelectors } from '@/store/global/selectors'; // eslint-disable-next-line no-undef export const createHeaderWithOpenAI = (header?: HeadersInit): HeadersInit => { - const openai = useGlobalStore.getState().settings.languageModel.openAI; + const openai = modelProviderSelectors.openAIConfig(useGlobalStore.getState()); const apiKey = openai.OPENAI_API_KEY || ''; const endpoint = openai.endpoint || ''; @@ -17,7 +18,7 @@ export const createHeaderWithOpenAI = (header?: HeadersInit): HeadersInit => { // eslint-disable-next-line no-undef const result: HeadersInit = { ...header, - [LOBE_CHAT_ACCESS_CODE]: useGlobalStore.getState().settings.password || '', + [LOBE_CHAT_ACCESS_CODE]: settingsSelectors.password(useGlobalStore.getState()), [OPENAI_API_KEY_HEADER_KEY]: apiKey, [OPENAI_END_POINT]: endpoint, }; diff --git a/src/services/user.ts b/src/services/user.ts new file mode 100644 index 0000000000000..e703d6604fd2a --- /dev/null +++ b/src/services/user.ts @@ -0,0 +1,31 @@ +import { DeepPartial } from 'utility-types'; + +import { UserModel } from '@/database/models/user'; +import { GlobalSettings } from '@/types/settings'; + +export interface UserConfig { + avatar?: string; + // settings: JSONPatch + settings: DeepPartial; +} + +class UserService { + getUserConfig = async () => { + const user = await UserModel.getUser(); + return user as unknown as UserConfig; + }; + + updateUserSettings = async (patch: DeepPartial) => { + return UserModel.updateSettings(patch); + }; + + resetUserSettings = async () => { + return UserModel.resetSettings(); + }; + + updateAvatar(avatar: string) { + return UserModel.updateAvatar(avatar); + } +} + +export const userService = new UserService(); diff --git a/src/store/chat/slices/message/selectors.test.ts b/src/store/chat/slices/message/selectors.test.ts index 6b99595708496..b5458007e03e0 100644 --- a/src/store/chat/slices/message/selectors.test.ts +++ b/src/store/chat/slices/message/selectors.test.ts @@ -1,9 +1,11 @@ +import { act } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { DEFAULT_INBOX_AVATAR } from '@/const/meta'; import { INBOX_SESSION_ID } from '@/const/session'; import { ChatStore } from '@/store/chat'; import { initialState } from '@/store/chat/initialState'; +import { useGlobalStore } from '@/store/global'; import { useSessionStore } from '@/store/session'; import { ChatMessage } from '@/types/message'; import { MetaData } from '@/types/meta'; @@ -155,8 +157,11 @@ describe('chatSelectors', () => { }); it('should slice the messages according to config, assuming historyCount is mocked to 2', async () => { const state = merge(initialStore, { messages: mockMessages }); - - useSessionStore.getState().updateAgentConfig({ historyCount: 2, enableHistoryCount: true }); + act(() => { + useGlobalStore.setState({ + settings: { defaultAgent: { config: { historyCount: 2, enableHistoryCount: true } } }, + }); + }); const chats = chatSelectors.currentChatsWithHistoryConfig(state); diff --git a/src/store/chat/slices/message/selectors.ts b/src/store/chat/slices/message/selectors.ts index 04bd0b95c573c..9d2a90b6133cc 100644 --- a/src/store/chat/slices/message/selectors.ts +++ b/src/store/chat/slices/message/selectors.ts @@ -4,6 +4,7 @@ import { t } from 'i18next'; import { DEFAULT_INBOX_AVATAR, DEFAULT_USER_AVATAR } from '@/const/meta'; import { INBOX_SESSION_ID } from '@/const/session'; import { useGlobalStore } from '@/store/global'; +import { commonSelectors } from '@/store/global/selectors'; import { useSessionStore } from '@/store/session'; import { agentSelectors } from '@/store/session/selectors'; import { ChatMessage } from '@/types/message'; @@ -17,7 +18,7 @@ const getMeta = (message: ChatMessage) => { switch (message.role) { case 'user': { return { - avatar: useGlobalStore.getState().settings.avatar || DEFAULT_USER_AVATAR, + avatar: commonSelectors.userAvatar(useGlobalStore.getState()) || DEFAULT_USER_AVATAR, }; } diff --git a/src/store/global/hooks/index.ts b/src/store/global/hooks/index.ts index 8d8dc9d9ae443..0a5c78c6f31a4 100644 --- a/src/store/global/hooks/index.ts +++ b/src/store/global/hooks/index.ts @@ -1,4 +1,3 @@ export * from './useEffectAfterHydrated'; -export * from './useHydrated'; export * from './useOnFinishHydrationGlobal'; export * from './useSwitchSideBarOnInit'; diff --git a/src/store/global/hooks/useHydrated.ts b/src/store/global/hooks/useHydrated.ts deleted file mode 100644 index f0a82539e708d..0000000000000 --- a/src/store/global/hooks/useHydrated.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { useGlobalStore } from '../store'; - -export const useGlobalHydrated = () => { - // 根据 sessions 是否有值来判断是否已经初始化 - const hasInited = !!Object.values(useGlobalStore.getState().settings).length; - - const [isInit, setInit] = useState(hasInited); - - useEffect(() => { - useGlobalStore.persist.onFinishHydration(() => { - if (!isInit) setInit(true); - }); - }, []); - - return isInit; -}; diff --git a/src/store/global/initialState.ts b/src/store/global/initialState.ts index bca50bdd865a3..0494ffe9073eb 100644 --- a/src/store/global/initialState.ts +++ b/src/store/global/initialState.ts @@ -1,11 +1,13 @@ import { GlobalCommonState, initialCommonState } from './slices/common/initialState'; +import { GlobalPreferenceState, initialPreferenceState } from './slices/preference/initialState'; import { GlobalSettingsState, initialSettingsState } from './slices/settings/initialState'; export { SettingsTabs, SidebarTabKey } from './slices/common/initialState'; -export type GlobalState = GlobalCommonState & GlobalSettingsState; +export type GlobalState = GlobalCommonState & GlobalSettingsState & GlobalPreferenceState; export const initialState: GlobalState = { ...initialCommonState, ...initialSettingsState, + ...initialPreferenceState, }; diff --git a/src/store/global/selectors.ts b/src/store/global/selectors.ts index 99513e0bbc94a..2cba4425495c1 100644 --- a/src/store/global/selectors.ts +++ b/src/store/global/selectors.ts @@ -1,2 +1,3 @@ export * from './slices/common/selectors'; +export * from './slices/preference/selectors'; export * from './slices/settings/selectors'; diff --git a/src/store/global/slices/common/action.test.ts b/src/store/global/slices/common/action.test.ts index 70743eb3ce585..0ff480e8ef820 100644 --- a/src/store/global/slices/common/action.test.ts +++ b/src/store/global/slices/common/action.test.ts @@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { globalService } from '@/services/global'; import { useGlobalStore } from '@/store/global'; -import { type Guide, SidebarTabKey } from '@/store/global/slices/common/initialState'; + +import { SidebarTabKey } from './initialState'; // Mock globalService vi.mock('@/services/global', () => ({ @@ -49,82 +50,4 @@ describe('createCommonSlice', () => { expect(result.current.sidebarKey).toEqual(SidebarTabKey.Market); }); }); - - describe('toggleChatSideBar', () => { - it('should toggle chat sidebar', () => { - const { result } = renderHook(() => useGlobalStore()); - - act(() => { - useGlobalStore.getState().updatePreference({ showChatSideBar: false }); - result.current.toggleChatSideBar(); - }); - - expect(result.current.preference.showChatSideBar).toBe(true); - }); - - // Add more tests for other actions in createCommonSlice - }); - - describe('toggleExpandSessionGroup', () => { - it('should toggle expand session group', () => { - const { result } = renderHook(() => useGlobalStore()); - const groupId = 'group-id'; - - act(() => { - result.current.toggleExpandSessionGroup(groupId, true); - }); - - expect(result.current.preference.expandSessionGroupKeys).toContain(groupId); - }); - }); - - describe('toggleMobileTopic', () => { - it('should toggle mobile topic', () => { - const { result } = renderHook(() => useGlobalStore()); - - act(() => { - result.current.toggleMobileTopic(); - }); - - expect(result.current.preference.mobileShowTopic).toBe(true); - }); - }); - - describe('toggleSystemRole', () => { - it('should toggle system role', () => { - const { result } = renderHook(() => useGlobalStore()); - - act(() => { - result.current.toggleSystemRole(true); - }); - - expect(result.current.preference.showSystemRole).toBe(true); - }); - }); - - describe('updateGuideState', () => { - it('should update guide state', () => { - const { result } = renderHook(() => useGlobalStore()); - const guide: Guide = { topic: true }; - - act(() => { - result.current.updateGuideState(guide); - }); - - expect(result.current.preference.guide).toEqual(guide); - }); - }); - - describe('updatePreference', () => { - it('should update preference', () => { - const { result } = renderHook(() => useGlobalStore()); - const preference = { inputHeight: 200 }; - - act(() => { - result.current.updatePreference(preference); - }); - - expect(result.current.preference.inputHeight).toEqual(200); - }); - }); }); diff --git a/src/store/global/slices/common/action.ts b/src/store/global/slices/common/action.ts index 8abfc5c5f67e1..e5d7d58e983e3 100644 --- a/src/store/global/slices/common/action.ts +++ b/src/store/global/slices/common/action.ts @@ -1,97 +1,54 @@ -import { produce } from 'immer'; import { gt } from 'semver'; -import useSWR, { SWRResponse } from 'swr'; +import useSWR, { SWRResponse, mutate } from 'swr'; import type { StateCreator } from 'zustand/vanilla'; import { INBOX_SESSION_ID } from '@/const/session'; import { SESSION_CHAT_URL } from '@/const/url'; import { CURRENT_VERSION } from '@/const/version'; import { globalService } from '@/services/global'; +import { UserConfig, userService } from '@/services/user'; import type { GlobalStore } from '@/store/global'; import type { GlobalServerConfig } from '@/types/settings'; import { merge } from '@/utils/merge'; import { setNamespace } from '@/utils/storeDebug'; -import type { GlobalCommonState, GlobalPreference, Guide, SidebarTabKey } from './initialState'; +import type { SidebarTabKey } from './initialState'; -const n = setNamespace('settings'); +const n = setNamespace('common'); /** * 设置操作 */ export interface CommonAction { + refreshUserConfig: () => Promise; switchBackToChat: (sessionId?: string) => void; - /** - * 切换侧边栏选项 - * @param key - 选中的侧边栏选项 - */ switchSideBar: (key: SidebarTabKey) => void; - toggleChatSideBar: (visible?: boolean) => void; - toggleExpandSessionGroup: (id: string, expand: boolean) => void; - toggleMobileTopic: (visible?: boolean) => void; - toggleSystemRole: (visible?: boolean) => void; - updateGuideState: (guide: Partial) => void; - updatePreference: (preference: Partial, action?: string) => void; + updateAvatar: (avatar: string) => Promise; useCheckLatestVersion: () => SWRResponse; - useFetchGlobalConfig: () => SWRResponse; + useFetchServerConfig: () => SWRResponse; + useFetchUserConfig: (initServer: boolean) => SWRResponse; } +const USER_CONFIG_FETCH_KEY = 'fetchUserConfig'; + export const createCommonSlice: StateCreator< GlobalStore, [['zustand/devtools', never]], [], CommonAction > = (set, get) => ({ + refreshUserConfig: async () => { + await mutate([USER_CONFIG_FETCH_KEY, true]); + }, switchBackToChat: (sessionId) => { get().router?.push(SESSION_CHAT_URL(sessionId || INBOX_SESSION_ID, get().isMobile)); }, switchSideBar: (key) => { set({ sidebarKey: key }, false, n('switchSideBar', key)); }, - toggleChatSideBar: (newValue) => { - const showChatSideBar = - typeof newValue === 'boolean' ? newValue : !get().preference.showChatSideBar; - - get().updatePreference({ showChatSideBar }, n('toggleAgentPanel', newValue) as string); - }, - toggleExpandSessionGroup: (id, expand) => { - const { preference } = get(); - const nextExpandSessionGroup = produce(preference.expandSessionGroupKeys, (draft) => { - if (expand) { - if (draft.includes(id)) return; - draft.push(id); - } else { - const index = draft.indexOf(id); - if (index !== -1) draft.splice(index, 1); - } - }); - get().updatePreference({ expandSessionGroupKeys: nextExpandSessionGroup }); - }, - toggleMobileTopic: (newValue) => { - const mobileShowTopic = - typeof newValue === 'boolean' ? newValue : !get().preference.mobileShowTopic; - - get().updatePreference({ mobileShowTopic }, n('toggleMobileTopic', newValue) as string); - }, - toggleSystemRole: (newValue) => { - const showSystemRole = - typeof newValue === 'boolean' ? newValue : !get().preference.mobileShowTopic; - - get().updatePreference({ showSystemRole }, n('toggleMobileTopic', newValue) as string); - }, - updateGuideState: (guide) => { - const { updatePreference } = get(); - const nextGuide = merge(get().preference.guide, guide); - updatePreference({ guide: nextGuide }); - }, - updatePreference: (preference, action) => { - set( - produce((draft: GlobalCommonState) => { - draft.preference = merge(draft.preference, preference); - }), - false, - action, - ); + updateAvatar: async (avatar) => { + await userService.updateAvatar(avatar); + await get().refreshUserConfig(); }, useCheckLatestVersion: () => useSWR('checkLatestVersion', globalService.getLatestVersion, { @@ -102,11 +59,30 @@ export const createCommonSlice: StateCreator< set({ hasNewVersion: true, latestVersion: data }, false, n('checkLatestVersion')); }, }), - useFetchGlobalConfig: () => + useFetchServerConfig: () => useSWR('fetchGlobalConfig', globalService.getGlobalConfig, { onSuccess: (data) => { - if (data) set({ serverConfig: data }); + if (data) { + const defaultSettings = merge(get().defaultSettings, { defaultAgent: data.defaultAgent }); + set({ defaultSettings, serverConfig: data }, false, n('initGlobalConfig')); + } }, revalidateOnFocus: false, }), + useFetchUserConfig: (initServer) => + useSWR( + [USER_CONFIG_FETCH_KEY, initServer], + async () => { + if (!initServer) return; + return userService.getUserConfig(); + }, + { + onSuccess: (data) => { + if (!data) return; + + set({ avatar: data.avatar, settings: data.settings }, false, n('fetchUserConfig', data)); + }, + revalidateOnFocus: false, + }, + ), }); diff --git a/src/store/global/slices/common/initialState.ts b/src/store/global/slices/common/initialState.ts index 41d79a8d0d5e0..8e5e43c97d29d 100644 --- a/src/store/global/slices/common/initialState.ts +++ b/src/store/global/slices/common/initialState.ts @@ -1,6 +1,5 @@ import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime'; -import { SessionDefaultGroup, SessionGroupId } from '@/types/session'; export enum SidebarTabKey { Chat = 'chat', @@ -20,32 +19,10 @@ export interface Guide { topic?: boolean; } -export interface GlobalPreference { - // which sessionGroup should expand - expandSessionGroupKeys: SessionGroupId[]; - guide?: Guide; - inputHeight: number; - mobileShowTopic?: boolean; - sessionsWidth: number; - - showChatSideBar?: boolean; - showSessionPanel?: boolean; - showSystemRole?: boolean; - /** - * whether to use cmd + enter to send message - */ - useCmdEnterToSend?: boolean; -} - export interface GlobalCommonState { hasNewVersion?: boolean; isMobile?: boolean; latestVersion?: string; - /** - * 用户偏好的 UI 状态 - * @localStorage - */ - preference: GlobalPreference; router?: AppRouterInstance; settingsTab: SettingsTabs; sidebarKey: SidebarTabKey; @@ -53,17 +30,6 @@ export interface GlobalCommonState { export const initialCommonState: GlobalCommonState = { isMobile: false, - preference: { - expandSessionGroupKeys: [SessionDefaultGroup.Pinned, SessionDefaultGroup.Default], - guide: {}, - inputHeight: 200, - mobileShowTopic: false, - sessionsWidth: 320, - showChatSideBar: true, - showSessionPanel: true, - showSystemRole: false, - useCmdEnterToSend: false, - }, settingsTab: SettingsTabs.Common, sidebarKey: SidebarTabKey.Chat, }; diff --git a/src/store/global/slices/common/selectors.ts b/src/store/global/slices/common/selectors.ts index ab201e9b5ce6f..cfb84feae7100 100644 --- a/src/store/global/slices/common/selectors.ts +++ b/src/store/global/slices/common/selectors.ts @@ -1,10 +1,5 @@ import { GlobalStore } from '@/store/global'; -const sessionGroupKeys = (s: GlobalStore): string[] => s.preference.expandSessionGroupKeys || []; - -const useCmdEnterToSend = (s: GlobalStore): boolean => s.preference.useCmdEnterToSend || false; - -export const preferenceSelectors = { - sessionGroupKeys, - useCmdEnterToSend, +export const commonSelectors = { + userAvatar: (s: GlobalStore) => s.avatar || '', }; diff --git a/src/store/global/slices/preference/action.test.ts b/src/store/global/slices/preference/action.test.ts new file mode 100644 index 0000000000000..ab46eccd9d375 --- /dev/null +++ b/src/store/global/slices/preference/action.test.ts @@ -0,0 +1,92 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useGlobalStore } from '@/store/global'; + +import { type Guide } from './initialState'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('createPreferenceSlice', () => { + describe('toggleChatSideBar', () => { + it('should toggle chat sidebar', () => { + const { result } = renderHook(() => useGlobalStore()); + + act(() => { + useGlobalStore.getState().updatePreference({ showChatSideBar: false }); + result.current.toggleChatSideBar(); + }); + + expect(result.current.preference.showChatSideBar).toBe(true); + }); + }); + + describe('toggleExpandSessionGroup', () => { + it('should toggle expand session group', () => { + const { result } = renderHook(() => useGlobalStore()); + const groupId = 'group-id'; + + act(() => { + result.current.toggleExpandSessionGroup(groupId, true); + }); + + expect(result.current.preference.expandSessionGroupKeys).toContain(groupId); + }); + }); + + describe('toggleMobileTopic', () => { + it('should toggle mobile topic', () => { + const { result } = renderHook(() => useGlobalStore()); + + act(() => { + result.current.toggleMobileTopic(); + }); + + expect(result.current.preference.mobileShowTopic).toBe(true); + }); + }); + + describe('toggleSystemRole', () => { + it('should toggle system role', () => { + const { result } = renderHook(() => useGlobalStore()); + + act(() => { + result.current.toggleSystemRole(true); + }); + + expect(result.current.preference.showSystemRole).toBe(true); + }); + }); + + describe('updateGuideState', () => { + it('should update guide state', () => { + const { result } = renderHook(() => useGlobalStore()); + const guide: Guide = { topic: true }; + + act(() => { + result.current.updateGuideState(guide); + }); + + expect(result.current.preference.guide).toEqual(guide); + }); + }); + + describe('updatePreference', () => { + it('should update preference', () => { + const { result } = renderHook(() => useGlobalStore()); + const preference = { inputHeight: 200 }; + + act(() => { + result.current.updatePreference(preference); + }); + + expect(result.current.preference.inputHeight).toEqual(200); + }); + }); +}); diff --git a/src/store/global/slices/preference/action.ts b/src/store/global/slices/preference/action.ts new file mode 100644 index 0000000000000..f31a8bdf5606e --- /dev/null +++ b/src/store/global/slices/preference/action.ts @@ -0,0 +1,75 @@ +import { produce } from 'immer'; +import type { StateCreator } from 'zustand/vanilla'; + +import type { GlobalStore } from '@/store/global'; +import { merge } from '@/utils/merge'; +import { setNamespace } from '@/utils/storeDebug'; + +import type { GlobalPreference, GlobalPreferenceState, Guide } from './initialState'; + +const n = setNamespace('preference'); + +/** + * 设置操作 + */ +export interface PreferenceAction { + toggleChatSideBar: (visible?: boolean) => void; + toggleExpandSessionGroup: (id: string, expand: boolean) => void; + toggleMobileTopic: (visible?: boolean) => void; + toggleSystemRole: (visible?: boolean) => void; + updateGuideState: (guide: Partial) => void; + updatePreference: (preference: Partial, action?: string) => void; +} + +export const createPreferenceSlice: StateCreator< + GlobalStore, + [['zustand/devtools', never]], + [], + PreferenceAction +> = (set, get) => ({ + toggleChatSideBar: (newValue) => { + const showChatSideBar = + typeof newValue === 'boolean' ? newValue : !get().preference.showChatSideBar; + + get().updatePreference({ showChatSideBar }, n('toggleAgentPanel', newValue) as string); + }, + toggleExpandSessionGroup: (id, expand) => { + const { preference } = get(); + const nextExpandSessionGroup = produce(preference.expandSessionGroupKeys, (draft) => { + if (expand) { + if (draft.includes(id)) return; + draft.push(id); + } else { + const index = draft.indexOf(id); + if (index !== -1) draft.splice(index, 1); + } + }); + get().updatePreference({ expandSessionGroupKeys: nextExpandSessionGroup }); + }, + toggleMobileTopic: (newValue) => { + const mobileShowTopic = + typeof newValue === 'boolean' ? newValue : !get().preference.mobileShowTopic; + + get().updatePreference({ mobileShowTopic }, n('toggleMobileTopic', newValue) as string); + }, + toggleSystemRole: (newValue) => { + const showSystemRole = + typeof newValue === 'boolean' ? newValue : !get().preference.mobileShowTopic; + + get().updatePreference({ showSystemRole }, n('toggleMobileTopic', newValue) as string); + }, + updateGuideState: (guide) => { + const { updatePreference } = get(); + const nextGuide = merge(get().preference.guide, guide); + updatePreference({ guide: nextGuide }); + }, + updatePreference: (preference, action) => { + set( + produce((draft: GlobalPreferenceState) => { + draft.preference = merge(draft.preference, preference); + }), + false, + action, + ); + }, +}); diff --git a/src/store/global/slices/preference/initialState.ts b/src/store/global/slices/preference/initialState.ts new file mode 100644 index 0000000000000..65f241cfd2505 --- /dev/null +++ b/src/store/global/slices/preference/initialState.ts @@ -0,0 +1,45 @@ +import { SessionDefaultGroup, SessionGroupId } from '@/types/session'; + +export interface Guide { + // Topic 引导 + topic?: boolean; +} + +export interface GlobalPreference { + // which sessionGroup should expand + expandSessionGroupKeys: SessionGroupId[]; + guide?: Guide; + inputHeight: number; + mobileShowTopic?: boolean; + sessionsWidth: number; + + showChatSideBar?: boolean; + showSessionPanel?: boolean; + showSystemRole?: boolean; + /** + * whether to use cmd + enter to send message + */ + useCmdEnterToSend?: boolean; +} + +export interface GlobalPreferenceState { + /** + * 用户偏好的 UI 状态 + * @localStorage + */ + preference: GlobalPreference; +} + +export const initialPreferenceState: GlobalPreferenceState = { + preference: { + expandSessionGroupKeys: [SessionDefaultGroup.Pinned, SessionDefaultGroup.Default], + guide: {}, + inputHeight: 200, + mobileShowTopic: false, + sessionsWidth: 320, + showChatSideBar: true, + showSessionPanel: true, + showSystemRole: false, + useCmdEnterToSend: false, + }, +}; diff --git a/src/store/global/slices/preference/selectors.ts b/src/store/global/slices/preference/selectors.ts new file mode 100644 index 0000000000000..ab201e9b5ce6f --- /dev/null +++ b/src/store/global/slices/preference/selectors.ts @@ -0,0 +1,10 @@ +import { GlobalStore } from '@/store/global'; + +const sessionGroupKeys = (s: GlobalStore): string[] => s.preference.expandSessionGroupKeys || []; + +const useCmdEnterToSend = (s: GlobalStore): boolean => s.preference.useCmdEnterToSend || false; + +export const preferenceSelectors = { + sessionGroupKeys, + useCmdEnterToSend, +}; diff --git a/src/store/global/slices/settings/action.test.ts b/src/store/global/slices/settings/action.test.ts index 9c9a8c1718f8c..a4980168684e0 100644 --- a/src/store/global/slices/settings/action.test.ts +++ b/src/store/global/slices/settings/action.test.ts @@ -1,75 +1,102 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { DeepPartial } from 'utility-types'; import { describe, expect, it, vi } from 'vitest'; +import { withSWR } from '~test-utils'; import { DEFAULT_AGENT, DEFAULT_SETTINGS } from '@/const/settings'; +import { userService } from '@/services/user'; import { useGlobalStore } from '@/store/global'; import { SettingsTabs } from '@/store/global/initialState'; import { LobeAgentSettings } from '@/types/session'; import { GlobalSettings, OpenAIConfig } from '@/types/settings'; -beforeEach(() => { - vi.clearAllMocks(); -}); - -vi.mock('@/utils/uuid', () => ({ - nanoid: vi.fn(() => 'unique-id'), +// Mock userService +vi.mock('@/services/user', () => ({ + userService: { + updateUserSettings: vi.fn(), + resetUserSettings: vi.fn(), + }, })); describe('SettingsAction', () => { describe('importAppSettings', () => { - it('should import app settings', () => { + it('should import app settings', async () => { const { result } = renderHook(() => useGlobalStore()); const newSettings: GlobalSettings = { ...DEFAULT_SETTINGS, themeMode: 'dark', }; - act(() => { - result.current.importAppSettings(newSettings); + // Mock the internal setSettings function call + const setSettingsSpy = vi.spyOn(result.current, 'setSettings'); + + // Perform the action + await act(async () => { + await result.current.importAppSettings(newSettings); }); - expect(result.current.settings).toEqual(newSettings); + // Assert that setSettings was called with the correct settings + expect(setSettingsSpy).toHaveBeenCalledWith({ + ...DEFAULT_SETTINGS, + password: undefined, + themeMode: 'dark', + }); + + // Assert that the state has been updated + expect(userService.updateUserSettings).toHaveBeenCalledWith({ themeMode: 'dark' }); + + // Restore the spy + setSettingsSpy.mockRestore(); }); }); describe('resetSettings', () => { - it('should reset settings to default', () => { + it('should reset settings to default', async () => { const { result } = renderHook(() => useGlobalStore()); - act(() => { - result.current.resetSettings(); + // Perform the action + await act(async () => { + await result.current.resetSettings(); }); - expect(result.current.settings).toEqual(DEFAULT_SETTINGS); + // Assert that resetUserSettings was called + expect(userService.resetUserSettings).toHaveBeenCalled(); + + // Assert that the state has been updated to default settings + expect(result.current.settings).toEqual({}); }); }); describe('setOpenAIConfig', () => { - it('should set OpenAI configuration', () => { + it('should set OpenAI configuration', async () => { const { result } = renderHook(() => useGlobalStore()); - const openAIConfig: Partial = { OPENAI_API_KEY: 'test' }; + const openAIConfig: Partial = { OPENAI_API_KEY: 'test-key' }; - act(() => { - result.current.setOpenAIConfig(openAIConfig); + // Perform the action + await act(async () => { + await result.current.setOpenAIConfig(openAIConfig); }); - expect(result.current.settings.languageModel.openAI.OPENAI_API_KEY).toEqual( - openAIConfig.OPENAI_API_KEY, - ); + // Assert that updateUserSettings was called with the correct OpenAI configuration + expect(userService.updateUserSettings).toHaveBeenCalledWith({ + languageModel: { + openAI: openAIConfig, + }, + }); }); }); - describe('setSettings', () => { - it('should set partial settings', () => { + it('should set partial settings', async () => { const { result } = renderHook(() => useGlobalStore()); const partialSettings: Partial = { themeMode: 'dark' }; - act(() => { - result.current.setSettings(partialSettings); + // Perform the action + await act(async () => { + await result.current.setSettings(partialSettings); }); - expect(result.current.settings.themeMode).toEqual('dark'); + // Assert that updateUserSettings was called with the correct settings + expect(userService.updateUserSettings).toHaveBeenCalledWith(partialSettings); }); }); @@ -86,32 +113,34 @@ describe('SettingsAction', () => { }); describe('switchThemeMode', () => { - it('should switch theme mode', () => { + it('should switch theme mode', async () => { const { result } = renderHook(() => useGlobalStore()); + const themeMode = 'light'; - act(() => { - result.current.switchThemeMode('light'); + // Perform the action + await act(async () => { + await result.current.switchThemeMode(themeMode); }); - expect(result.current.settings.themeMode).toEqual('light'); + // Assert that updateUserSettings was called with the correct theme mode + expect(userService.updateUserSettings).toHaveBeenCalledWith({ themeMode }); }); }); describe('updateDefaultAgent', () => { - it('should update default agent settings', () => { + it('should update default agent settings', async () => { const { result } = renderHook(() => useGlobalStore()); - const updatedAgent: DeepPartial = { + const updatedAgent: Partial = { meta: { title: 'docs' }, }; - act(() => { - result.current.updateDefaultAgent(updatedAgent); + // Perform the action + await act(async () => { + await result.current.updateDefaultAgent(updatedAgent); }); - expect(result.current.settings.defaultAgent).toEqual({ - ...DEFAULT_AGENT, - ...updatedAgent, - }); + // Assert that updateUserSettings was called with the merged agent settings + expect(userService.updateUserSettings).toHaveBeenCalledWith({ defaultAgent: updatedAgent }); }); }); }); diff --git a/src/store/global/slices/settings/action.ts b/src/store/global/slices/settings/action.ts index ccd5b1dfccb6a..6b0e3a21c53b6 100644 --- a/src/store/global/slices/settings/action.ts +++ b/src/store/global/slices/settings/action.ts @@ -1,42 +1,27 @@ import { ThemeMode } from 'antd-style'; import isEqual from 'fast-deep-equal'; -import { produce } from 'immer'; import { DeepPartial } from 'utility-types'; import type { StateCreator } from 'zustand/vanilla'; -import { DEFAULT_AGENT, DEFAULT_SETTINGS } from '@/const/settings'; +import { userService } from '@/services/user'; import type { GlobalStore } from '@/store/global'; import { SettingsTabs } from '@/store/global/initialState'; import { LobeAgentSettings } from '@/types/session'; import type { GlobalSettings, OpenAIConfig } from '@/types/settings'; +import { difference } from '@/utils/difference'; import { merge } from '@/utils/merge'; -import { setNamespace } from '@/utils/storeDebug'; - -const n = setNamespace('settings'); /** * 设置操作 */ export interface SettingsAction { - importAppSettings: (settings: GlobalSettings) => void; - /** - * 重置设置 - */ - resetSettings: () => void; - setOpenAIConfig: (config: Partial) => void; - /** - * 设置部分配置设置 - * @param settings - 部分配置设置 - */ - setSettings: (settings: DeepPartial) => void; - + importAppSettings: (settings: GlobalSettings) => Promise; + resetSettings: () => Promise; + setOpenAIConfig: (config: Partial) => Promise; + setSettings: (settings: DeepPartial) => Promise; switchSettingTabs: (tab: SettingsTabs) => void; - /** - * 设置主题模式 - * @param themeMode - 主题模式 - */ - switchThemeMode: (themeMode: ThemeMode) => void; - updateDefaultAgent: (agent: DeepPartial) => void; + switchThemeMode: (themeMode: ThemeMode) => Promise; + updateDefaultAgent: (agent: DeepPartial) => Promise; } export const createSettingsSlice: StateCreator< @@ -45,49 +30,40 @@ export const createSettingsSlice: StateCreator< [], SettingsAction > = (set, get) => ({ - importAppSettings: (importAppSettings) => { + importAppSettings: async (importAppSettings) => { const { setSettings } = get(); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { password: _, ...settings } = importAppSettings; - setSettings({ - ...settings, - // 如果用户存在用户头像,那么不做导入 - avatar: !get().settings.avatar ? settings.avatar : get().settings.avatar, - }); - }, - resetDefaultAgent: () => { - const settings = produce(get().settings, (draft: GlobalSettings) => { - draft.defaultAgent = DEFAULT_AGENT; - }); - set({ settings }, false, n('resetDefaultAgent')); + await setSettings(settings); }, - resetSettings: () => { - set({ settings: DEFAULT_SETTINGS }, false, n('resetSettings')); + resetSettings: async () => { + await userService.resetUserSettings(); + await get().refreshUserConfig(); }, - setOpenAIConfig: (config) => { - get().setSettings({ languageModel: { openAI: config } }); + setOpenAIConfig: async (config) => { + await get().setSettings({ languageModel: { openAI: config } }); }, - setSettings: (settings) => { - const prevSetting = get().settings; + setSettings: async (settings) => { + const { settings: prevSetting, defaultSettings } = get(); + const nextSettings = merge(prevSetting, settings); if (isEqual(prevSetting, nextSettings)) return; - set({ settings: merge(prevSetting, settings) }, false, n('setSettings', settings)); + const diffs = difference(nextSettings, defaultSettings); + + await userService.updateUserSettings(diffs); + await get().refreshUserConfig(); }, switchSettingTabs: (tab) => { set({ settingsTab: tab }); }, - switchThemeMode: (themeMode) => { - get().setSettings({ themeMode }); + switchThemeMode: async (themeMode) => { + await get().setSettings({ themeMode }); }, - updateDefaultAgent: (agent) => { - const settings = produce(get().settings, (draft: GlobalSettings) => { - draft.defaultAgent = merge(draft.defaultAgent, agent); - }); - - set({ settings }, false, n('updateDefaultAgent', agent)); + updateDefaultAgent: async (defaultAgent) => { + await get().setSettings({ defaultAgent }); }, }); diff --git a/src/store/global/slices/settings/initialState.ts b/src/store/global/slices/settings/initialState.ts index e5fd079b7f934..2803dc3970104 100644 --- a/src/store/global/slices/settings/initialState.ts +++ b/src/store/global/slices/settings/initialState.ts @@ -1,15 +1,17 @@ +import { DeepPartial } from 'utility-types'; + import { DEFAULT_SETTINGS } from '@/const/settings'; -import type { GlobalServerConfig, GlobalSettings } from '@/types/settings'; +import { GlobalServerConfig, GlobalSettings } from '@/types/settings'; export interface GlobalSettingsState { + avatar?: string; + defaultSettings: GlobalSettings; serverConfig: GlobalServerConfig; - /** - * @localStorage - */ - settings: GlobalSettings; + settings: DeepPartial; } export const initialSettingsState: GlobalSettingsState = { + defaultSettings: DEFAULT_SETTINGS, serverConfig: {}, - settings: DEFAULT_SETTINGS, + settings: {}, }; diff --git a/src/store/global/slices/settings/selectors.ts b/src/store/global/slices/settings/selectors.ts deleted file mode 100644 index 81112f8c1baed..0000000000000 --- a/src/store/global/slices/settings/selectors.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { DEFAULT_OPENAI_MODEL_LIST } from '@/const/llm'; -import { DEFAULT_LANG } from '@/const/locale'; -import { DEFAULT_AGENT_META } from '@/const/meta'; -import { - DEFAULT_AGENT, - DEFAULT_AGENT_CONFIG, - DEFAULT_SETTINGS, - DEFAULT_TTS_CONFIG, -} from '@/const/settings'; -import { Locales } from '@/locales/resources'; -import { CustomModels, GlobalSettings } from '@/types/settings'; -import { isOnServerSide } from '@/utils/env'; -import { merge } from '@/utils/merge'; - -import { GlobalStore } from '../../store'; - -const currentSettings = (s: GlobalStore) => merge(DEFAULT_SETTINGS, s.settings); - -const currentTTS = (s: GlobalStore) => merge(DEFAULT_TTS_CONFIG, s.settings.tts); - -const defaultAgent = (s: GlobalStore) => merge(DEFAULT_AGENT, s.settings.defaultAgent); - -const defaultAgentConfig = (s: GlobalStore) => merge(DEFAULT_AGENT_CONFIG, defaultAgent(s).config); - -const defaultAgentMeta = (s: GlobalStore) => merge(DEFAULT_AGENT_META, defaultAgent(s).meta); - -const openAIAPIKeySelectors = (s: GlobalStore) => s.settings.languageModel.openAI.OPENAI_API_KEY; - -const openAIProxyUrlSelectors = (s: GlobalStore) => s.settings.languageModel.openAI.endpoint; - -const modelListSelectors = (s: GlobalStore) => { - let models: CustomModels = []; - - const removedModels: string[] = []; - const modelNames = [ - ...DEFAULT_OPENAI_MODEL_LIST, - ...(s.serverConfig.customModelName || '').split(/[,,]/).filter(Boolean), - ...(s.settings.languageModel.openAI.customModelName || '').split(/[,,]/).filter(Boolean), - ]; - - for (const item of modelNames) { - const disable = item.startsWith('-'); - const nameConfig = item.startsWith('+') || item.startsWith('-') ? item.slice(1) : item; - const [name, displayName] = nameConfig.split('='); - - if (disable) { - // Disable all models. - if (name === 'all') { - models = []; - } - removedModels.push(name); - continue; - } - - // Remove duplicate model entries. - const existingIndex = models.findIndex(({ name: n }) => n === name); - if (existingIndex !== -1) { - models.splice(existingIndex, 1); - } - - models.push({ - displayName: displayName || name, - name, - }); - } - - return models.filter((m) => !removedModels.includes(m.name)); -}; - -export const exportSettings = (s: GlobalStore) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { password: _, ...settings } = s.settings; - - return settings as GlobalSettings; -}; - -const currentLanguage = (s: GlobalStore) => { - const locale = s.settings.language; - - if (locale === 'auto') { - if (isOnServerSide) return DEFAULT_LANG; - - return navigator.language as Locales; - } - - return locale; -}; - -const dalleConfig = (s: GlobalStore) => s.settings.tool?.dalle || {}; -const isDalleAutoGenerating = (s: GlobalStore) => s.settings.tool?.dalle?.autoGenerate; - -export const settingsSelectors = { - currentLanguage, - currentSettings, - currentTTS, - dalleConfig, - defaultAgent, - defaultAgentConfig, - defaultAgentMeta, - exportSettings, - isDalleAutoGenerating, - modelList: modelListSelectors, - openAIAPI: openAIAPIKeySelectors, - openAIProxyUrl: openAIProxyUrlSelectors, -}; diff --git a/src/store/global/slices/settings/selectors/__snapshots__/modelProvider.test.ts.snap b/src/store/global/slices/settings/selectors/__snapshots__/modelProvider.test.ts.snap new file mode 100644 index 0000000000000..629d8f577a54f --- /dev/null +++ b/src/store/global/slices/settings/selectors/__snapshots__/modelProvider.test.ts.snap @@ -0,0 +1,100 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`modelProviderSelectors > CUSTOM_MODELS > custom deletion, addition, and renaming of models 1`] = ` +[ + { + "displayName": "llama", + "name": "llama", + }, + { + "displayName": "claude-2", + "name": "claude-2", + }, + { + "displayName": "gpt-4-32k", + "name": "gpt-4-1106-preview", + }, +] +`; + +exports[`modelProviderSelectors > CUSTOM_MODELS > duplicate naming model 1`] = ` +[ + { + "displayName": "gpt-3.5-turbo", + "name": "gpt-3.5-turbo", + }, + { + "displayName": "gpt-3.5-turbo-1106", + "name": "gpt-3.5-turbo-1106", + }, + { + "displayName": "gpt-3.5-turbo-16k", + "name": "gpt-3.5-turbo-16k", + }, + { + "displayName": "gpt-4", + "name": "gpt-4", + }, + { + "displayName": "gpt-4-32k", + "name": "gpt-4-32k", + }, + { + "displayName": "gpt-4-vision-preview", + "name": "gpt-4-vision-preview", + }, + { + "displayName": "gpt-4-32k", + "name": "gpt-4-1106-preview", + }, +] +`; + +exports[`modelProviderSelectors > CUSTOM_MODELS > only add the model 1`] = ` +[ + { + "displayName": "gpt-3.5-turbo", + "name": "gpt-3.5-turbo", + }, + { + "displayName": "gpt-3.5-turbo-1106", + "name": "gpt-3.5-turbo-1106", + }, + { + "displayName": "gpt-3.5-turbo-16k", + "name": "gpt-3.5-turbo-16k", + }, + { + "displayName": "gpt-4", + "name": "gpt-4", + }, + { + "displayName": "gpt-4-32k", + "name": "gpt-4-32k", + }, + { + "displayName": "gpt-4-1106-preview", + "name": "gpt-4-1106-preview", + }, + { + "displayName": "gpt-4-vision-preview", + "name": "gpt-4-vision-preview", + }, + { + "displayName": "model1", + "name": "model1", + }, + { + "displayName": "model2", + "name": "model2", + }, + { + "displayName": "model3", + "name": "model3", + }, + { + "displayName": "model4", + "name": "model4", + }, +] +`; diff --git a/src/store/global/slices/settings/selectors/__snapshots__/selectors.test.ts.snap b/src/store/global/slices/settings/selectors/__snapshots__/selectors.test.ts.snap new file mode 100644 index 0000000000000..71719731d3ef0 --- /dev/null +++ b/src/store/global/slices/settings/selectors/__snapshots__/selectors.test.ts.snap @@ -0,0 +1,132 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`settingsSelectors > currentSettings > should merge DEFAULT_SETTINGS and s.settings correctly 1`] = ` +{ + "avatar": "avatar.jpg", + "defaultAgent": { + "config": { + "model": "gpt-3.5-turbo", + "params": {}, + "systemRole": "", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { + "openai": "alloy", + }, + }, + }, + "meta": { + "avatar": "Default Agent", + "description": "Default agent for testing", + }, + }, + "fontSize": 14, + "language": "en-US", + "languageModel": { + "openAI": { + "OPENAI_API_KEY": "openai-api-key", + "endpoint": "https://openai-endpoint.com", + "models": [ + "gpt-3.5-turbo", + ], + }, + }, + "neutralColor": "sand", + "password": "password123", + "primaryColor": "blue", + "themeMode": "light", + "tts": { + "openAI": { + "sttModel": "whisper-1", + "ttsModel": "tts-1", + }, + "sttAutoStop": true, + "sttServer": "openai", + }, +} +`; + +exports[`settingsSelectors > currentTTS > should merge DEFAULT_TTS_CONFIG and s.settings.tts correctly 1`] = ` +{ + "openAI": { + "sttModel": "whisper-2", + "ttsModel": "tts-1", + }, + "sttAutoStop": false, + "sttServer": "openai", +} +`; + +exports[`settingsSelectors > dalleConfig > should return the dalle configuration 1`] = ` +{ + "apiKey": "dalle-api-key", + "autoGenerate": true, +} +`; + +exports[`settingsSelectors > defaultAgent > should merge DEFAULT_AGENT and s.settings.defaultAgent correctly 1`] = ` +{ + "config": { + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1, + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.6, + "top_p": 1, + }, + "plugins": [], + "systemRole": "user", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { + "openai": "alloy", + }, + }, + }, + "meta": { + "avatar": "agent-avatar.jpg", + "description": "Test agent", + }, +} +`; + +exports[`settingsSelectors > defaultAgentConfig > should merge DEFAULT_AGENT_CONFIG and defaultAgent(s).config correctly 1`] = ` +{ + "autoCreateTopicThreshold": 2, + "displayMode": "chat", + "enableAutoCreateTopic": true, + "historyCount": 1, + "model": "gpt-3.5-turbo", + "params": { + "frequency_penalty": 0, + "presence_penalty": 0, + "temperature": 0.7, + "top_p": 1, + }, + "plugins": [], + "systemRole": "user", + "tts": { + "showAllLocaleVoice": false, + "sttLocale": "auto", + "ttsService": "openai", + "voice": { + "openai": "alloy", + }, + }, +} +`; + +exports[`settingsSelectors > defaultAgentMeta > should merge DEFAULT_AGENT_META and defaultAgent(s).meta correctly 1`] = ` +{ + "avatar": "agent-avatar.jpg", + "description": "Test agent", +} +`; diff --git a/src/store/global/slices/settings/selectors/index.ts b/src/store/global/slices/settings/selectors/index.ts new file mode 100644 index 0000000000000..9268ed9068cb4 --- /dev/null +++ b/src/store/global/slices/settings/selectors/index.ts @@ -0,0 +1,2 @@ +export { modelProviderSelectors } from './modelProvider'; +export { settingsSelectors } from './settings'; diff --git a/src/store/global/slices/settings/selectors/modelProvider.test.ts b/src/store/global/slices/settings/selectors/modelProvider.test.ts new file mode 100644 index 0000000000000..c045449e1e598 --- /dev/null +++ b/src/store/global/slices/settings/selectors/modelProvider.test.ts @@ -0,0 +1,100 @@ +import { GlobalStore } from '../../../store'; +import { modelProviderSelectors } from './modelProvider'; + +describe('modelProviderSelectors', () => { + describe('CUSTOM_MODELS', () => { + it('custom deletion, addition, and renaming of models', () => { + const s = { + serverConfig: { + customModelName: + '-all,+llama,+claude-2,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo,gpt-4-1106-preview=gpt-4-32k', + }, + settings: { + languageModel: { + openAI: {}, + }, + }, + } as unknown as GlobalStore; + + const result = modelProviderSelectors.modelList(s); + + expect(result).toMatchSnapshot(); + }); + + it('duplicate naming model', () => { + const s = { + serverConfig: {}, + settings: { + languageModel: { + openAI: { + customModelName: 'gpt-4-1106-preview=gpt-4-turbo,gpt-4-1106-preview=gpt-4-32k', + }, + }, + }, + } as unknown as GlobalStore; + + const result = modelProviderSelectors.modelList(s); + + expect(result).toMatchSnapshot(); + }); + + it('should delete model', () => { + const s = { + serverConfig: { + customModelName: '-gpt-4', + }, + settings: { + languageModel: { + openAI: {}, + }, + }, + } as unknown as GlobalStore; + + const result = modelProviderSelectors.modelList(s); + + expect(result).toEqual([ + { + displayName: 'gpt-3.5-turbo', + name: 'gpt-3.5-turbo', + }, + { + displayName: 'gpt-3.5-turbo-1106', + name: 'gpt-3.5-turbo-1106', + }, + { + displayName: 'gpt-3.5-turbo-16k', + name: 'gpt-3.5-turbo-16k', + }, + { + displayName: 'gpt-4-32k', + name: 'gpt-4-32k', + }, + { + displayName: 'gpt-4-1106-preview', + name: 'gpt-4-1106-preview', + }, + { + displayName: 'gpt-4-vision-preview', + name: 'gpt-4-vision-preview', + }, + ]); + }); + + it('only add the model', () => { + const s = { + serverConfig: {}, + settings: { + languageModel: { + openAI: { + customModelName: 'model1,model2,model3,model4', + }, + }, + }, + } as unknown as GlobalStore; + + const result = modelProviderSelectors.modelList(s); + + expect(result).toMatchSnapshot(); + }); + }); +}); diff --git a/src/store/global/slices/settings/selectors/modelProvider.ts b/src/store/global/slices/settings/selectors/modelProvider.ts new file mode 100644 index 0000000000000..6db844761b966 --- /dev/null +++ b/src/store/global/slices/settings/selectors/modelProvider.ts @@ -0,0 +1,64 @@ +import { DEFAULT_OPENAI_MODEL_LIST } from '@/const/llm'; +import { CustomModels } from '@/types/settings'; + +import { GlobalStore } from '../../../store'; +import { currentSettings } from './settings'; + +const openAIConfig = (s: GlobalStore) => currentSettings(s).languageModel.openAI; + +const openAIAPIKeySelectors = (s: GlobalStore) => + currentSettings(s).languageModel.openAI.OPENAI_API_KEY; + +const enableAzure = (s: GlobalStore) => currentSettings(s).languageModel.openAI.useAzure; + +const openAIProxyUrlSelectors = (s: GlobalStore) => + currentSettings(s).languageModel.openAI.endpoint; + +const modelListSelectors = (s: GlobalStore) => { + let models: CustomModels = []; + + const removedModels: string[] = []; + const modelNames = [ + ...DEFAULT_OPENAI_MODEL_LIST, + ...(s.serverConfig.customModelName || '').split(/[,,]/).filter(Boolean), + ...(currentSettings(s).languageModel.openAI.customModelName || '') + .split(/[,,]/) + .filter(Boolean), + ]; + + for (const item of modelNames) { + const disable = item.startsWith('-'); + const nameConfig = item.startsWith('+') || item.startsWith('-') ? item.slice(1) : item; + const [name, displayName] = nameConfig.split('='); + + if (disable) { + // Disable all models. + if (name === 'all') { + models = []; + } + removedModels.push(name); + continue; + } + + // Remove duplicate model entries. + const existingIndex = models.findIndex(({ name: n }) => n === name); + if (existingIndex !== -1) { + models.splice(existingIndex, 1); + } + + models.push({ + displayName: displayName || name, + name, + }); + } + + return models.filter((m) => !removedModels.includes(m.name)); +}; + +export const modelProviderSelectors = { + enableAzure, + modelList: modelListSelectors, + openAIAPI: openAIAPIKeySelectors, + openAIConfig, + openAIProxyUrl: openAIProxyUrlSelectors, +}; diff --git a/src/store/global/slices/settings/selectors.test.ts b/src/store/global/slices/settings/selectors/selectors.test.ts similarity index 66% rename from src/store/global/slices/settings/selectors.test.ts rename to src/store/global/slices/settings/selectors/selectors.test.ts index 8a109e880eacb..7d3923ec2c2db 100644 --- a/src/store/global/slices/settings/selectors.test.ts +++ b/src/store/global/slices/settings/selectors/selectors.test.ts @@ -1,7 +1,7 @@ import { LanguageModel } from '@/types/llm'; -import { GlobalStore } from '../../store'; -import { settingsSelectors } from './selectors'; +import { GlobalStore } from '../../../store'; +import { settingsSelectors } from './settings'; describe('settingsSelectors', () => { describe('currentSettings', () => { @@ -58,101 +58,6 @@ describe('settingsSelectors', () => { }); }); - describe('CUSTOM_MODELS', () => { - it('custom deletion, addition, and renaming of models', () => { - const s = { - serverConfig: { - customModelName: - '-all,+llama,+claude-2,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo,gpt-4-1106-preview=gpt-4-32k', - }, - settings: { - languageModel: { - openAI: {}, - }, - }, - } as unknown as GlobalStore; - - const result = settingsSelectors.modelList(s); - - expect(result).toMatchSnapshot(); - }); - - it('duplicate naming model', () => { - const s = { - serverConfig: {}, - settings: { - languageModel: { - openAI: { - customModelName: 'gpt-4-1106-preview=gpt-4-turbo,gpt-4-1106-preview=gpt-4-32k', - }, - }, - }, - } as unknown as GlobalStore; - - const result = settingsSelectors.modelList(s); - - expect(result).toMatchSnapshot(); - }); - - it('should delete model', () => { - const s = { - serverConfig: { - customModelName: '-gpt-4', - }, - settings: { - languageModel: { - openAI: {}, - }, - }, - } as unknown as GlobalStore; - - const result = settingsSelectors.modelList(s); - - expect(result).toEqual([ - { - displayName: 'gpt-3.5-turbo', - name: 'gpt-3.5-turbo', - }, - { - displayName: 'gpt-3.5-turbo-1106', - name: 'gpt-3.5-turbo-1106', - }, - { - displayName: 'gpt-3.5-turbo-16k', - name: 'gpt-3.5-turbo-16k', - }, - { - displayName: 'gpt-4-32k', - name: 'gpt-4-32k', - }, - { - displayName: 'gpt-4-1106-preview', - name: 'gpt-4-1106-preview', - }, - { - displayName: 'gpt-4-vision-preview', - name: 'gpt-4-vision-preview', - }, - ]); - }); - - it('only add the model', () => { - const s = { - serverConfig: {}, - settings: { - languageModel: { - openAI: { - customModelName: 'model1,model2,model3,model4', - }, - }, - }, - } as unknown as GlobalStore; - - const result = settingsSelectors.modelList(s); - - expect(result).toMatchSnapshot(); - }); - }); describe('defaultAgent', () => { it('should merge DEFAULT_AGENT and s.settings.defaultAgent correctly', () => { const s = { diff --git a/src/store/global/slices/settings/selectors/settings.ts b/src/store/global/slices/settings/selectors/settings.ts new file mode 100644 index 0000000000000..aa7fa80ea8553 --- /dev/null +++ b/src/store/global/slices/settings/selectors/settings.ts @@ -0,0 +1,58 @@ +import { DEFAULT_LANG } from '@/const/locale'; +import { DEFAULT_AGENT_META } from '@/const/meta'; +import { DEFAULT_AGENT, DEFAULT_AGENT_CONFIG, DEFAULT_TTS_CONFIG } from '@/const/settings'; +import { Locales } from '@/locales/resources'; +import { GlobalSettings } from '@/types/settings'; +import { isOnServerSide } from '@/utils/env'; +import { merge } from '@/utils/merge'; + +import { GlobalStore } from '../../../store'; + +export const currentSettings = (s: GlobalStore): GlobalSettings => + merge(s.defaultSettings, s.settings); + +const password = (s: GlobalStore) => currentSettings(s).password; + +const currentTTS = (s: GlobalStore) => merge(DEFAULT_TTS_CONFIG, currentSettings(s).tts); + +const defaultAgent = (s: GlobalStore) => merge(DEFAULT_AGENT, currentSettings(s).defaultAgent); + +const defaultAgentConfig = (s: GlobalStore) => merge(DEFAULT_AGENT_CONFIG, defaultAgent(s).config); + +const defaultAgentMeta = (s: GlobalStore) => merge(DEFAULT_AGENT_META, defaultAgent(s).meta); + +// TODO: Maybe we can also export settings difference +const exportSettings = (s: GlobalStore) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password: _, ...settings } = currentSettings(s); + + return settings as GlobalSettings; +}; + +const currentLanguage = (s: GlobalStore) => { + const locale = currentSettings(s).language; + + if (locale === 'auto') { + if (isOnServerSide) return DEFAULT_LANG; + + return navigator.language as Locales; + } + + return locale; +}; + +const dalleConfig = (s: GlobalStore) => currentSettings(s).tool?.dalle || {}; +const isDalleAutoGenerating = (s: GlobalStore) => currentSettings(s).tool?.dalle?.autoGenerate; + +export const settingsSelectors = { + currentLanguage, + currentSettings, + currentTTS, + dalleConfig, + defaultAgent, + defaultAgentConfig, + defaultAgentMeta, + exportSettings, + isDalleAutoGenerating, + password, +}; diff --git a/src/store/global/store.ts b/src/store/global/store.ts index a7287c8af4bba..c281377dc87e7 100644 --- a/src/store/global/store.ts +++ b/src/store/global/store.ts @@ -1,66 +1,39 @@ -import { produce } from 'immer'; -import { PersistOptions, devtools, persist } from 'zustand/middleware'; +import { PersistOptions, devtools, persist, subscribeWithSelector } from 'zustand/middleware'; import { shallow } from 'zustand/shallow'; import { createWithEqualityFn } from 'zustand/traditional'; import { StateCreator } from 'zustand/vanilla'; -import { DEFAULT_AGENT, DEFAULT_LLM_CONFIG } from '@/const/settings'; -import { SessionDefaultGroup } from '@/types/session'; import { isDev } from '@/utils/env'; import { createHyperStorage } from '../middleware/createHyperStorage'; import { type GlobalState, initialState } from './initialState'; import { type CommonAction, createCommonSlice } from './slices/common/action'; +import { type PreferenceAction, createPreferenceSlice } from './slices/preference/action'; import { type SettingsAction, createSettingsSlice } from './slices/settings/action'; // =============== 聚合 createStoreFn ============ // -export type GlobalStore = CommonAction & GlobalState & SettingsAction; +export type GlobalStore = CommonAction & GlobalState & SettingsAction & PreferenceAction; const createStore: StateCreator = (...parameters) => ({ ...initialState, ...createCommonSlice(...parameters), ...createSettingsSlice(...parameters), + ...createPreferenceSlice(...parameters), }); // =============== persist 本地缓存中间件配置 ============ // type GlobalPersist = Pick; const persistOptions: PersistOptions = { - merge: (persistedState, currentState) => { - const state = persistedState as GlobalPersist; - - return { - ...currentState, - ...state, - preference: produce(state.preference, (draft) => { - if (!draft.expandSessionGroupKeys) { - draft.expandSessionGroupKeys = [SessionDefaultGroup.Pinned, SessionDefaultGroup.Default]; - delete (draft as any).sessionGroupKeys; - } - }), - settings: produce(state.settings, (draft) => { - if (!draft.defaultAgent) { - draft.defaultAgent = DEFAULT_AGENT; - } - - // migration to new data model - if (!draft.languageModel) { - draft.languageModel = { - openAI: DEFAULT_LLM_CONFIG.openAI, - }; - } - }), - }; - }, - name: 'LOBE_SETTINGS', + name: 'LOBE_GLOBAL', skipHydration: true, storage: createHyperStorage({ localStorage: { dbName: 'LobeHub', - selectors: ['preference', 'settings'], + selectors: ['preference'], }, }), }; @@ -69,9 +42,11 @@ const persistOptions: PersistOptions = { export const useGlobalStore = createWithEqualityFn()( persist( - devtools(createStore, { - name: 'LobeChat_Global' + (isDev ? '_DEV' : ''), - }), + subscribeWithSelector( + devtools(createStore, { + name: 'LobeChat_Global' + (isDev ? '_DEV' : ''), + }), + ), persistOptions, ), shallow, diff --git a/src/store/session/slices/agent/action.ts b/src/store/session/slices/agent/action.ts index 44bde0c23845b..a738b8a9c1ce9 100644 --- a/src/store/session/slices/agent/action.ts +++ b/src/store/session/slices/agent/action.ts @@ -58,7 +58,7 @@ export const createAgentSlice: StateCreator< // if is the inbox session, update the global config const isInbox = sessionSelectors.isInboxSession(get()); if (isInbox) { - useGlobalStore.getState().updateDefaultAgent({ config }); + await useGlobalStore.getState().updateDefaultAgent({ config }); } else { const session = sessionSelectors.currentSession(get()); if (!session) return; diff --git a/src/types/settings.ts b/src/types/settings.ts deleted file mode 100644 index 23dc775cbe390..0000000000000 --- a/src/types/settings.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { NeutralColors, PrimaryColors } from '@lobehub/ui'; -import type { ThemeMode } from 'antd-style'; - -import { LocaleMode } from '@/types/locale'; -import type { LobeAgentSession } from '@/types/session'; - -export interface GlobalBaseSettings { - avatar: string; - fontSize: number; - language: LocaleMode; - neutralColor?: NeutralColors; - password: string; - primaryColor?: PrimaryColors; - themeMode: ThemeMode; -} - -export type GlobalDefaultAgent = Pick; - -export type CustomModels = { displayName: string; name: string }[]; - -export interface OpenAIConfig { - OPENAI_API_KEY: string; - azureApiVersion?: string; - /** - * custom mode name for fine-tuning or openai like model - */ - customModelName?: string; - endpoint?: string; - models?: string[]; - useAzure?: boolean; -} - -export interface GlobalLLMConfig { - openAI: OpenAIConfig; -} - -export type STTServer = 'openai' | 'browser'; -export interface GlobalTTSConfig { - openAI: { - sttModel: 'whisper-1'; - ttsModel: 'tts-1' | 'tts-1-hd'; - }; - sttAutoStop: boolean; - sttServer: STTServer; -} - -export type LLMBrand = keyof GlobalLLMConfig; - -export interface GlobalTool { - dalle: { - autoGenerate: boolean; - }; -} -/** - * 配置设置 - */ -export interface GlobalSettings extends GlobalBaseSettings { - defaultAgent: GlobalDefaultAgent; - languageModel: GlobalLLMConfig; - tool: GlobalTool; - tts: GlobalTTSConfig; -} - -export type ConfigKeys = keyof GlobalSettings; - -export interface GlobalServerConfig { - customModelName?: string; -} diff --git a/src/types/settings/base.ts b/src/types/settings/base.ts new file mode 100644 index 0000000000000..ae1c2da215642 --- /dev/null +++ b/src/types/settings/base.ts @@ -0,0 +1,13 @@ +import type { NeutralColors, PrimaryColors } from '@lobehub/ui'; +import type { ThemeMode } from 'antd-style'; + +import { LocaleMode } from '@/types/locale'; + +export interface GlobalBaseSettings { + fontSize: number; + language: LocaleMode; + neutralColor?: NeutralColors; + password: string; + primaryColor?: PrimaryColors; + themeMode: ThemeMode; +} diff --git a/src/types/settings/index.ts b/src/types/settings/index.ts new file mode 100644 index 0000000000000..b30abd1c0f940 --- /dev/null +++ b/src/types/settings/index.ts @@ -0,0 +1,34 @@ +import { DeepPartial } from 'utility-types'; + +import type { LobeAgentSession } from '@/types/session'; + +import { GlobalBaseSettings } from './base'; +import { GlobalLLMConfig } from './modelProvider'; +import { GlobalTTSConfig } from './tts'; + +export type GlobalDefaultAgent = Pick; + +export * from './base'; +export * from './modelProvider'; +export * from './tts'; + +export interface GlobalTool { + dalle: { + autoGenerate: boolean; + }; +} + +export interface GlobalServerConfig { + customModelName?: string; + defaultAgent?: DeepPartial; +} + +/** + * 配置设置 + */ +export interface GlobalSettings extends GlobalBaseSettings { + defaultAgent: GlobalDefaultAgent; + languageModel: GlobalLLMConfig; + tool: GlobalTool; + tts: GlobalTTSConfig; +} diff --git a/src/types/settings/modelProvider.ts b/src/types/settings/modelProvider.ts new file mode 100644 index 0000000000000..1f988b032cded --- /dev/null +++ b/src/types/settings/modelProvider.ts @@ -0,0 +1,17 @@ +export type CustomModels = { displayName: string; name: string }[]; + +export interface OpenAIConfig { + OPENAI_API_KEY: string; + azureApiVersion?: string; + /** + * custom mode name for fine-tuning or openai like model + */ + customModelName?: string; + endpoint?: string; + models?: string[]; + useAzure?: boolean; +} + +export interface GlobalLLMConfig { + openAI: OpenAIConfig; +} diff --git a/src/types/settings/tts.ts b/src/types/settings/tts.ts new file mode 100644 index 0000000000000..606aa24c9d709 --- /dev/null +++ b/src/types/settings/tts.ts @@ -0,0 +1,10 @@ +export type STTServer = 'openai' | 'browser'; + +export interface GlobalTTSConfig { + openAI: { + sttModel: 'whisper-1'; + ttsModel: 'tts-1' | 'tts-1-hd'; + }; + sttAutoStop: boolean; + sttServer: STTServer; +} diff --git a/src/utils/difference.ts b/src/utils/difference.ts new file mode 100644 index 0000000000000..e848a4b7fea3d --- /dev/null +++ b/src/utils/difference.ts @@ -0,0 +1,12 @@ +import { isEqual, isObject, transform } from 'lodash-es'; + +export const difference = (object: T, base: T) => { + const changes = (object: any, base: any) => + transform(object, (result: any, value, key) => { + if (!isEqual(value, base[key])) { + result[key] = isObject(value) && isObject(base[key]) ? changes(value, base[key]) : value; + } + }); + + return changes(object, base); +}; diff --git a/tests/utils.tsx b/tests/utils.tsx new file mode 100644 index 0000000000000..4b12bbe24d6ab --- /dev/null +++ b/tests/utils.tsx @@ -0,0 +1,8 @@ +import { SWRConfig } from 'swr'; + +// 全局的 SWR 配置 +const swrConfig = { + provider: () => new Map(), +}; + +export const withSWR = ({ children }: any) => {children}; diff --git a/tsconfig.json b/tsconfig.json index 84a8305f3bf80..1de249cba2ebc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,13 +18,14 @@ "baseUrl": ".", "types": ["vitest/globals"], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "~test-utils": ["./tests/utils.tsx"], }, "plugins": [ { - "name": "next" - } - ] + "name": "next", + }, + ], }, "exclude": ["node_modules"], "include": [ @@ -35,11 +36,11 @@ "**/*.ts", "**/*.d.ts", "**/*.tsx", - ".next/types/**/*.ts" + ".next/types/**/*.ts", ], "ts-node": { "compilerOptions": { - "module": "commonjs" - } - } + "module": "commonjs", + }, + }, } diff --git a/vitest.config.ts b/vitest.config.ts index 18e5acbb22cfb..ab26fbb8b58db 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ test: { alias: { '@': resolve(__dirname, './src'), + '~test-utils': resolve(__dirname, './tests/utils.tsx'), }, coverage: { all: false,