diff --git a/package.json b/package.json index 710ec8c0..7f7d9c10 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "@emotion/styled": "^11.11.5", "@fontsource/inter": "^5.0.18", "@reduxjs/toolkit": "^2.2.5", + "ahooks": "^3.8.0", "antd": "^5.18.3", + "browser-image-compression": "^2.0.2", "dayjs": "^1.11.11", "electron-updater": "^6.1.7", "electron-window-state": "^5.0.3", diff --git a/src/renderer/index.html b/src/renderer/index.html index f0f81e5b..79267a30 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -7,7 +7,7 @@ + content="default-src 'self'; connect-src *; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' *; img-src 'self' data:" /> diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index 127d71fb..cb187c1c 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -2,16 +2,18 @@ import { FC } from 'react' import Logo from '@renderer/assets/images/logo.png' import styled from 'styled-components' import { Link, useLocation } from 'react-router-dom' +import useAvatar from '@renderer/hooks/useAvatar' const Sidebar: FC = () => { const { pathname } = useLocation() + const avatar = useAvatar() const isRoute = (path: string): string => (pathname === path ? 'active' : '') return ( - + diff --git a/src/renderer/src/hooks/useAppInitEffect.ts b/src/renderer/src/hooks/useAppInitEffect.ts new file mode 100644 index 00000000..92495bc9 --- /dev/null +++ b/src/renderer/src/hooks/useAppInitEffect.ts @@ -0,0 +1,20 @@ +import LocalStorage from '@renderer/services/storage' +import { useAppDispatch } from '@renderer/store' +import { setAvatar } from '@renderer/store/runtime' +import { runAsyncFunction } from '@renderer/utils' +import { useEffect } from 'react' + +export function useAppInitEffect() { + const dispatch = useAppDispatch() + + useEffect(() => { + runAsyncFunction(async () => { + try { + const storedImage = await LocalStorage.getImage('avatar') + storedImage && dispatch(setAvatar(storedImage)) + } catch (error) { + console.error('Error retrieving the image', error) + } + }) + }, [dispatch]) +} diff --git a/src/renderer/src/hooks/useAvatar.ts b/src/renderer/src/hooks/useAvatar.ts new file mode 100644 index 00000000..0c57d157 --- /dev/null +++ b/src/renderer/src/hooks/useAvatar.ts @@ -0,0 +1,5 @@ +import { useAppSelector } from '@renderer/store' + +export default function useAvatar() { + return useAppSelector((state) => state.runtime.avatar) +} diff --git a/src/renderer/src/pages/home/components/Message.tsx b/src/renderer/src/pages/home/components/Message.tsx index 8f905db5..e4b000ba 100644 --- a/src/renderer/src/pages/home/components/Message.tsx +++ b/src/renderer/src/pages/home/components/Message.tsx @@ -4,13 +4,14 @@ import { marked } from 'marked' import { FC } from 'react' import styled from 'styled-components' import Logo from '@renderer/assets/images/logo.png' +import useAvatar from '@renderer/hooks/useAvatar' const MessageItem: FC<{ message: Message }> = ({ message }) => { + const avatar = useAvatar() + return ( - - {message.role === 'assistant' ? : Y} - + {message.role === 'assistant' ? : }
) diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index 7a2bcac9..454d475c 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -1,11 +1,59 @@ import { FC } from 'react' +import { + SettingContainer, + SettingDivider, + SettingRow, + SettingRowTitle, + SettingTitle +} from './components/SettingComponent' +import { Avatar, message, Upload } from 'antd' import styled from 'styled-components' +import LocalStorage from '@renderer/services/storage' +import { compressImage } from '@renderer/utils' +import useAvatar from '@renderer/hooks/useAvatar' +import { useAppDispatch } from '@renderer/store' +import { setAvatar } from '@renderer/store/runtime' const GeneralSettings: FC = () => { - return General Settings + const avatar = useAvatar() + const [messageApi, contextHolder] = message.useMessage() + const dispatch = useAppDispatch() + + return ( + + {contextHolder} + General Settings + + + Avatar + {}} + accept="image/png, image/jpeg" + itemRender={() => null} + maxCount={1} + onChange={async ({ file }) => { + try { + const _file = file.originFileObj as File + const compressedFile = await compressImage(_file) + await LocalStorage.storeImage('avatar', compressedFile) + dispatch(setAvatar(await LocalStorage.getImage('avatar'))) + } catch (error: any) { + messageApi.open({ + type: 'error', + content: error.message + }) + } + }}> + + + + + + ) } -const Container = styled.div` - padding: 20px; +const UserAvatar = styled(Avatar)` + cursor: pointer; ` + export default GeneralSettings diff --git a/src/renderer/src/pages/settings/components/SettingComponent.tsx b/src/renderer/src/pages/settings/components/SettingComponent.tsx index 5fcf6fe5..f58779e8 100644 --- a/src/renderer/src/pages/settings/components/SettingComponent.tsx +++ b/src/renderer/src/pages/settings/components/SettingComponent.tsx @@ -30,3 +30,15 @@ export const SettingSubtitle = styled.div` export const SettingDivider = styled(Divider)` margin: 10px 0; ` + +export const SettingRow = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +` + +export const SettingRowTitle = styled.div` + font-size: 14px; + color: var(--color-text-1); +` diff --git a/src/renderer/src/services/storage.ts b/src/renderer/src/services/storage.ts index 40c3a5c3..8ab09b48 100644 --- a/src/renderer/src/services/storage.ts +++ b/src/renderer/src/services/storage.ts @@ -1,6 +1,9 @@ import { Topic } from '@renderer/types' +import { convertToBase64 } from '@renderer/utils' import localforage from 'localforage' +const IMAGE_PREFIX = 'image://' + export default class LocalStorage { static async getTopic(id: string) { return localforage.getItem(`topic:${id}`) @@ -21,4 +24,23 @@ export default class LocalStorage { await localforage.setItem(`topic:${id}`, topic) } } + + static async storeImage(name: string, file: File) { + try { + const base64Image = await convertToBase64(file) + if (typeof base64Image === 'string') { + await localforage.setItem(IMAGE_PREFIX + name, base64Image) + } + } catch (error) { + console.error('Error storing the image', error) + } + } + + static async getImage(name: string) { + return localforage.getItem(IMAGE_PREFIX + name) + } + + static async removeImage(name: string) { + await localforage.removeItem(IMAGE_PREFIX + name) + } } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index f75c5966..75017643 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -5,18 +5,21 @@ import storage from 'redux-persist/lib/storage' import assistants from './assistants' import settings from './settings' import llm from './llm' +import runtime from './runtime' const rootReducer = combineReducers({ assistants, settings, - llm + llm, + runtime }) const persistedReducer = persistReducer( { key: 'cherry-ai', storage, - version: 1 + version: 1, + whitelist: ['runtime'] }, rootReducer ) diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts new file mode 100644 index 00000000..999fed17 --- /dev/null +++ b/src/renderer/src/store/runtime.ts @@ -0,0 +1,24 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import Logo from '@renderer/assets/images/logo.png' + +export interface RuntimeState { + avatar: string +} + +const initialState: RuntimeState = { + avatar: Logo +} + +const runtimeSlice = createSlice({ + name: 'settings', + initialState, + reducers: { + setAvatar: (state, action: PayloadAction) => { + state.avatar = action.payload || Logo + } + } +}) + +export const { setAvatar } = runtimeSlice.actions + +export default runtimeSlice.reducer diff --git a/src/renderer/src/utils/index.ts b/src/renderer/src/utils/index.ts index 1dd3bd77..0aebc309 100644 --- a/src/renderer/src/utils/index.ts +++ b/src/renderer/src/utils/index.ts @@ -1,4 +1,5 @@ import { v4 as uuidv4 } from 'uuid' +import imageCompression from 'browser-image-compression' export const runAsyncFunction = async (fn: () => void) => { await fn() @@ -48,3 +49,20 @@ export const waitAsyncFunction = (fn: () => Promise, interval = 200, stopTi } export const uuid = () => uuidv4() + +export const convertToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => resolve(reader.result) + reader.onerror = reject + reader.readAsDataURL(file) + }) +} + +export const compressImage = async (file: File) => { + return await imageCompression(file, { + maxSizeMB: 1, + maxWidthOrHeight: 300, + useWebWorker: false + }) +} diff --git a/yarn.lock b/yarn.lock index bc6168be..b244071d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1424,6 +1424,21 @@ agentkeepalive@^4.2.1: dependencies: humanize-ms "^1.2.1" +ahooks@^3.8.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/ahooks/-/ahooks-3.8.0.tgz#62476bf3459862ff706de2189b87de5e4f49b298" + integrity sha512-M01m+mxLRNNeJ/PCT3Fom26UyreTj6oMqJBetUrJnK4VNI5j6eMA543Xxo53OBXn6XibA2FXKcCCgrT6YCTtKQ== + dependencies: + "@babel/runtime" "^7.21.0" + dayjs "^1.9.1" + intersection-observer "^0.12.0" + js-cookie "^2.x.x" + lodash "^4.17.21" + react-fast-compare "^3.2.2" + resize-observer-polyfill "^1.5.1" + screenfull "^5.0.0" + tslib "^2.4.1" + ajv-keywords@^3.4.1: version "3.5.2" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" @@ -1770,6 +1785,13 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" +browser-image-compression@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/browser-image-compression/-/browser-image-compression-2.0.2.tgz#4d5ef8882e9e471d6d923715ceb9034499d14eaa" + integrity sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw== + dependencies: + uzip "0.20201231.0" + browserslist@^4.22.2: version "4.23.0" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.0.tgz#8f3acc2bbe73af7213399430890f86c63a5674ab" @@ -2125,7 +2147,7 @@ data-view-byte-offset@^1.0.0: es-errors "^1.3.0" is-data-view "^1.0.1" -dayjs@^1.11.11: +dayjs@^1.11.11, dayjs@^1.9.1: version "1.11.11" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.11.tgz#dfe0e9d54c5f8b68ccf8ca5f72ac603e7e5ed59e" integrity sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg== @@ -3271,6 +3293,11 @@ internal-slot@^1.0.7: hasown "^2.0.0" side-channel "^1.0.4" +intersection-observer@^0.12.0: + version "0.12.2" + resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.12.2.tgz#4a45349cc0cd91916682b1f44c28d7ec737dc375" + integrity sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg== + is-array-buffer@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98" @@ -3520,6 +3547,11 @@ jake@^10.8.5: filelist "^1.0.4" minimatch "^3.1.2" +js-cookie@^2.x.x: + version "2.2.1" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" + integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -4543,6 +4575,11 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.2" +react-fast-compare@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" + integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -4831,6 +4868,11 @@ scheduler@^0.23.2: dependencies: loose-envify "^1.1.0" +screenfull@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.2.0.tgz#6533d524d30621fc1283b9692146f3f13a93d1ba" + integrity sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA== + scroll-into-view-if-needed@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz#fa9524518c799b45a2ef6bbffb92bcad0296d01f" @@ -5234,7 +5276,7 @@ tslib@2.6.2, tslib@^2.6.2: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== -tslib@^2.1.0: +tslib@^2.1.0, tslib@^2.4.1: version "2.6.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== @@ -5374,6 +5416,11 @@ uuid@^10.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== +uzip@0.20201231.0: + version "0.20201231.0" + resolved "https://registry.yarnpkg.com/uzip/-/uzip-0.20201231.0.tgz#9e64b065b9a8ebf26eb7583fe8e77e1d9a15ed14" + integrity sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng== + verror@^1.10.0: version "1.10.1" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.1.tgz#4bf09eeccf4563b109ed4b3d458380c972b0cdeb"