diff --git a/next.config.mjs b/next.config.mjs index a9fd4d4d..0aaa214b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -20,8 +20,7 @@ const nextConfig = { compress: isProd, pageExtensions: ['tsx', 'ts'], experimental: { - optimizePackageImports: ['@lobehub/ui', '@lobehub/icons', 'chroma-js', 'shiki'], - webVitalsAttribution: ['CLS', 'LCP'], + optimizePackageImports: ['@lobehub/ui', '@lobehub/icons', 'chroma-js', 'shiki', '@icons-pack/react-simple-icons','gpt-tokenizer'], }, reactStrictMode: true, webpack(config) { diff --git a/package.json b/package.json index a1afa583..6c13f191 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/utilities": "^3.2.2", "@gltf-transform/core": "^3.10.1", + "@icons-pack/react-simple-icons": "^9.5.0", "@lobehub/tts": "^1.24.1", "@lobehub/ui": "^1.138.17", "@pixiv/three-vrm": "^2.1.1", diff --git a/src/app/chat/Apps.tsx b/src/app/chat/Apps.tsx index 4b6f064c..dfee8570 100644 --- a/src/app/chat/Apps.tsx +++ b/src/app/chat/Apps.tsx @@ -1,5 +1,5 @@ import { DancePanel, MarketPanel } from '@/panels'; -import { useConfigStore } from '@/store/config'; +import { useGlobalStore } from '@/store/global'; import { PanelKey } from '@/types/config'; export const apps = [ @@ -16,7 +16,7 @@ export const apps = [ ]; export default () => { - const [panel] = useConfigStore((s) => [s.panel]); + const [panel] = useGlobalStore((s) => [s.panel]); return ( <> diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 00000000..07149103 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,5 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +export default dynamic(() => import('@/components/Error')); diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 00000000..5f6fc47d --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,22 @@ +'use client'; + +import Error from 'next/error'; +import { useEffect } from 'react'; + +export default function GlobalError({ + error, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.log('error', error); + }, [error]); + return ( + + + + + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 44eea653..8cd5742d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,22 +1,18 @@ import { Analytics } from '@vercel/analytics/react'; -import { cookies } from 'next/headers'; import { PropsWithChildren } from 'react'; -import { VIDOL_THEME_APPEARANCE } from '@/constants/theme'; import Layout from '@/layout'; import StyleRegistry from './StyleRegistry'; const RootLayout = ({ children }: PropsWithChildren) => { // get default theme config to use with ssr - const cookieStore = cookies(); - const appearance = cookieStore.get(VIDOL_THEME_APPEARANCE); return ( - {children} + {children} diff --git a/src/app/role/style.ts b/src/app/role/style.ts index 2d780607..b9c25802 100644 --- a/src/app/role/style.ts +++ b/src/app/role/style.ts @@ -4,7 +4,7 @@ export const useStyles = createStyles(({ css }) => ({ preview: css` overflow: auto; width: 80rem; - margin: 32px auto; + margin: 0 auto; `, edit: css` padding: 0 24px; diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx new file mode 100644 index 00000000..a730528f --- /dev/null +++ b/src/components/Avatar.tsx @@ -0,0 +1,43 @@ +import { createStyles } from 'antd-style'; +import NextImage from 'next/image'; + +import { AVATAR_IMAGE_SIZE, DEFAULT_USER_AVATAR_URL } from '@/constants/common'; + +const useStyle = createStyles( + ({ css, token }) => css` + cursor: pointer; + overflow: hidden; + border-radius: 50%; + transition: + scale 400ms ${token.motionEaseOut}, + box-shadow 100ms ${token.motionEaseOut}; + + &:hover { + box-shadow: 0 0 0 3px ${token.colorText}; + } + + &:active { + scale: 0.8; + } + `, +); + +interface Props { + avatar?: string; + size?: number; +} + +export default (props: Props) => { + const { size = AVATAR_IMAGE_SIZE, avatar } = props; + const { styles } = useStyle(); + return ( + + ); +}; diff --git a/src/components/Error/index.tsx b/src/components/Error/index.tsx new file mode 100644 index 00000000..44a72044 --- /dev/null +++ b/src/components/Error/index.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { FluentEmoji } from '@lobehub/ui'; +import { Button } from 'antd'; +import Link from 'next/link'; +import { memo, useEffect } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { MAX_WIDTH } from '@/constants/common'; +import ResetConfig from '@/features/Actions/ClearSession'; +import ClearChat from '@/features/Actions/ResetConfig'; + +interface ErrorCaptureProps { + error: Error & { digest?: string }; + reset: () => void; +} + +const ErrorCapture = memo(({ reset, error }) => { + useEffect(() => { + // Log the error to an error reporting service + console.error(error); + }, [error]); + + return ( + +

+ ERROR +

+ +

+ 页面遇到一点问题... +

+

+ 项目当前正在施工中,不保证数据稳定性,如果遇到问题可以尝试 + + 或 + ,造成地不便敬请谅解 +

+ + + + + + +
+ ); +}); + +ErrorCapture.displayName = 'ErrorCapture'; + +export default ErrorCapture; diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx new file mode 100644 index 00000000..4a3d89da --- /dev/null +++ b/src/components/Menu/index.tsx @@ -0,0 +1,97 @@ +import { Menu as AntdMenu, MenuProps as AntdMenuProps, ConfigProvider } from 'antd'; +import { createStyles } from 'antd-style'; +import { memo } from 'react'; + +const useStyles = createStyles(({ css, token, prefixCls }) => ({ + compact: css` + display: flex; + flex-direction: column; + gap: 0.125rem; + `, + menu: css` + flex: 1; + background: transparent; + border: none !important; + + .${prefixCls}-menu-item-divider { + margin-block: 0.125rem; + border-color: ${token.colorFillTertiary}; + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + } + + .${prefixCls}-menu-item, .${prefixCls}-menu-submenu-title { + display: flex; + gap: 0.75rem; + align-items: center; + + height: unset; + min-height: 2rem; + padding: 0.375rem 0.75rem; + + line-height: 2; + + .anticon + .${prefixCls}-menu-title-content { + margin-inline-start: 0; + } + } + + .${prefixCls}-menu-item-selected { + .${prefixCls}-menu-item-icon svg { + color: ${token.colorText}; + } + } + + .${prefixCls}-menu-item-icon svg { + color: ${token.colorTextSecondary}; + } + + .${prefixCls}-menu-title-content { + flex: 1; + } + `, +})); + +export interface MenuProps extends AntdMenuProps { + variant?: 'default' | 'compact'; +} + +const Menu = memo(({ className, selectable = false, variant, ...rest }) => { + const isCompact = variant === 'compact'; + const { cx, styles, theme } = useStyles(); + return ( + + + + ); +}); + +export default Menu; diff --git a/src/components/PageLoading/index.tsx b/src/components/PageLoading/index.tsx index 0873482d..2c0a42b3 100644 --- a/src/components/PageLoading/index.tsx +++ b/src/components/PageLoading/index.tsx @@ -5,12 +5,13 @@ import { Center, Flexbox } from 'react-layout-kit'; interface PageLoadingProps { className?: string; + description?: string; style?: React.CSSProperties; title: string; } const PageLoading = (props: PageLoadingProps) => { - const { title, className, style } = props; + const { title, className, style, description } = props; return (
@@ -19,6 +20,7 @@ const PageLoading = (props: PageLoadingProps) => { {title}
+ {description &&
{description}
}
); diff --git a/src/constants/common.ts b/src/constants/common.ts index 511e59e8..81047fb4 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -9,6 +9,8 @@ export const LOADING_FLAG = '...'; export const DEFAULT_USER_AVATAR = '😀'; export const MAX_NAME_LENGTH = 20; + +export const MAX_WIDTH = 1024; export const MAX_DESCRIPTION_LENGTH = 100; export const MAX_GREETING_LENGTH = 200; export const MAX_README_LENGTH = 800; diff --git a/src/constants/settings.ts b/src/constants/settings.ts deleted file mode 100644 index e218ec52..00000000 --- a/src/constants/settings.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { DEFAULT_USER_AVATAR } from '@/constants/common'; -import { DEFAULT_PRIMARY_COLOR } from '@/constants/theme'; -import { Config } from '@/types/config'; - -export const DEFAULT_SETTINGS: Config = { - backgroundEffect: 'glow', - languageModel: { - openAI: { - apikey: '', - endpoint: '', - model: 'gpt-3.5-turbo', - }, - }, - primaryColor: DEFAULT_PRIMARY_COLOR, - avatar: DEFAULT_USER_AVATAR, - nickName: '', -}; diff --git a/src/features/Actions/ClearSession.tsx b/src/features/Actions/ClearSession.tsx new file mode 100644 index 00000000..213715dd --- /dev/null +++ b/src/features/Actions/ClearSession.tsx @@ -0,0 +1,41 @@ +import { App, Button } from 'antd'; +import { ButtonType } from 'antd/es/button'; +import React from 'react'; + +import { useAgentStore } from '@/store/agent'; +import { useSessionStore } from '@/store/session'; + +interface Props { + text?: string; + type?: ButtonType; +} +export default (props: Props) => { + const { text = '立即清除', type = 'primary' } = props; + const clearAgentStorage = useAgentStore((s) => s.clearAgentStorage); + const clearSessions = useSessionStore((s) => s.clearSessions); + const { message, modal } = App.useApp(); + + const handleClear = () => { + modal.confirm({ + cancelText: '取消', + centered: true, + content: '操作无法撤销,清除后数据将无法恢复,请慎重操作', + okButtonProps: { + danger: true, + }, + okText: '确定', + onOk: () => { + clearSessions(); + clearAgentStorage(); + message.success('清除成功'); + }, + title: '确认清除所有会话消息?', + }); + }; + + return ( + + ); +}; diff --git a/src/features/Actions/Dance.tsx b/src/features/Actions/Dance.tsx index e153baae..9691a1d0 100644 --- a/src/features/Actions/Dance.tsx +++ b/src/features/Actions/Dance.tsx @@ -1,10 +1,10 @@ import { ActionIcon } from '@lobehub/ui'; import { Music2 } from 'lucide-react'; -import { useConfigStore } from '@/store/config'; +import { useGlobalStore } from '@/store/global'; export default () => { - const [openPanel] = useConfigStore((s) => [s.openPanel]); + const [openPanel] = useGlobalStore((s) => [s.openPanel]); return ( { + return { + icon: css` + svg { + fill: ${token.colorTextDescription}; + } + + &:hover { + svg { + fill: ${token.colorText}; + } + } + `, + }; +}); + +export default () => { + const { styles } = useStyles(); + const theme = useTheme(); + return ( + window.open('https://discord.gg/AYFPHvv2jT', '_blank')} + style={{ border: `1px solid ${theme.colorFillSecondary}` }} + /> + ); +}; diff --git a/src/features/Actions/Github.tsx b/src/features/Actions/Github.tsx new file mode 100644 index 00000000..996847d5 --- /dev/null +++ b/src/features/Actions/Github.tsx @@ -0,0 +1,35 @@ +import { SiGithub } from '@icons-pack/react-simple-icons'; +import { ActionIcon } from '@lobehub/ui'; +import { createStyles, useTheme } from 'antd-style'; + +const useStyles = createStyles(({ css, token }) => { + return { + icon: css` + svg { + fill: ${token.colorTextDescription}; + } + + &:hover { + svg { + fill: ${token.colorText}; + } + } + `, + }; +}); + +export default () => { + const theme = useTheme(); + + const { styles } = useStyles(); + return ( + window.open('https://github.com/lobehub/lobe-vidol', '_blank')} + style={{ border: `1px solid ${theme.colorFillSecondary}` }} + /> + ); +}; diff --git a/src/features/Actions/Market.tsx b/src/features/Actions/Market.tsx index f795708e..40e3f59b 100644 --- a/src/features/Actions/Market.tsx +++ b/src/features/Actions/Market.tsx @@ -2,10 +2,10 @@ import { ActionIcon } from '@lobehub/ui'; import { PlusCircle } from 'lucide-react'; import { DESKTOP_HEADER_ICON_SIZE } from '@/constants/token'; -import { useConfigStore } from '@/store/config'; +import { useGlobalStore } from '@/store/global'; export default () => { - const openPanel = useConfigStore((s) => s.openPanel); + const openPanel = useGlobalStore((s) => s.openPanel); return ( { + const { text = '立即重置', type = 'primary' } = props; + const resetConfig = useSettingStore((s) => s.resetConfig); + const { message, modal } = App.useApp(); + + const handleReset = () => { + modal.confirm({ + cancelText: '取消', + centered: true, + content: '操作无法撤销,重置后数据将无法恢复,请慎重操作', + okButtonProps: { + danger: true, + }, + okText: '确定', + onOk: () => { + resetConfig(); + message.success('重置成功'); + }, + title: '确认重置所有系统设置?', + }); + }; + + return ( + + ); +}; diff --git a/src/features/Actions/ThemeMode/index.tsx b/src/features/Actions/ThemeMode/index.tsx new file mode 100644 index 00000000..6c8925bb --- /dev/null +++ b/src/features/Actions/ThemeMode/index.tsx @@ -0,0 +1,61 @@ +import { ActionIcon, Icon } from '@lobehub/ui'; +import { Popover } from 'antd'; +import { useTheme } from 'antd-style'; +import { Monitor, Moon, Sun } from 'lucide-react'; +import { memo, useMemo } from 'react'; + +import Menu, { type MenuProps } from '@/components/Menu'; +import { useGlobalStore } from '@/store/global'; + +const themeIcons = { + auto: Monitor, + dark: Moon, + light: Sun, +}; + +const ThemeButton = memo(() => { + const theme = useTheme(); + const [themeMode, switchThemeMode] = useGlobalStore((s) => [s.themeMode, s.setThemeMode]); + + const items: MenuProps['items'] = useMemo( + () => [ + { + icon: , + key: 'auto', + label: '跟随系统', + onClick: () => switchThemeMode('auto'), + }, + { + icon: , + key: 'light', + label: '亮色模式', + onClick: () => switchThemeMode('light'), + }, + { + icon: , + key: 'dark', + label: '暗黑模式', + onClick: () => switchThemeMode('dark'), + }, + ], + [], + ); + + return ( + } + overlayInnerStyle={{ + padding: 0, + }} + trigger={['click', 'hover']} + > + + + ); +}); + +export default ThemeButton; diff --git a/src/features/Actions/Token.tsx b/src/features/Actions/Token.tsx index 92c28232..f73b0967 100644 --- a/src/features/Actions/Token.tsx +++ b/src/features/Actions/Token.tsx @@ -3,10 +3,10 @@ import { isEqual } from 'lodash-es'; import { OPENAI_MODEL_LIST } from '@/constants/openai'; import { useCalculateToken } from '@/hooks/useCalculateToken'; -import { configSelectors, useConfigStore } from '@/store/config'; +import { configSelectors, useSettingStore } from '@/store/setting'; const Token = () => { - const config = useConfigStore((s) => configSelectors.currentOpenAIConfig(s), isEqual); + const config = useSettingStore((s) => configSelectors.currentOpenAIConfig(s), isEqual); const usedTokens = useCalculateToken(); return ( diff --git a/src/features/Actions/TokenMini.tsx b/src/features/Actions/TokenMini.tsx index 1489ccbf..46183b1f 100644 --- a/src/features/Actions/TokenMini.tsx +++ b/src/features/Actions/TokenMini.tsx @@ -3,10 +3,10 @@ import { isEqual } from 'lodash-es'; import { OPENAI_MODEL_LIST } from '@/constants/openai'; import { useCalculateToken } from '@/hooks/useCalculateToken'; -import { configSelectors, useConfigStore } from '@/store/config'; +import { configSelectors, useSettingStore } from '@/store/setting'; const TokenMini = () => { - const config = useConfigStore((s) => configSelectors.currentOpenAIConfig(s), isEqual); + const config = useSettingStore((s) => configSelectors.currentOpenAIConfig(s), isEqual); const usedTokens = useCalculateToken(); const maxValue = OPENAI_MODEL_LIST.find((item) => item.name === config?.model)?.maxToken || 4096; diff --git a/src/features/Actions/UserAvatar.tsx b/src/features/Actions/UserAvatar.tsx new file mode 100644 index 00000000..f96b4e93 --- /dev/null +++ b/src/features/Actions/UserAvatar.tsx @@ -0,0 +1,15 @@ +import { Space, Typography } from 'antd'; + +import Avatar from '@/components/Avatar'; +import { useSettingStore } from '@/store/setting'; + +export default () => { + const [avatar, nickName] = useSettingStore((s) => [s.config.avatar, s.config.nickName]); + + return ( + + + {nickName} + + ); +}; diff --git a/src/features/ChatInfo/Operations/index.tsx b/src/features/ChatInfo/Operations/index.tsx index d7461643..4fec801d 100644 --- a/src/features/ChatInfo/Operations/index.tsx +++ b/src/features/ChatInfo/Operations/index.tsx @@ -3,7 +3,7 @@ import { Modal } from 'antd'; import { Eraser, Music } from 'lucide-react'; import React, { memo } from 'react'; -import { useConfigStore } from '@/store/config'; +import { useGlobalStore } from '@/store/global'; import { useSessionStore } from '@/store/session'; import Item from './Item'; @@ -15,7 +15,7 @@ export interface MyListProps { } const Operations = memo(({ mobile }) => { - const [openPanel] = useConfigStore((s) => [s.openPanel]); + const [openPanel] = useGlobalStore((s) => [s.openPanel]); const [clearHistory] = useSessionStore((s) => [s.clearHistory]); const items = [ diff --git a/src/features/ChatItem/Error/ApiKeyForm.tsx b/src/features/ChatItem/Error/ApiKeyForm.tsx index 19bf7c2a..a6e1b17f 100644 --- a/src/features/ChatItem/Error/ApiKeyForm.tsx +++ b/src/features/ChatItem/Error/ApiKeyForm.tsx @@ -4,8 +4,8 @@ import { Network } from 'lucide-react'; import { memo, useState } from 'react'; import { Center, Flexbox } from 'react-layout-kit'; -import { configSelectors, useConfigStore } from '@/store/config'; import { useSessionStore } from '@/store/session'; +import { configSelectors, useSettingStore } from '@/store/setting'; import { FormAction } from './style'; @@ -16,7 +16,7 @@ interface APIKeyFormProps { const APIKeyForm = ({ id }: APIKeyFormProps) => { const [showProxy, setShow] = useState(false); - const [currentOpenAIConfig, setConfig] = useConfigStore((s) => [ + const [currentOpenAIConfig, setConfig] = useSettingStore((s) => [ configSelectors.currentOpenAIConfig(s), s.setOpenAIConfig, ]); diff --git a/src/features/DebugUI/index.tsx b/src/features/DebugUI/index.tsx new file mode 100644 index 00000000..6ebe6bf9 --- /dev/null +++ b/src/features/DebugUI/index.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { Icon } from '@lobehub/ui'; +import { FloatButton } from 'antd'; +import { LucideBugPlay } from 'lucide-react'; +import { memo } from 'react'; + +const DebugUI = memo(() => { + return ( + } + onClick={async () => { + throw new Error('触发错误'); + }} + tooltip={'触发错误'} + /> + ); +}); + +export default DebugUI; diff --git a/src/features/MarketInfo/index.tsx b/src/features/MarketInfo/index.tsx index 1b9d1d08..fb967267 100644 --- a/src/features/MarketInfo/index.tsx +++ b/src/features/MarketInfo/index.tsx @@ -9,7 +9,7 @@ import AgentCard from '@/components/agent/AgentCard'; import SystemRole from '@/components/agent/SystemRole'; import { SIDEBAR_MAX_WIDTH, SIDEBAR_WIDTH } from '@/constants/token'; import { agentSelectors, useAgentStore } from '@/store/agent'; -import { useConfigStore } from '@/store/config'; +import { useGlobalStore } from '@/store/global'; import { marketStoreSelectors, useMarketStore } from '@/store/market'; import { useSessionStore } from '@/store/session'; @@ -37,7 +37,7 @@ const Header = () => { marketStoreSelectors.currentAgentItem(s), ], ); - const [closePanel] = useConfigStore((s) => [s.closePanel]); + const [closePanel] = useGlobalStore((s) => [s.closePanel]); const [subscribe, unsubscribe, subscribed] = useAgentStore((s) => [ s.subscribe, s.unsubscribe, diff --git a/src/features/Settings/common.tsx b/src/features/Settings/common.tsx index 482ab053..0db070fb 100644 --- a/src/features/Settings/common.tsx +++ b/src/features/Settings/common.tsx @@ -1,19 +1,15 @@ import { Form, FormGroup, FormItem } from '@lobehub/ui'; -import { App, Button, Input, Segmented } from 'antd'; -import { ThemeMode, createStyles } from 'antd-style'; +import { createStyles } from 'antd-style'; import classNames from 'classnames'; -import { isEqual } from 'lodash-es'; import { Monitor, Settings2, User2Icon } from 'lucide-react'; import React from 'react'; -import { MAX_NAME_LENGTH } from '@/constants/common'; +import ResetConfig from '@/features/Actions/ClearSession'; +import ClearChat from '@/features/Actions/ResetConfig'; +import BackgroundEffect from '@/features/Settings/features/BackgroundEffect'; +import NickName from '@/features/Settings/features/NickName'; +import ThemeSwatchesNetural from '@/features/Settings/features/ThemeSwatchesNetural'; import ThemeSwatchesPrimary from '@/features/Settings/features/ThemeSwatchesPrimary'; -import { useSyncSettings } from '@/features/Settings/useSyncSettings'; -import { useAgentStore } from '@/store/agent'; -import { useConfigStore } from '@/store/config'; -import { useSessionStore } from '@/store/session'; -import { useThemeStore } from '@/store/theme'; -import { BackgroundEffect } from '@/types/config'; import AvatarWithUpload from './features/AvatarWithUpload'; @@ -37,124 +33,32 @@ const useStyles = createStyles(({ css }) => ({ const CommonConfig = (props: CommonConfigProps) => { const { style, className } = props; const { styles } = useStyles(); - const [config, setConfig] = useConfigStore((s) => [s.config, s.setConfig], isEqual); - const clearAgentStorage = useAgentStore((s) => s.clearAgentStorage); - const [themeMode, setThemeMode] = useThemeStore((s) => [s.themeMode, s.setThemeMode]); - const clearSessions = useSessionStore((s) => s.clearSessions); - const resetConfig = useConfigStore((s) => s.resetConfig); - const { message, modal } = App.useApp(); - - const [form] = Form.useForm(); - - useSyncSettings(form); - - const handleClear = () => { - modal.confirm({ - cancelText: '取消', - centered: true, - content: '操作无法撤销,清除后数据将无法恢复,请慎重操作', - okButtonProps: { - danger: true, - }, - okText: '确定', - onOk: () => { - clearSessions(); - clearAgentStorage(); - message.success('清除成功'); - }, - title: '确认清除所有会话消息?', - }); - }; - - const handleReset = () => { - modal.confirm({ - cancelText: '取消', - centered: true, - content: '操作无法撤销,重置后数据将无法恢复,请慎重操作', - okButtonProps: { - danger: true, - }, - okText: '确定', - onOk: () => { - resetConfig(); - message.success('重置成功'); - }, - title: '确认重置所有系统设置?', - }); - }; return (
-
- + + - { - setConfig({ nickName: e.target.value }); - }} - /> + - - { - setThemeMode(value as ThemeMode); - }} - options={[ - { - label: '🔆 亮色模式', - value: 'light', - }, - { - label: '🌙 暗色模式', - value: 'dark', - }, - { - label: '💻 跟随系统', - value: 'auto', - }, - ]} - /> - - { - setConfig({ backgroundEffect: value }); - }} - options={[ - { - label: '光辉', - value: 'glow', - }, - { - label: '无背景', - value: 'none', - }, - ]} - /> + + + + @@ -163,18 +67,14 @@ const CommonConfig = (props: CommonConfigProps) => { divider label={'清除所有会话消息'} > - + - + diff --git a/src/features/Settings/features/AvatarWithUpload/index.tsx b/src/features/Settings/features/AvatarWithUpload/index.tsx index 421d525c..7cee9922 100644 --- a/src/features/Settings/features/AvatarWithUpload/index.tsx +++ b/src/features/Settings/features/AvatarWithUpload/index.tsx @@ -1,70 +1,32 @@ import { Upload } from 'antd'; -import { createStyles } from 'antd-style'; -import NextImage from 'next/image'; -import { CSSProperties, memo, useCallback } from 'react'; +import { memo, useCallback } from 'react'; -import { DEFAULT_USER_AVATAR_URL } from '@/constants/common'; -import { useConfigStore } from '@/store/config'; +import Avatar from '@/components/Avatar'; +import { AVATAR_COMPRESS_SIZE, AVATAR_IMAGE_SIZE } from '@/constants/common'; +import { useSettingStore } from '@/store/setting'; import { createUploadImageHandler } from '@/utils/common'; import { imageToBase64 } from '@/utils/imageToBase64'; -const useStyle = createStyles( - ({ css, token }) => css` - cursor: pointer; - overflow: hidden; - border-radius: 50%; - transition: - scale 400ms ${token.motionEaseOut}, - box-shadow 100ms ${token.motionEaseOut}; - - &:hover { - box-shadow: 0 0 0 3px ${token.colorText}; - } - - &:active { - scale: 0.8; - } - `, -); - -interface AvatarWithUploadProps { - compressSize?: number; - id?: string; - size?: number; - style?: CSSProperties; -} - -const AvatarWithUpload = memo( - ({ size = 40, compressSize = 256, style, id }) => { - const { styles } = useStyle(); - const [avatar, setConfig] = useConfigStore((s) => [s.config.avatar, s.setConfig]); - - const handleUploadAvatar = useCallback( - createUploadImageHandler((avatar) => { - const img = new Image(); - img.src = avatar; - img.addEventListener('load', () => { - const webpBase64 = imageToBase64({ img, size: compressSize }); - setConfig({ avatar: webpBase64 }); - }); - }), - [], - ); - - return ( -
- void 0} maxCount={1}> - - -
- ); - }, -); +const AvatarWithUpload = memo(() => { + const [avatar, setAvatar] = useSettingStore((s) => [s.config.avatar, s.setAvatar]); + + const handleUploadAvatar = useCallback( + createUploadImageHandler((avatar) => { + const img = new Image(); + img.src = avatar; + img.addEventListener('load', () => { + const webpBase64 = imageToBase64({ img, size: AVATAR_COMPRESS_SIZE }); + setAvatar(webpBase64); + }); + }), + [], + ); + + return ( + void 0} maxCount={1}> + + + ); +}); export default AvatarWithUpload; diff --git a/src/features/Settings/features/BackgroundEffect/index.tsx b/src/features/Settings/features/BackgroundEffect/index.tsx new file mode 100644 index 00000000..76b7eb8d --- /dev/null +++ b/src/features/Settings/features/BackgroundEffect/index.tsx @@ -0,0 +1,38 @@ +import { Segmented } from 'antd'; +import { isEqual } from 'lodash-es'; +import React, { CSSProperties, memo } from 'react'; + +import { useSettingStore } from '@/store/setting'; +import { BackgroundEffect } from '@/types/config'; + +interface Props { + style?: CSSProperties; +} + +export default memo((props) => { + const { style } = props; + const [backgroundEffect, setBackgroundEffect] = useSettingStore( + (s) => [s.config.backgroundEffect, s.setBackgroundEffect], + isEqual, + ); + + return ( + { + setBackgroundEffect(value); + }} + options={[ + { + label: '光辉', + value: 'glow', + }, + { + label: '无背景', + value: 'none', + }, + ]} + /> + ); +}); diff --git a/src/features/Settings/features/NickName/index.tsx b/src/features/Settings/features/NickName/index.tsx new file mode 100644 index 00000000..3364ea56 --- /dev/null +++ b/src/features/Settings/features/NickName/index.tsx @@ -0,0 +1,33 @@ +import { Input } from 'antd'; +import { isEqual } from 'lodash-es'; +import React, { CSSProperties, memo } from 'react'; + +import { MAX_NAME_LENGTH } from '@/constants/common'; +import { useSettingStore } from '@/store/setting'; + +interface Props { + style?: CSSProperties; +} + +const NickName = memo((props) => { + const { style } = props; + const [nickName, setNickName] = useSettingStore( + (s) => [s.config.nickName, s.setNickName], + isEqual, + ); + + return ( + { + setNickName(e.target.value); + }} + /> + ); +}); + +export default NickName; diff --git a/src/features/Settings/features/ThemeSwatchesNetural.tsx b/src/features/Settings/features/ThemeSwatchesNetural.tsx new file mode 100644 index 00000000..23e94084 --- /dev/null +++ b/src/features/Settings/features/ThemeSwatchesNetural.tsx @@ -0,0 +1,32 @@ +import { + NeutralColors, + Swatches, + findCustomThemeName, + neutralColors, + neutralColorsSwatches, +} from '@lobehub/ui'; +import { memo } from 'react'; + +import { useSettingStore } from '@/store/setting'; + +const ThemeSwatchesNeutral = memo(() => { + const [neutralColor, setNeutralColor] = useSettingStore((s) => [ + s.config.neutralColor, + s.setNeutralColor, + ]); + + const handleSelect = (v: any) => { + const name = findCustomThemeName('neutral', v) as NeutralColors; + setNeutralColor(name || ''); + }; + + return ( + + ); +}); + +export default ThemeSwatchesNeutral; diff --git a/src/features/Settings/features/ThemeSwatchesPrimary.tsx b/src/features/Settings/features/ThemeSwatchesPrimary.tsx index 026474bc..179e1a82 100644 --- a/src/features/Settings/features/ThemeSwatchesPrimary.tsx +++ b/src/features/Settings/features/ThemeSwatchesPrimary.tsx @@ -7,14 +7,17 @@ import { } from '@lobehub/ui'; import { memo } from 'react'; -import { useConfigStore } from '@/store/config'; +import { useSettingStore } from '@/store/setting'; const ThemeSwatchesPrimary = memo(() => { - const [primaryColor, setConfig] = useConfigStore((s) => [s.config.primaryColor, s.setConfig]); + const [primaryColor, setPrimaryColor] = useSettingStore((s) => [ + s.config.primaryColor, + s.setPrimaryColor, + ]); const handleSelect = (v: any) => { const name = findCustomThemeName('primary', v) as PrimaryColors; - setConfig({ primaryColor: name || '' }); + setPrimaryColor(name || ''); }; return ( diff --git a/src/features/Settings/model/openai.tsx b/src/features/Settings/model/openai.tsx index c55605ea..8a4676d9 100644 --- a/src/features/Settings/model/openai.tsx +++ b/src/features/Settings/model/openai.tsx @@ -9,7 +9,7 @@ import React, { useEffect } from 'react'; import { OPENAI_MODEL_LIST } from '@/constants/openai'; import { chatCompletion } from '@/services/chat'; -import { configSelectors, useConfigStore } from '@/store/config'; +import { configSelectors, useSettingStore } from '@/store/setting'; import { ChatMessage } from '@/types/chat'; interface ConfigProps { @@ -29,8 +29,8 @@ const Config = (props: ConfigProps) => { const { style, className } = props; const { styles } = useStyles(); const [form] = AForm.useForm(); - const openAIConfig = useConfigStore((s) => configSelectors.currentOpenAIConfig(s), isEqual); - const setOpenAIConfig = useConfigStore((s) => s.setOpenAIConfig); + const openAIConfig = useSettingStore((s) => configSelectors.currentOpenAIConfig(s), isEqual); + const setOpenAIConfig = useSettingStore((s) => s.setOpenAIConfig); useEffect(() => { form.setFieldsValue(openAIConfig); diff --git a/src/features/Settings/useSyncSettings.ts b/src/features/Settings/useSyncSettings.ts deleted file mode 100644 index c74d7179..00000000 --- a/src/features/Settings/useSyncSettings.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { FormInstance } from 'antd/es/form/hooks/useForm'; -import { useEffect } from 'react'; - -import { useConfigStore } from '@/store/config'; - -export const useSyncSettings = (form: FormInstance) => { - useEffect(() => { - // set the first time - form.setFieldsValue(useConfigStore.getState().config); - - // sync with later updated settings - const unsubscribe = useConfigStore.subscribe( - (s) => s.config, - (config) => { - form.setFieldsValue(config); - }, - ); - - return () => { - unsubscribe(); - }; - }, []); -}; diff --git a/src/layout/AppTheme.tsx b/src/layout/AppTheme.tsx new file mode 100644 index 00000000..6880d66a --- /dev/null +++ b/src/layout/AppTheme.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { NeutralColors, PrimaryColors, ThemeProvider } from '@lobehub/ui'; +import { createStyles } from 'antd-style'; +import { ReactNode, memo, useEffect } from 'react'; + +import { VIDOL_THEME_NEUTRAL_COLOR, VIDOL_THEME_PRIMARY_COLOR } from '@/constants/theme'; +import { useGlobalStore } from '@/store/global'; +import { useSettingStore } from '@/store/setting'; +import { GlobalStyle } from '@/styles'; +import { setCookie } from '@/utils/cookie'; + +export interface AppThemeProps { + children?: ReactNode; + defaultNeutralColor?: NeutralColors; + defaultPrimaryColor?: PrimaryColors; +} + +const useStyles = createStyles(({ css }) => ({ + content: css` + overflow-y: hidden; + display: flex; + flex-direction: column; + align-items: center; + + height: 100%; + `, +})); + +const AppTheme = memo((props: AppThemeProps) => { + const { children, defaultNeutralColor, defaultPrimaryColor } = props; + const [primaryColor, neutralColor] = useSettingStore((s) => [ + s.config.primaryColor, + s.config.neutralColor, + ]); + + const themeMode = useGlobalStore((s) => s.themeMode); + + const { styles } = useStyles(); + + useEffect(() => { + setCookie(VIDOL_THEME_PRIMARY_COLOR, primaryColor); + }, [primaryColor]); + + useEffect(() => { + setCookie(VIDOL_THEME_NEUTRAL_COLOR, neutralColor); + }, [neutralColor]); + + return ( + + +
{children}
+
+ ); +}); + +export default AppTheme; diff --git a/src/layout/Background/index.tsx b/src/layout/Background/index.tsx index d9fe0dff..fcba83a3 100644 --- a/src/layout/Background/index.tsx +++ b/src/layout/Background/index.tsx @@ -1,10 +1,10 @@ -import { useConfigStore } from '@/store/config'; +import { useSettingStore } from '@/store/setting'; import { useStyles } from './style'; const Background = () => { const { styles } = useStyles(); - const backgroundEffect = useConfigStore((s) => s.config.backgroundEffect); + const backgroundEffect = useSettingStore((s) => s.config.backgroundEffect); return backgroundEffect === 'glow' ?
: null; }; diff --git a/src/layout/Header/index.tsx b/src/layout/Header/index.tsx index 2126f1a6..0a0ac453 100644 --- a/src/layout/Header/index.tsx +++ b/src/layout/Header/index.tsx @@ -1,12 +1,14 @@ 'use client'; -import { ActionIcon, Header as LobeHeader, Logo, TabsNav } from '@lobehub/ui'; +import { Header as LobeHeader, Logo, TabsNav } from '@lobehub/ui'; import { Space, Tag, Tooltip } from 'antd'; -import { GithubIcon, UserRoundPlusIcon } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { memo } from 'react'; +import Discord from '@/features/Actions/Discord'; +import Github from '@/features/Actions/Github'; +import ThemeMode from '@/features/Actions/ThemeMode'; import { HeaderNavKey } from '@/layout/type'; interface Props { @@ -20,20 +22,10 @@ const Header = (props: Props) => { return ( window.open('https://github.com/lobehub/lobe-vidol-market', '_blank')} - size="large" - />, - window.open('https://github.com/lobehub/lobe-vidol', '_blank')} - size="large" - />, + , + , + , + // , ]} logo={ diff --git a/src/layout/StoreHydration.tsx b/src/layout/StoreHydration.tsx index f508a95a..803485b8 100644 --- a/src/layout/StoreHydration.tsx +++ b/src/layout/StoreHydration.tsx @@ -1,8 +1,10 @@ +'use client'; + import { useRouter } from 'next/navigation'; import { memo, useEffect } from 'react'; -import { useConfigStore } from '@/store/config'; import { useSessionStore } from '@/store/session'; +import { useSettingStore } from '@/store/setting'; const StoreHydration = () => { const router = useRouter(); @@ -10,11 +12,14 @@ const StoreHydration = () => { useEffect(() => { // refs: https://github.com/pmndrs/zustand/blob/main/docs/integrations/persisting-store-data.md#hashydrated useSessionStore.persist.rehydrate(); - useConfigStore.persist.rehydrate(); + useSettingStore.persist.rehydrate(); }, []); useEffect(() => { router.prefetch('/chat'); + router.prefetch('/settings'); + router.prefetch('/role'); + router.prefetch('/market'); }, [router]); return null; }; diff --git a/src/layout/StyleRegistry.tsx b/src/layout/StyleRegistry.tsx new file mode 100644 index 00000000..bf625ae9 --- /dev/null +++ b/src/layout/StyleRegistry.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { StyleProvider, extractStaticStyle } from 'antd-style'; +import { useServerInsertedHTML } from 'next/navigation'; +import { PropsWithChildren, useRef } from 'react'; + +const StyleRegistry = ({ children }: PropsWithChildren) => { + const isInsert = useRef(false); + + useServerInsertedHTML(() => { + // avoid duplicate css insert + // refs: https://github.com/vercel/next.js/discussions/49354#discussioncomment-6279917 + if (isInsert.current) return; + + isInsert.current = true; + + // @ts-ignore + return extractStaticStyle().map((item) => item.style); + }); + + return {children}; +}; + +export default StyleRegistry; diff --git a/src/layout/index.tsx b/src/layout/index.tsx index 89cd87b3..f3473050 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -1,54 +1,44 @@ -'use client'; +import { PrimaryColors } from '@lobehub/ui'; +import { ThemeAppearance } from 'antd-style'; +import dynamic from 'next/dynamic'; +import { cookies } from 'next/headers'; +import { FC, ReactNode } from 'react'; -import { ThemeProvider } from '@lobehub/ui'; -import { ThemeAppearance, createStyles } from 'antd-style'; -import { ReactNode } from 'react'; +import { VIDOL_THEME_NEUTRAL_COLOR, VIDOL_THEME_PRIMARY_COLOR } from '@/constants/theme'; +import AppTheme from '@/layout/AppTheme'; +import StoreHydration from '@/layout/StoreHydration'; +import StyleRegistry from '@/layout/StyleRegistry'; -import { VIDOL_THEME_APPEARANCE } from '@/constants/theme'; -import { useConfigStore } from '@/store/config'; -import { useThemeStore } from '@/store/theme'; -import { GlobalStyle } from '@/styles'; -import { setCookie } from '@/utils/cookie'; +let DebugUI: FC = () => null; -import StoreHydration from './StoreHydration'; +if (process.env.NODE_ENV === 'development') { + DebugUI = dynamic(() => import('@/features/DebugUI'), { ssr: false }) as FC; +} export interface LayoutProps { children?: ReactNode; defaultAppearance?: ThemeAppearance; + defaultPrimaryColor?: PrimaryColors; } -const useStyles = createStyles(({ css }) => ({ - content: css` - overflow-y: hidden; - display: flex; - flex-direction: column; - align-items: center; - - height: 100%; - `, -})); - const Layout = (props: LayoutProps) => { - const { children, defaultAppearance } = props; - const { styles } = useStyles(); - const themeMode = useThemeStore((s) => s.themeMode); - const [primaryColor] = useConfigStore((s) => [s.config.primaryColor]); + const { children } = props; + + const cookieStore = cookies(); + const primaryColor = cookieStore.get(VIDOL_THEME_PRIMARY_COLOR); + const neutralColor = cookieStore.get(VIDOL_THEME_NEUTRAL_COLOR); return ( - { - setCookie(VIDOL_THEME_APPEARANCE, appearance); - }} - themeMode={themeMode} - > - - -
{children}
-
+ + + + + {children} + + ); }; diff --git a/src/panels/PanelContainer.tsx b/src/panels/PanelContainer.tsx index 213c2726..123bc3c1 100644 --- a/src/panels/PanelContainer.tsx +++ b/src/panels/PanelContainer.tsx @@ -3,7 +3,7 @@ import React, { PropsWithChildren } from 'react'; import Panel from '@/components/Panel'; -import { configSelectors, useConfigStore } from '@/store/config'; +import { globalSelectors, useGlobalStore } from '@/store/global'; import { PanelKey } from '@/types/config'; import { PanelContext } from './PanelContext'; @@ -20,13 +20,13 @@ interface PanelContainerProps { const PanelContainer = (props: PropsWithChildren) => { const { style, className, panelKey, title, children, toolbar, extra, footer } = props; - const [panel, setPanel, focusPanel, closePanel] = useConfigStore((s) => [ + const [panel, setPanel, focusPanel, closePanel] = useGlobalStore((s) => [ s.panel, s.setPanel, s.focusPanel, s.closePanel, ]); - const zIndex = useConfigStore((s) => configSelectors.getPanelZIndex(s, panelKey)); + const zIndex = useGlobalStore((s) => globalSelectors.getPanelZIndex(s, panelKey)); return ( { - const config = configSelectors.currentOpenAIConfig(useConfigStore.getState()); + const config = configSelectors.currentOpenAIConfig(useSettingStore.getState()); return { 'Content-Type': 'application/json', [OPENAI_API_KEY]: config?.apikey || '', @@ -21,7 +21,7 @@ interface ChatCompletionPayload extends Partial { - const config = configSelectors.currentOpenAIConfig(useConfigStore.getState()); + const config = configSelectors.currentOpenAIConfig(useSettingStore.getState()); const { messages } = payload; const postMessages = messages.map((m) => ({ content: m.content, role: m.role })); diff --git a/src/store/config/index.ts b/src/store/config/index.ts deleted file mode 100644 index 357fd7d0..00000000 --- a/src/store/config/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { produce } from 'immer'; -import { isEqual, merge } from 'lodash-es'; -import { devtools, persist, subscribeWithSelector } from 'zustand/middleware'; -import { shallow } from 'zustand/shallow'; -import { createWithEqualityFn } from 'zustand/traditional'; -import { StateCreator } from 'zustand/vanilla'; - -import { Config, Panel, PanelKey } from '@/types/config'; - -import { ConfigState, initialState } from './initialState'; - -const CONFIG_STORAGE_KEY = 'vidol-chat-config-storage'; - -export interface ConfigAction { - /** - * Close panel - * @param key - */ - closePanel: (key: PanelKey) => void; - /** - * Focus panel - * @param key - */ - focusPanel: (key: PanelKey) => void; - /** - * Open panel - * @param key - */ - openPanel: (key: PanelKey) => void; - /** - * Reset config - */ - resetConfig: () => void; - /** - * Set config - * @param config - */ - setConfig: (config: Partial) => void; - /** - * Set OpenAI config - * @param config - */ - setOpenAIConfig: (config: Partial) => void; - /** - * Set panel config - * @param panel - * @param config - */ - setPanel: (panel: PanelKey, config: Partial) => void; -} - -export interface ConfigStore extends ConfigState, ConfigAction {} - -const createStore: StateCreator = (set, get) => ({ - ...initialState, - closePanel: (key: PanelKey) => { - const { setPanel, focusList } = get(); - setPanel(key, { open: false }); - const nextSetting = focusList.filter((item) => item !== key); - set({ focusList: nextSetting }); - }, - - focusPanel: (key: PanelKey) => { - const { focusList } = get(); - const nextSetting: PanelKey[] = focusList.filter((item) => item !== key).concat(key); - set({ focusList: nextSetting }); - }, - - openPanel: (key: PanelKey) => { - const { setPanel, focusPanel } = get(); - setPanel(key, { open: true }); - focusPanel(key); - }, - - resetConfig: () => { - localStorage.removeItem(CONFIG_STORAGE_KEY); - set({ ...initialState }); - }, - - setConfig: (config) => { - const prevSetting = get().config; - const nextSetting = produce(prevSetting, (draftState) => { - merge(draftState, config); - }); - if (isEqual(prevSetting, nextSetting)) return; - set({ config: nextSetting }); - }, - - setOpenAIConfig: (config) => { - get().setConfig({ languageModel: { openAI: config } }); - }, - setPanel: (panel, config) => { - const prevSetting = get().panel[panel]; - const nextSetting = produce(prevSetting, (draftState) => { - merge(draftState, config); - }); - - if (isEqual(prevSetting, nextSetting)) return; - set((state) => ({ - panel: { - ...state.panel, - [panel]: nextSetting, - }, - })); - }, -}); - -export const useConfigStore = createWithEqualityFn()( - subscribeWithSelector( - persist( - devtools(createStore, { - name: 'VIDOL_CONFIG_STORE', - }), - { name: CONFIG_STORAGE_KEY }, - ), - ), - shallow, -); - -export { configSelectors } from './selectors/config'; diff --git a/src/store/config/initialState.ts b/src/store/config/initialState.ts deleted file mode 100644 index deb2c39c..00000000 --- a/src/store/config/initialState.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DEFAULT_SETTINGS } from '@/constants/settings'; -import { INITIAL_COORDINATES } from '@/constants/token'; -import { Config, PanelConfig, PanelKey } from '@/types/config'; - -export interface ConfigState { - config: Config; - focusList: PanelKey[]; - panel: PanelConfig; -} - -const initialState: ConfigState = { - config: DEFAULT_SETTINGS, - focusList: [], - - panel: { - agent: { - coordinates: INITIAL_COORDINATES, - open: false, - }, - dance: { - coordinates: INITIAL_COORDINATES, - open: false, - }, - role: { - coordinates: INITIAL_COORDINATES, - open: false, - }, - market: { - coordinates: INITIAL_COORDINATES, - open: false, - }, - }, -}; - -export { initialState }; diff --git a/src/store/config/selectors/config.ts b/src/store/config/selectors/config.ts deleted file mode 100644 index 28d4c833..00000000 --- a/src/store/config/selectors/config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { INITIAL_Z_INDEX } from '@/constants/token'; -import { ConfigStore } from '@/store/config'; -import { OpenAIConfig, PanelKey } from '@/types/config'; - -const currentOpenAIConfig = (s: ConfigStore): OpenAIConfig | undefined => { - return s.config.languageModel.openAI; -}; - -const getPanelZIndex = (s: ConfigStore, panelKey: PanelKey) => { - const focusList = s.focusList; - const index = focusList.indexOf(panelKey); - return index === -1 ? INITIAL_Z_INDEX : INITIAL_Z_INDEX + index; -}; - -export const configSelectors = { - currentOpenAIConfig, - getPanelZIndex, -}; diff --git a/src/store/global.ts b/src/store/global.ts deleted file mode 100644 index a4c8ebca..00000000 --- a/src/store/global.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { shallow } from 'zustand/shallow'; -import { createWithEqualityFn } from 'zustand/traditional'; - -interface GlobalStore { - setChatDialog: (show: boolean) => void; - setChatSidebar: (show: boolean) => void; - setRoleList: (show: boolean) => void; - setSessionList: (show: boolean) => void; - showChatDialog: boolean; - showChatSidebar: boolean; - showRoleList: boolean; - showSessionList: boolean; - toggleChatDialog: () => void; - toggleChatSideBar: () => void; - toggleRoleList: () => void; - toggleSessionList: () => void; -} - -const initialState = { - showChatSidebar: true, - showSessionList: true, - showChatDialog: true, - showRoleList: true, -}; - -export const useGlobalStore = createWithEqualityFn()( - (set) => ({ - ...initialState, - setChatSidebar: (show) => { - set({ showChatSidebar: show }); - }, - setRoleList: (show) => { - set({ showRoleList: show }); - }, - toggleRoleList: () => { - set((state) => ({ showRoleList: !state.showRoleList })); - }, - toggleChatSideBar: () => { - set((state) => ({ showChatSidebar: !state.showChatSidebar })); - }, - setSessionList: (show) => { - set({ showSessionList: show }); - }, - toggleSessionList: () => { - set((state) => ({ showSessionList: !state.showSessionList })); - }, - - setChatDialog: (show) => { - set({ showChatDialog: show }); - }, - toggleChatDialog: () => { - set((state) => ({ showChatDialog: !state.showChatDialog })); - }, - }), - shallow, -); diff --git a/src/store/global/index.ts b/src/store/global/index.ts new file mode 100644 index 00000000..ba30b327 --- /dev/null +++ b/src/store/global/index.ts @@ -0,0 +1,153 @@ +import type { ThemeMode } from 'antd-style'; +import { produce } from 'immer'; +import { isEqual, merge } from 'lodash-es'; +import { shallow } from 'zustand/shallow'; +import { createWithEqualityFn } from 'zustand/traditional'; + +import { INITIAL_COORDINATES } from '@/constants/token'; +import { Panel, PanelConfig, PanelKey } from '@/types/config'; + +export * from './selectors/panel'; + +export interface GlobalStore { + /** + * Close panel + * @param key + */ + closePanel: (key: PanelKey) => void; + focusList: PanelKey[]; + /** + * Focus panel + * @param key + */ + focusPanel: (key: PanelKey) => void; + /** + * Open panel + * @param key + */ + openPanel: (key: PanelKey) => void; + panel: PanelConfig; + setChatDialog: (show: boolean) => void; + setChatSidebar: (show: boolean) => void; + /** + * Set panel config + * @param panel + * @param config + */ + setPanel: (panel: PanelKey, config: Partial) => void; + setRoleList: (show: boolean) => void; + setSessionList: (show: boolean) => void; + setThemeMode: (themeMode: ThemeMode) => void; + showChatDialog: boolean; + showChatSidebar: boolean; + showRoleList: boolean; + + showSessionList: boolean; + /** + * 主题模式 + */ + themeMode: ThemeMode; + toggleChatDialog: () => void; + toggleChatSideBar: () => void; + toggleRoleList: () => void; + toggleSessionList: () => void; +} + +const initialState = { + /** + * 主题模式 + */ + themeMode: 'auto' as ThemeMode, + showChatSidebar: true, + showSessionList: true, + showChatDialog: true, + showRoleList: true, + focusList: [], + panel: { + agent: { + coordinates: INITIAL_COORDINATES, + open: false, + }, + dance: { + coordinates: INITIAL_COORDINATES, + open: false, + }, + role: { + coordinates: INITIAL_COORDINATES, + open: false, + }, + market: { + coordinates: INITIAL_COORDINATES, + open: false, + }, + }, +}; + +export const useGlobalStore = createWithEqualityFn()( + (set, get) => ({ + ...initialState, + closePanel: (key: PanelKey) => { + const { setPanel, focusList } = get(); + setPanel(key, { open: false }); + const nextSetting = focusList.filter((item) => item !== key); + set({ focusList: nextSetting }); + }, + + setThemeMode: (themeMode) => { + set({ themeMode }); + }, + + focusPanel: (key: PanelKey) => { + const { focusList } = get(); + const nextSetting: PanelKey[] = focusList.filter((item) => item !== key).concat(key); + set({ focusList: nextSetting }); + }, + + openPanel: (key: PanelKey) => { + const { setPanel, focusPanel } = get(); + setPanel(key, { open: true }); + focusPanel(key); + }, + setPanel: (panel, config) => { + const prevSetting = get().panel[panel]; + const nextSetting = produce(prevSetting, (draftState) => { + merge(draftState, config); + }); + + if (isEqual(prevSetting, nextSetting)) return; + set((state) => ({ + panel: { + ...state.panel, + [panel]: nextSetting, + }, + })); + }, + + setChatSidebar: (show) => { + set({ showChatSidebar: show }); + }, + setRoleList: (show) => { + set({ showRoleList: show }); + }, + toggleRoleList: () => { + set((state) => ({ showRoleList: !state.showRoleList })); + }, + toggleChatSideBar: () => { + set((state) => ({ showChatSidebar: !state.showChatSidebar })); + }, + setSessionList: (show) => { + set({ showSessionList: show }); + }, + toggleSessionList: () => { + set((state) => ({ showSessionList: !state.showSessionList })); + }, + + setChatDialog: (show) => { + set({ showChatDialog: show }); + }, + toggleChatDialog: () => { + set((state) => ({ showChatDialog: !state.showChatDialog })); + }, + }), + shallow, +); diff --git a/src/store/global/selectors/panel.ts b/src/store/global/selectors/panel.ts new file mode 100644 index 00000000..aa6fcd82 --- /dev/null +++ b/src/store/global/selectors/panel.ts @@ -0,0 +1,13 @@ +import { INITIAL_Z_INDEX } from '@/constants/token'; +import { GlobalStore } from '@/store/global'; +import { PanelKey } from '@/types/config'; + +const getPanelZIndex = (s: GlobalStore, panelKey: PanelKey) => { + const focusList = s.focusList; + const index = focusList.indexOf(panelKey); + return index === -1 ? INITIAL_Z_INDEX : INITIAL_Z_INDEX + index; +}; + +export const globalSelectors = { + getPanelZIndex, +}; diff --git a/src/store/session/selectors.ts b/src/store/session/selectors.ts index 79c82e53..8d59749b 100644 --- a/src/store/session/selectors.ts +++ b/src/store/session/selectors.ts @@ -1,7 +1,7 @@ import { LOBE_VIDOL_DEFAULT_AGENT_ID } from '@/constants/agent'; import { DEFAULT_USER_AVATAR } from '@/constants/common'; import { useAgentStore } from '@/store/agent'; -import { useConfigStore } from '@/store/config'; +import { useSettingStore } from '@/store/setting'; import { Agent } from '@/types/agent'; import { ChatMessage } from '@/types/chat'; import { Session } from '@/types/session'; @@ -63,8 +63,8 @@ const currentChats = (s: SessionStore): ChatMessage[] => { const { messages } = session; return messages?.map((message) => { - const userAvatar = useConfigStore.getState().config.avatar; - const userNickName = useConfigStore.getState().config.nickName; + const userAvatar = useSettingStore.getState().config.avatar; + const userNickName = useSettingStore.getState().config.nickName; return { ...message, meta: { diff --git a/src/store/setting/index.ts b/src/store/setting/index.ts new file mode 100644 index 00000000..75c7c44c --- /dev/null +++ b/src/store/setting/index.ts @@ -0,0 +1,109 @@ +import { NeutralColors, PrimaryColors } from '@lobehub/ui'; +import { produce } from 'immer'; +import { isEqual, merge } from 'lodash-es'; +import { devtools, persist, subscribeWithSelector } from 'zustand/middleware'; +import { shallow } from 'zustand/shallow'; +import { createWithEqualityFn } from 'zustand/traditional'; +import { StateCreator } from 'zustand/vanilla'; + +import { BackgroundEffect, Config, OpenAIConfig } from '@/types/config'; + +import { SettingState, initialState } from './initialState'; + +const SETTING_STORAGE_KEY = 'vidol-chat-config-storage'; + +export interface SettingAction { + /** + * Reset config + */ + resetConfig: () => void; + /** + * Set avatar + * @param avatar + */ + setAvatar: (avatar: string) => void; + /** + * Set background effect + * @param backgroundEffect + */ + setBackgroundEffect: (backgroundEffect: BackgroundEffect) => void; + /** + * Set config + * @param config + */ + setConfig: (config: Partial) => void; + /** + * Set neutral color + * @param neutralColor + */ + setNeutralColor: (neutralColor: NeutralColors) => void; + /** + * Set nick name + * @param nickName + */ + setNickName: (nickName: string) => void; + + /** + * Set OpenAI config + * @param config + */ + setOpenAIConfig: (config: Partial) => void; + + /** + * Set primary color + * @param primaryColor + */ + setPrimaryColor: (primaryColor: PrimaryColors) => void; +} + +export interface SettingStore extends SettingState, SettingAction {} + +const createStore: StateCreator = (set, get) => ({ + ...initialState, + + resetConfig: () => { + localStorage.removeItem(SETTING_STORAGE_KEY); + set({ ...initialState }); + }, + setAvatar: (avatar) => { + get().setConfig({ avatar }); + }, + setPrimaryColor: (primaryColor) => { + get().setConfig({ primaryColor }); + }, + setNeutralColor: (neutralColor) => { + get().setConfig({ neutralColor }); + }, + setBackgroundEffect: (backgroundEffect) => { + get().setConfig({ backgroundEffect }); + }, + setNickName: (nickName) => { + get().setConfig({ nickName }); + }, + setConfig: (config) => { + const prevSetting = get().config; + const nextSetting = produce(prevSetting, (draftState) => { + merge(draftState, config); + }); + if (isEqual(prevSetting, nextSetting)) return; + set({ config: nextSetting }); + }, + + setOpenAIConfig: (config) => { + get().setConfig({ languageModel: { openAI: config } }); + }, +}); + +export const useSettingStore = createWithEqualityFn()( + subscribeWithSelector( + persist( + devtools(createStore, { + name: 'VIDOL_CONFIG_STORE', + }), + { name: SETTING_STORAGE_KEY }, + ), + ), + shallow, +); + +export { configSelectors } from './selectors/config'; diff --git a/src/store/setting/initialState.ts b/src/store/setting/initialState.ts new file mode 100644 index 00000000..dab69b2b --- /dev/null +++ b/src/store/setting/initialState.ts @@ -0,0 +1,20 @@ +import { Config } from '@/types/config'; + +export interface SettingState { + config: Config; +} + +const initialState: SettingState = { + config: { + backgroundEffect: 'glow', + languageModel: { + openAI: { + apikey: '', + endpoint: '', + model: 'gpt-3.5-turbo', + }, + }, + }, +}; + +export { initialState }; diff --git a/src/store/setting/selectors/config.ts b/src/store/setting/selectors/config.ts new file mode 100644 index 00000000..65fcbd4b --- /dev/null +++ b/src/store/setting/selectors/config.ts @@ -0,0 +1,10 @@ +import { SettingStore } from '@/store/setting'; +import { OpenAIConfig } from '@/types/config'; + +const currentOpenAIConfig = (s: SettingStore): OpenAIConfig | undefined => { + return s.config.languageModel.openAI; +}; + +export const configSelectors = { + currentOpenAIConfig, +}; diff --git a/src/store/theme.ts b/src/store/theme.ts deleted file mode 100644 index 6f6222e1..00000000 --- a/src/store/theme.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ThemeMode } from 'antd-style'; -import { shallow } from 'zustand/shallow'; -import { createWithEqualityFn } from 'zustand/traditional'; - -interface ThemeStore { - setThemeMode: (themeMode: ThemeMode) => void; - themeMode: ThemeMode; -} - -export const useThemeStore = createWithEqualityFn()( - (set) => ({ - setThemeMode: (themeMode: ThemeMode) => set({ themeMode }), - themeMode: 'auto' as ThemeMode, - }), - shallow, -); diff --git a/src/types/config.ts b/src/types/config.ts index 98a63cef..9e09cfae 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,5 +1,5 @@ import { Coordinates } from '@dnd-kit/utilities'; -import { PrimaryColors } from '@lobehub/ui'; +import { NeutralColors, PrimaryColors } from '@lobehub/ui'; export type BackgroundEffect = 'glow' | 'none'; @@ -23,35 +23,42 @@ export interface PanelConfig { export type PanelKey = keyof PanelConfig; +export interface OpenAIConfig { + apikey?: string; + endpoint?: string; + model?: string; +} + +export interface LanguageModelConfig { + openAI: OpenAIConfig; +} + +export interface Config extends CommonConfig { + /** + * 语言模型配置 + */ + languageModel: LanguageModelConfig; +} + export interface CommonConfig { /** * 用户头像 */ - avatar: string; + avatar?: string; /** * 背景类型 */ - backgroundEffect: BackgroundEffect; + backgroundEffect?: BackgroundEffect; + /** + * 中性色 + */ + neutralColor?: NeutralColors; /** * 用户昵称 */ - nickName: string; + nickName?: string; /** * 主题色 */ - primaryColor: PrimaryColors; -} - -export interface OpenAIConfig { - apikey?: string; - endpoint?: string; - model?: string; -} - -export interface LanguageModelConfig { - openAI: OpenAIConfig; -} - -export interface Config extends CommonConfig { - languageModel: LanguageModelConfig; + primaryColor?: PrimaryColors; }