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,