From 99c4f0bb0472b662ad167e7296085e1ad37acb2f Mon Sep 17 00:00:00 2001 From: d_pelekhatyi Date: Tue, 7 May 2024 14:14:50 +0300 Subject: [PATCH 1/2] characterStore and db, basic character page --- .../character/CharacterDetailsPage.tsx | 39 +++++ .../character/CharacterListPage.tsx | 25 +++ .../character/CharacterListProvider.ts | 0 src/components/character/NameComponent.tsx | 36 ++++ src/components/settings.tsx | 160 +++++++----------- src/components/settings/NamePage.tsx | 50 ------ ...CharacterModelPage.tsx => VrmListPage.tsx} | 36 ++-- src/features/characters/character.ts | 30 ++++ src/features/characters/characterStore.ts | 116 +++++++++++++ .../characters/characterStoreContext.tsx | 151 +++++++++++++++++ .../characters/characterStoreReducer.ts | 93 ++++++++++ .../characters/db/characterDataProvider.ts | 38 +++++ .../characters/db/characterDbModel.ts | 21 +++ src/features/indexedDb/amicaDb.ts | 18 ++ .../vrmStore/{ => db}/vrmDataProvider.ts | 10 +- src/features/vrmStore/{ => db}/vrmDbModel.ts | 0 src/features/vrmStore/vrmDb.ts | 15 -- src/features/vrmStore/vrmStoreContext.tsx | 11 +- src/features/vrmStore/vrmStoreReducer.ts | 19 ++- src/pages/import/[sqid]/index.tsx | 2 +- src/pages/index.tsx | 103 +++++++++-- src/pages/share.tsx | 4 +- src/utils/config.ts | 2 + 23 files changed, 768 insertions(+), 211 deletions(-) create mode 100644 src/components/character/CharacterDetailsPage.tsx create mode 100644 src/components/character/CharacterListPage.tsx create mode 100644 src/components/character/CharacterListProvider.ts create mode 100644 src/components/character/NameComponent.tsx delete mode 100644 src/components/settings/NamePage.tsx rename src/components/settings/{CharacterModelPage.tsx => VrmListPage.tsx} (74%) create mode 100644 src/features/characters/character.ts create mode 100644 src/features/characters/characterStore.ts create mode 100644 src/features/characters/characterStoreContext.tsx create mode 100644 src/features/characters/characterStoreReducer.ts create mode 100644 src/features/characters/db/characterDataProvider.ts create mode 100644 src/features/characters/db/characterDbModel.ts create mode 100644 src/features/indexedDb/amicaDb.ts rename src/features/vrmStore/{ => db}/vrmDataProvider.ts (83%) rename src/features/vrmStore/{ => db}/vrmDbModel.ts (100%) delete mode 100644 src/features/vrmStore/vrmDb.ts diff --git a/src/components/character/CharacterDetailsPage.tsx b/src/components/character/CharacterDetailsPage.tsx new file mode 100644 index 00000000..b1202758 --- /dev/null +++ b/src/components/character/CharacterDetailsPage.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from "react-i18next"; +import { BasicPage } from "../settings/common"; +import { TextButton } from "../textButton"; +import { useCharacterStoreContext } from "@/features/characters/characterStoreContext"; +import { NameComponent } from "./NameComponent"; + +export function CharacterDetailsPage({ + setSettingsUpdated, + handleClickOpenVrmFile, + }: { + setSettingsUpdated: (updated: boolean) => void; + handleClickOpenVrmFile: () => void; + }) { + const { t } = useTranslation(); + const characterContext = useCharacterStoreContext(); + + return ( + +
+
    +
  • + +
  • +
+
+ { characterContext.saveCharacter(); }} + > + {t("Save Character")} + +
+ + ); +} + diff --git a/src/components/character/CharacterListPage.tsx b/src/components/character/CharacterListPage.tsx new file mode 100644 index 00000000..82eced51 --- /dev/null +++ b/src/components/character/CharacterListPage.tsx @@ -0,0 +1,25 @@ +import { useCharacterStoreContext } from "@/features/characters/characterStoreContext"; +import { VrmListPage } from "../settings/VrmListPage"; +import Character from "@/features/characters/character"; + +export const CharacterListPage = ({ + viewer, + setSettingsUpdated, + handleClickOpenVrmFile, + }: { + viewer: any; // TODO + setSettingsUpdated: (updated: boolean) => void; + handleClickOpenVrmFile: () => void; + }) => { + const characterStoreContext = useCharacterStoreContext(); + + return +} \ No newline at end of file diff --git a/src/components/character/CharacterListProvider.ts b/src/components/character/CharacterListProvider.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/components/character/NameComponent.tsx b/src/components/character/NameComponent.tsx new file mode 100644 index 00000000..71d9055f --- /dev/null +++ b/src/components/character/NameComponent.tsx @@ -0,0 +1,36 @@ +import { t } from "@/i18n"; +import { FormRow, ResetToDefaultButton } from "../settings/common"; +import { TextInput } from "../textInput"; +import { defaultConfig, updateConfig } from "@/utils/config"; + +interface NameComponentProps { + name: string + setName: (name: string) => void + setSettingsUpdated: (updated: boolean) => void +}; + +export const NameComponent: React.FC = ({name, setName, setSettingsUpdated}) => { + return <> + + ) => { + setName(event.target.value); + updateConfig("name", event.target.value); + setSettingsUpdated(true); + }} + /> + + { name !== defaultConfig("name") && ( +

+ { + setName(defaultConfig("name")); + updateConfig("name", defaultConfig("name")); + setSettingsUpdated(true); + }} + /> +

+ )} +
+ ; +} \ No newline at end of file diff --git a/src/components/settings.tsx b/src/components/settings.tsx index 08f42ed2..67555438 100644 --- a/src/components/settings.tsx +++ b/src/components/settings.tsx @@ -31,7 +31,7 @@ import { CommunityPage } from './settings/CommunityPage'; import { BackgroundImgPage } from './settings/BackgroundImgPage'; import { BackgroundColorPage } from './settings/BackgroundColorPage'; import { BackgroundVideoPage } from './settings/BackgroundVideoPage'; -import { CharacterModelPage } from './settings/CharacterModelPage'; +import { CharacterListPage } from './character/CharacterListPage'; import { CharacterAnimationPage } from './settings/CharacterAnimationPage'; import { ChatbotBackendPage } from './settings/ChatbotBackendPage'; @@ -64,8 +64,18 @@ import { SystemPromptPage } from './settings/SystemPromptPage'; import { useVrmStoreContext } from "@/features/vrmStore/vrmStoreContext"; export const Settings = ({ + showNotification, + setShowNotification, + setSettingsUpdated, + showSettingsUpdatedNotification, + handleClickOpenVrmFile, onClickClose, }: { + showNotification: boolean; + setShowNotification: (updated: boolean) => void; + setSettingsUpdated: (updated: boolean) => void; + showSettingsUpdatedNotification: () => () => void; + handleClickOpenVrmFile: () => void; onClickClose: () => void; }) => { const { viewer } = useContext(ViewerContext); @@ -74,8 +84,6 @@ export const Settings = ({ const [page, setPage] = useState('main_menu'); const [breadcrumbs, setBreadcrumbs] = useState([]); - const [showNotification, setShowNotification] = useState(false); - const [settingsUpdated, setSettingsUpdated] = useState(false); const [chatbotBackend, setChatbotBackend] = useState(config("chatbot_backend")); const [openAIApiKey, setOpenAIApiKey] = useState(config("openai_apikey")); @@ -109,14 +117,6 @@ export const Settings = ({ const [visionOllamaModel, setVisionOllamaModel] = useState(config("vision_ollama_model")); const [visionSystemPrompt, setVisionSystemPrompt] = useState(config("vision_system_prompt")); - const [bgUrl, setBgUrl] = useState(config("bg_url")); - const [bgColor, setBgColor] = useState(config("bg_color")); - const [vrmUrl, setVrmUrl] = useState(config("vrm_url")); - const [vrmHash, setVrmHash] = useState(config("vrm_hash")); - const [vrmSaveType, setVrmSaveType] = useState(config('vrm_save_type')); - const [youtubeVideoID, setYoutubeVideoID] = useState(config("youtube_videoid")); - const [animationUrl, setAnimationUrl] = useState(config("animation_url")); - const [sttBackend, setSTTBackend] = useState(config("stt_backend")); const [sttWakeWordEnabled, setSTTWakeWordEnabled] = useState(config("wake_word_enabled") === 'true' ? true : false); const [sttWakeWord, setSTTWakeWord] = useState(config("wake_word")); @@ -127,15 +127,8 @@ export const Settings = ({ const [whisperOpenAIModel, setWhisperOpenAIModel] = useState(config("openai_whisper_model")); const [whisperCppUrl, setWhisperCppUrl] = useState(config("whispercpp_url")); - const [name, setName] = useState(config("name")); const [systemPrompt, setSystemPrompt] = useState(config("system_prompt")); - - const vrmFileInputRef = useRef(null); - const handleClickOpenVrmFile = useCallback(() => { - vrmFileInputRef.current?.click(); - }, []); - const bgImgFileInputRef = useRef(null); const handleClickOpenBgImgFile = useCallback(() => { bgImgFileInputRef.current?.click(); @@ -195,15 +188,7 @@ export const Settings = ({ } useEffect(() => { - const timeOutId = setTimeout(() => { - if (settingsUpdated) { - setShowNotification(true); - setTimeout(() => { - setShowNotification(false); - }, 5000); - } - }, 1000); - return () => clearTimeout(timeOutId); + showSettingsUpdatedNotification(); }, [ chatbotBackend, openAIApiKey, openAIUrl, openAIModel, @@ -220,8 +205,6 @@ export const Settings = ({ visionLlamaCppUrl, visionOllamaUrl, visionOllamaModel, visionSystemPrompt, - bgColor, - bgUrl, vrmHash, vrmUrl, youtubeVideoID, animationUrl, sttBackend, whisperOpenAIApiKey, whisperOpenAIModel, whisperOpenAIUrl, whisperCppUrl, @@ -240,17 +223,17 @@ export const Settings = ({ switch(page) { case 'main_menu': return ; - case 'appearance': - return ; + // case 'appearance': + // return ; case 'chatbot': return ; case 'tts': @@ -274,49 +257,42 @@ export const Settings = ({ case 'community': return - case 'background_img': - return - - case 'background_color': - return - - case 'background_video': - return ; - - case 'character_model': - return - - case 'character_animation': - return + // case 'background_img': + // return + + // case 'background_color': + // return + + // case 'background_video': + // return ; + + // case 'character_list': + // return + + // case 'character_animation': + // return case 'chatbot_backend': return - case 'system_prompt': - return - - case 'name': - return - + // case 'system_prompt': + // return + default: throw new Error('page not found'); } @@ -639,13 +608,6 @@ export const Settings = ({ - void; - setSettingsUpdated: (updated: boolean) => void; -}) { - const { t } = useTranslation(); - - return ( - -
    -
  • - - ) => { - setName(event.target.value); - updateConfig("name", event.target.value); - setSettingsUpdated(true); - }} - /> - - { name !== defaultConfig("name") && ( -

    - { - setName(defaultConfig("name")); - updateConfig("name", defaultConfig("name")); - setSettingsUpdated(true); - }} - /> -

    - )} -
    -
  • -
-
- ); -} diff --git a/src/components/settings/CharacterModelPage.tsx b/src/components/settings/VrmListPage.tsx similarity index 74% rename from src/components/settings/CharacterModelPage.tsx rename to src/components/settings/VrmListPage.tsx index 3d48a19f..ea097c99 100644 --- a/src/components/settings/CharacterModelPage.tsx +++ b/src/components/settings/VrmListPage.tsx @@ -3,32 +3,22 @@ import { clsx } from "clsx"; import { BasicPage } from "./common"; import { updateConfig } from "@/utils/config"; import { TextButton } from "@/components/textButton"; -import { VrmData } from '@/features/vrmStore/vrmData'; +import { useVrmStoreContext } from '@/features/vrmStore/vrmStoreContext'; +import { useCharacterStoreContext } from '@/features/characters/characterStoreContext'; -export function CharacterModelPage({ +export function VrmListPage({ viewer, - vrmHash, - vrmUrl, - vrmSaveType, - vrmList, - setVrmHash, - setVrmUrl, - setVrmSaveType, setSettingsUpdated, handleClickOpenVrmFile, }: { viewer: any; // TODO - vrmHash: string; - vrmUrl: string; - vrmSaveType: string; - vrmList: VrmData[], - setVrmHash: (hash: string) => void; - setVrmUrl: (url: string) => void; - setVrmSaveType: (saveType: string) => void; setSettingsUpdated: (updated: boolean) => void; handleClickOpenVrmFile: () => void; }) { const { t } = useTranslation(); + const vrmStoreContext = useVrmStoreContext(); + const vrmList = vrmStoreContext.vrmList; + const characterContext = useCharacterStoreContext(); return ( { viewer.loadVrm(vrm.url); - setVrmSaveType(vrm.saveType); - updateConfig('vrm_save_type', vrm.saveType); + const hash = vrm.getHash(); + characterContext.setVrmSaveType(hash, vrm.saveType); if (vrm.saveType == 'local') { - updateConfig('vrm_hash', vrm.getHash()); + updateConfig('vrm_hash', hash); updateConfig('vrm_url', vrm.url); - setVrmUrl(vrm.url); - setVrmHash(vrm.getHash()); + characterContext.setVrmUrl(hash, vrm.url); + characterContext.setVrmHash(hash); } else { - updateConfig('vrm_hash', ''); + updateConfig('vrm_hash', vrm.url); updateConfig('vrm_url', vrm.url); - setVrmUrl(vrm.url); + characterContext.setVrmUrl(hash, vrm.url); } setSettingsUpdated(true); }} diff --git a/src/features/characters/character.ts b/src/features/characters/character.ts new file mode 100644 index 00000000..349fa3ac --- /dev/null +++ b/src/features/characters/character.ts @@ -0,0 +1,30 @@ +export default class Character { + constructor( + id: number = -1, + tag: string, + name: string = "", + vrmHash: string = "", + bgUrl: string = "", + bgColor: string = "", + youtubeVideoId: string = "", + animationUrl: string = "", + ) { + this.id = id; + this.tag = tag; + this.name = name; + this.vrmHash = vrmHash; + this.bgUrl = bgUrl; + this.bgColor = bgColor; + this.youtubeVideoId = youtubeVideoId; + this.animationUrl = animationUrl; + } + + public id: number; + public tag: string; + public name: string; + public vrmHash: string; + public bgUrl: string; + public bgColor: string; + public youtubeVideoId: string; + public animationUrl: string; +} diff --git a/src/features/characters/characterStore.ts b/src/features/characters/characterStore.ts new file mode 100644 index 00000000..c1ea961f --- /dev/null +++ b/src/features/characters/characterStore.ts @@ -0,0 +1,116 @@ +import { config, updateConfig } from '@/utils/config'; +import Character from './character'; +import { useState } from 'react'; + +interface CharacterStore { + characterId: number; + setCharacterId: (characterId: number) => void; + characterTag: string; + setCharacterTag: (characterTag: string) => void; + name: string; + setName: (name: string) => void; + vrmHash: string; + setVrmHash: (vrmHash: string) => void; + bgUrl: string; + setBgUrl: (bgUrl: string) => void; + bgColor: string; + setBgColor: (bgColor: string) => void; + youtubeVideoId: string; + setYoutubeVideoId: (youtubeVideoId: string) => void; + animationUrl: string; + setAnimationUrl: (animationUrl: string) => void; + characters: Character[]; + setCharacterList: (characters: Character[]) => void; + + initFromConfig: () => void; + getCharacter: () => Character; + hasUnsavedChanges: () => boolean; +} + +export const GetCharacterStore = (): CharacterStore => { + const [characters, setCharacters] = useState(new Array()); + const [characterId, setCharacterId] = useState(-1); + const [characterTag, setCharacterTag] = useState(''); + const [name, setName] = useState(''); + const [vrmHash, setVrmHash] = useState(''); + const [bgUrl, setBgUrl] = useState(''); + const [bgColor, setBgColor] = useState(''); + const [youtubeVideoId, setYoutubeVideoId] = useState(''); + const [animationUrl, setAnimationUrl] = useState(''); + + const initFromConfig = (): void => { + setCharacterId(parseInt(config('character_id'))); + setCharacterTag(config('character_tag')); + setName(config('name')); + setVrmHash(config('vrm_hash')); + setBgUrl(config('bg_url')); + setBgColor(config('bg_color')); + setYoutubeVideoId(config('youtube_videoid')); + setAnimationUrl(config('animation_url')); + } + + return { + characterId: characterId, + setCharacterId: (characterId: number): void => { + updateConfig('character_id', characterId.toString()); + }, + characterTag: characterTag, + setCharacterTag: (tag: string): void => { + setCharacterTag(tag); + updateConfig('character_tag', tag); + }, + name: name, + setName: (name: string): void => { + setName(name); + updateConfig('name', name); + }, + vrmHash: vrmHash, + setVrmHash: (vrmHash: string): void => { + setVrmHash(vrmHash); + updateConfig('vrm_hash', vrmHash); + }, + bgUrl: bgUrl, + setBgUrl: (bgUrl: string): void => { + setBgUrl(bgUrl); + updateConfig('bg_url', bgUrl); + }, + bgColor: bgColor, + setBgColor: (bgColor: string): void => { + setBgColor(bgColor); + updateConfig('bg_color', bgColor); + }, + youtubeVideoId: youtubeVideoId, + setYoutubeVideoId: (youtubeVideoId: string): void => { + setYoutubeVideoId(youtubeVideoId); + updateConfig('youtube_videoid', youtubeVideoId); + }, + animationUrl: animationUrl, + setAnimationUrl: (animationUrl: string): void => { + setAnimationUrl(animationUrl); + updateConfig('animation_url', animationUrl); + }, + characters: characters, + setCharacterList: (data: Character[]) => { + setCharacters(data); + }, + + initFromConfig: initFromConfig, + + getCharacter: (): Character => { + return new Character(characterId, characterTag, name, vrmHash, bgUrl, bgColor, youtubeVideoId, animationUrl); + }, + + hasUnsavedChanges: (): boolean => { + if (characterId < 0) + return true; + const charIndex = characters.findIndex((character: Character) => { return character.id == characterId; }); + return characterTag !== characters[charIndex].tag || + name !== characters[charIndex].name || + vrmHash !== characters[charIndex].vrmHash || + bgUrl !== characters[charIndex].bgUrl || + bgColor !== characters[charIndex].bgColor || + youtubeVideoId !== characters[charIndex].youtubeVideoId || + animationUrl !== characters[charIndex].animationUrl; + } + } +} \ No newline at end of file diff --git a/src/features/characters/characterStoreContext.tsx b/src/features/characters/characterStoreContext.tsx new file mode 100644 index 00000000..9bd5ee9c --- /dev/null +++ b/src/features/characters/characterStoreContext.tsx @@ -0,0 +1,151 @@ +import { PropsWithChildren, createContext, useContext, useEffect, useReducer, useState } from "react"; +import { GetCharacterStore } from "./characterStore"; +import { CharacterStoreActionType, characterStoreReducer } from "./characterStoreReducer"; +import Character from "./character"; + +interface CharacterStoreContextType { + saveCharacter: () => Promise; + + characterId: number; + characterTag: string; + name: string; + vrmHash: string; + bgUrl: string; + bgColor: string; + youtubeVideoId: string; + animationUrl: string; + + setCharacterTag: (characterTag: string) => void; + setName: (name: string) => void; + setVrmHash: (vrmHash: string) => void; + setBgUrl: (bgUrl: string) => void; + setBgColor: (bgColor: string) => void; + setYoutubeVideoId: (youtubeVideoId: string) => void; + setAnimationUrl: (animationUrl: string) => void; + + characterList: Character[]; + hasUnsavedChanges: () => boolean; + + isLoadingCharactersList: boolean +} + +const CharacterStoreContext = createContext({ + saveCharacter: (): Promise => { return new Promise(() => {}) }, + + characterId: -1, + characterTag: '', + name: '', + vrmHash: '', + bgUrl: '', + bgColor: '', + youtubeVideoId: '', + animationUrl: '', + + setCharacterTag: () => {}, + setName: () => {}, + setVrmHash: () => {}, + setBgUrl: () => {}, + setBgColor: () => {}, + setYoutubeVideoId: () => {}, + setAnimationUrl: () => {}, + + characterList: new Array(), + hasUnsavedChanges: (): boolean => { return false; }, + + isLoadingCharactersList: false +}); + +export const CharacterStoreContextProvider = ({ children }: PropsWithChildren<{}>): JSX.Element => { + const [loadedCharactersList, charactersListDispatch] = useReducer(characterStoreReducer, new Array()); + const [isLoadingCharactersList, setIsLoadingCharactersList] = useState(true); + + const updateCharacterListWithCharacter = (character: Character) => { + const characterList = new Array(...loadedCharactersList); + const index = characterList.findIndex((characterInList: Character) => { characterInList.id == character.id }); + const isInList = index !== -1; + if (isInList) { + characterList[index] = character; + } else { + characterList.push(character); + } + charactersListDispatch({ type: CharacterStoreActionType.setState, characters: characterList }); + }; + + const characterStore = GetCharacterStore(); + + const characterStoreContext: CharacterStoreContextType = { + characterId: characterStore.characterId, + characterTag: characterStore.characterTag, + name: characterStore.name, + vrmHash: characterStore.vrmHash, + bgUrl: characterStore.bgUrl, + bgColor: characterStore.bgColor, + youtubeVideoId: characterStore.youtubeVideoId, + animationUrl: characterStore.animationUrl, + + setCharacterTag: characterStore.setCharacterTag, + setName: characterStore.setName, + setVrmHash: characterStore.setVrmHash, + setBgUrl: characterStore.setBgUrl, + setBgColor: characterStore.setBgColor, + setYoutubeVideoId: characterStore.setYoutubeVideoId, + setAnimationUrl: characterStore.setAnimationUrl, + + saveCharacter: async (): Promise => { + return new Promise( (resolve, reject) => { + if (loadedCharactersList.findIndex((loaded: Character) => { loaded.id == characterStore.characterId }) > 0) + charactersListDispatch({ type: CharacterStoreActionType.update, + character: characterStore.getCharacter(), + callback: (character: Character | undefined) => { + if (character) { + updateCharacterListWithCharacter(character); + resolve(character); + } else { + reject("Update error."); + } + } + }); + else + charactersListDispatch({ type: CharacterStoreActionType.create, + character: characterStore.getCharacter(), + callback: (character: Character) => { updateCharacterListWithCharacter(character); resolve(character); } + }); + }); + }, + + characterList: loadedCharactersList, + hasUnsavedChanges: characterStore.hasUnsavedChanges, + + isLoadingCharactersList: isLoadingCharactersList + }; + + useEffect(() => { + console.log("Init characters store"); + charactersListDispatch({ + type: CharacterStoreActionType.loadCharacters, + callback: (data: Character[]) => { charactersListDispatch({ type: CharacterStoreActionType.setState, characters: data}) } + }); + characterStore.initFromConfig(); + }, []); + + useEffect(() => { + characterStore.setCharacterList(loadedCharactersList); + setIsLoadingCharactersList(false); + }, [loadedCharactersList]); + + return ( + + {children} + + ); +}; + +export const useCharacterStoreContext = () => { + const context = useContext(CharacterStoreContext); + + if (!context) { + throw new Error("useCharacterContext must be used inside the CharacterProvider"); + } + + return context; +}; \ No newline at end of file diff --git a/src/features/characters/characterStoreReducer.ts b/src/features/characters/characterStoreReducer.ts new file mode 100644 index 00000000..8442b3b7 --- /dev/null +++ b/src/features/characters/characterStoreReducer.ts @@ -0,0 +1,93 @@ +import Character from "./character"; +import { characterDataProvider } from "./db/characterDataProvider"; +import CharacterDbModel from "./db/characterDbModel"; + +export type CharacterDispatchAction = { + type: CharacterStoreActionType; + characters?: Character[]; + character?: Character; + callback?: (props: any) => any; +} + +export enum CharacterStoreActionType { + create, + update, + delete, + loadCharacters, + setState +} + +export const characterStoreReducer = (state: Character[], action: CharacterDispatchAction): Character[] => { + let newState = state; + + switch (action.type) { + case CharacterStoreActionType.create: + CreateCharacter(action); + break; + + case CharacterStoreActionType.update: + UpdateCharacter(action); + break; + + case CharacterStoreActionType.delete: + if (action.character) + characterDataProvider.delete(action.character.id); + break; + + case CharacterStoreActionType.loadCharacters: + LoadFromLocalStorage(action); + break; + + case CharacterStoreActionType.setState: + if (action.characters) + newState = action.characters; + break; + + default: break; + } + + return newState; +} + +const CreateCharacter = (action: CharacterDispatchAction) => { + if (action.character){ + characterDataProvider.create(action.character.tag, action.character.name, action.character.vrmHash, + action.character.bgUrl, action.character.bgColor, action.character.youtubeVideoId, action.character.animationUrl) + .then(CharacterDbModelToCharacter).then((character: Character) => { if (action.callback) action.callback(character); }) + } +} + +const UpdateCharacter = (action: CharacterDispatchAction) => { + if (action.character){ + characterDataProvider.update(action.character.id, action.character.tag, action.character.name, action.character.vrmHash, + action.character.bgUrl, action.character.bgColor, action.character.youtubeVideoId, action.character.animationUrl) + .then((updatedCharacter: Character | undefined) => { + if (updatedCharacter) + return CharacterDbModelToCharacter(updatedCharacter); + else + return undefined; + }).then((character: Character | undefined) => { if (action.callback) action.callback(character); }) + } +} + +const LoadFromLocalStorage = (action: CharacterDispatchAction): void => { + characterDataProvider + .getItems() + .then(CharacterDbModelArrayToCharacterArray) + .then((characters: Character[]) => { if (action.callback) action.callback(characters); }); +}; + +const CharacterDbModelArrayToCharacterArray = async (characters: CharacterDbModel[]): Promise => { + const promiseArray = characters.map((characterDbModel: CharacterDbModel): Promise => { + return CharacterDbModelToCharacter(characterDbModel); + }); + return Promise.all(promiseArray).then((characterArray: Character[]) => { return characterArray; }); +}; + +const CharacterDbModelToCharacter = async (characterDbModel: CharacterDbModel): Promise => { + return new Promise((resolve, reject) => { + resolve(new Character(characterDbModel.id, characterDbModel.tag, characterDbModel.name, + characterDbModel.vrmHash, characterDbModel.bgUrl, characterDbModel.bgColor, + characterDbModel.youtubeVideoId, characterDbModel.animationUrl)); + }); +}; \ No newline at end of file diff --git a/src/features/characters/db/characterDataProvider.ts b/src/features/characters/db/characterDataProvider.ts new file mode 100644 index 00000000..8aeb3af3 --- /dev/null +++ b/src/features/characters/db/characterDataProvider.ts @@ -0,0 +1,38 @@ +import { AmicaDexie } from "../../indexedDb/amicaDb"; +import CharacterDbModel from "./characterDbModel"; +import { db } from "../../indexedDb/amicaDb"; + +export class CharacterDataProvider { + private db: AmicaDexie; + + constructor() { + this.db = db; + } + + componentWillUnmount() { + this.db.close(); + } + + public create(tag: string, name: string, vrmHash: string, bgUrl: string, bgColor: string, youtubeVideoID: string, animationUrl: string): Promise { + return this.db.characters.add(new CharacterDbModel(1, tag, name, vrmHash, bgUrl, bgColor, youtubeVideoID, animationUrl)); + } + + public async getItems(): Promise { + return this.db.characters.toArray(); + } + + public async getItem(id: number): Promise { + return this.db.characters.get({ "id": id }); + } + + public async update(id: number, tag: string, name?: string, vrmHash?: string, bgUrl?: string, bgColor?: string, youtubeVideoId?: string, animationUrl?: string): Promise { + this.db.characters.where("id").equals(id).modify({tag, name, vrmHash, bgUrl, bgColor, youtubeVideoId, animationUrl}); + return this.db.characters.get(id); + } + + public async delete(id: number): Promise { + return this.db.characters.delete({ "id": id }); + } +} + +export const characterDataProvider = new CharacterDataProvider(); \ No newline at end of file diff --git a/src/features/characters/db/characterDbModel.ts b/src/features/characters/db/characterDbModel.ts new file mode 100644 index 00000000..d45c9443 --- /dev/null +++ b/src/features/characters/db/characterDbModel.ts @@ -0,0 +1,21 @@ +export default class CharacterDbModel { + public id: number; + public tag: string; + public name: string; + public vrmHash: string; + public bgUrl: string; + public bgColor: string; + public youtubeVideoId: string; + public animationUrl: string; + + constructor(id: number, tag: string, name: string, vrmHash: string, bgUrl: string, bgColor: string, youtubeVideoId: string, animationUrl: string) { + this.id = id; + this.tag = tag; + this.name = name; + this.vrmHash = vrmHash; + this.bgUrl = bgUrl; + this.bgColor = bgColor; + this.youtubeVideoId = youtubeVideoId; + this.animationUrl = animationUrl; + } +} \ No newline at end of file diff --git a/src/features/indexedDb/amicaDb.ts b/src/features/indexedDb/amicaDb.ts new file mode 100644 index 00000000..d4838547 --- /dev/null +++ b/src/features/indexedDb/amicaDb.ts @@ -0,0 +1,18 @@ +import Dexie, { Table } from 'dexie'; +import VrmDbModel from '../vrmStore/db/vrmDbModel'; +import CharacterDbModel from '../characters/db/characterDbModel'; + +export class AmicaDexie extends Dexie { + vrms!: Table; + characters!: Table; + + constructor() { + super('AmicaDatabase'); + this.version(1).stores({ + vrms: 'hash', + characters: '++id' + }); + } +} + +export const db = new AmicaDexie(); \ No newline at end of file diff --git a/src/features/vrmStore/vrmDataProvider.ts b/src/features/vrmStore/db/vrmDataProvider.ts similarity index 83% rename from src/features/vrmStore/vrmDataProvider.ts rename to src/features/vrmStore/db/vrmDataProvider.ts index eab74342..4e56fad8 100644 --- a/src/features/vrmStore/vrmDataProvider.ts +++ b/src/features/vrmStore/db/vrmDataProvider.ts @@ -1,10 +1,10 @@ -import { VrmDexie } from "./vrmDb"; +import { AmicaDexie } from "../../indexedDb/amicaDb"; import VrmDbModel from "./vrmDbModel"; -import { db } from "./vrmDb"; +import { db } from "../../indexedDb/amicaDb"; import { Base64ToBlob } from "@/utils/blobDataUtils"; export class VrmDataProvider { - private db: VrmDexie; + private db: AmicaDexie; constructor() { this.db = db; @@ -31,8 +31,8 @@ export class VrmDataProvider { .then(vrmDbModel => { console.log(`hash: ${hash}`); console.log(`vrmDbModel: ${vrmDbModel}`); return vrmDbModel ? Base64ToBlob(vrmDbModel?.vrmData) : undefined; }); } - public addItemUrl(hash: string, url: string) { - this.db.vrms.where("hash").equals(hash).modify({ vrmUrl: url, saveType: 'web' }); + public updateLoadedLocalVrm(hash: string, url: string) { + this.db.vrms.where("hash").equals(hash).modify({ vrmUrl: url, hash: url, saveType: 'web' }); } } diff --git a/src/features/vrmStore/vrmDbModel.ts b/src/features/vrmStore/db/vrmDbModel.ts similarity index 100% rename from src/features/vrmStore/vrmDbModel.ts rename to src/features/vrmStore/db/vrmDbModel.ts diff --git a/src/features/vrmStore/vrmDb.ts b/src/features/vrmStore/vrmDb.ts deleted file mode 100644 index 7d2a296b..00000000 --- a/src/features/vrmStore/vrmDb.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Dexie, { Table } from 'dexie'; -import VrmDbModel from './vrmDbModel'; - -export class VrmDexie extends Dexie { - vrms!: Table; - - constructor() { - super('AmicaVrmDatabase'); - this.version(1).stores({ - vrms: 'hash' // Primary key and indexed props - }); - } -} - -export const db = new VrmDexie(); \ No newline at end of file diff --git a/src/features/vrmStore/vrmStoreContext.tsx b/src/features/vrmStore/vrmStoreContext.tsx index 93ad3ef8..5d989381 100644 --- a/src/features/vrmStore/vrmStoreContext.tsx +++ b/src/features/vrmStore/vrmStoreContext.tsx @@ -10,6 +10,7 @@ interface VrmStoreContextType { getCurrentVrm: () => VrmData | undefined; vrmList: VrmData[]; vrmListAddFile: (file: File, viewer: Viewer) => void; + updateLoadedLocalVrm: (hash: string, url: string) => void; isLoadingVrmList: boolean; setIsLoadingVrmList: Dispatch>; }; @@ -22,6 +23,7 @@ export const VrmStoreContext = createContext({ getCurrentVrm: () => {return undefined;}, vrmList: vrmInitList, vrmListAddFile: () => {}, + updateLoadedLocalVrm: () => {}, isLoadingVrmList: false, setIsLoadingVrmList: () => {} }); @@ -54,11 +56,16 @@ export const VrmStoreProvider = ({ children }: PropsWithChildren<{}>): JSX.Eleme }, []); const getCurrentVrm = () => { - return config('vrm_save_type') == 'local' ? loadedVrmList.find(vrm => vrm.getHash() == config('vrm_hash') ) : loadedVrmList.find(vrm => vrm.url == config('vrm_url') ); + return loadedVrmList.find(vrm => vrm.getHash() == config('vrm_hash')); + } + + const updateLoadedLocalVrm = (hash: string, url: string) => { + updateConfig('vrm_save_type', 'web'); + vrmListDispatch({ type: VrmStoreActionType.updateLoadedLocalVrm, hash, url }); } return ( - + {children} ); diff --git a/src/features/vrmStore/vrmStoreReducer.ts b/src/features/vrmStore/vrmStoreReducer.ts index 48e4267e..b2be75fd 100644 --- a/src/features/vrmStore/vrmStoreReducer.ts +++ b/src/features/vrmStore/vrmStoreReducer.ts @@ -1,13 +1,14 @@ import { hashCode } from "@/components/settings/common"; import { VrmData } from "./vrmData"; -import { vrmDataProvider } from "./vrmDataProvider"; -import VrmDbModel from "./vrmDbModel"; +import { vrmDataProvider } from "./db/vrmDataProvider"; +import VrmDbModel from "./db/vrmDbModel"; import "@/utils/blobDataUtils"; import { Base64ToBlob, BlobToBase64 } from "@/utils/blobDataUtils"; export type VrmDispatchAction = { type: VrmStoreActionType; itemFile?: File; + hash?: string; url?: string; thumbBlob?: Blob; vrmList?: VrmData[]; @@ -18,7 +19,8 @@ export enum VrmStoreActionType { addItem, updateVrmThumb, setVrmList, - loadFromLocalStorage + loadFromLocalStorage, + updateLoadedLocalVrm }; export const vrmStoreReducer = (state: VrmData[], action: VrmDispatchAction): VrmData[] => { @@ -30,12 +32,19 @@ export const vrmStoreReducer = (state: VrmData[], action: VrmDispatchAction): Vr break; case VrmStoreActionType.updateVrmThumb: newState = updateVrmThumb(state, action); + break; case VrmStoreActionType.setVrmList: if (action.vrmList && action.vrmList.length) newState = action.vrmList; + break; case VrmStoreActionType.loadFromLocalStorage: if (action.vrmList && action.callback) newState = LoadFromLocalStorage(action) + break; + case VrmStoreActionType.updateLoadedLocalVrm: + if (action.hash && action.url) + UpdateLoadedLocalVrm(action.hash, action.url); + break; default: break; } @@ -99,6 +108,10 @@ const LoadFromLocalStorage = (action: VrmDispatchAction): VrmData[] => { return action.vrmList || new Array; }; +const UpdateLoadedLocalVrm = (hash: string, url: string): void => { + vrmDataProvider.updateLoadedLocalVrm(hash, url); +} + const VrmDataArrayFromVrmDbModelArray = async (vrms: VrmDbModel[]): Promise => { const promiseArray = vrms.map((vrmDbModel: VrmDbModel): Promise => { return VrmDbModelToVrmData(vrmDbModel); diff --git a/src/pages/import/[sqid]/index.tsx b/src/pages/import/[sqid]/index.tsx index 9d6ac801..9d85ad34 100644 --- a/src/pages/import/[sqid]/index.tsx +++ b/src/pages/import/[sqid]/index.tsx @@ -6,7 +6,7 @@ import { updateConfig, defaultConfig } from '@/utils/config'; import { isTauri } from '@/utils/isTauri'; import VrmDemo from "@/components/vrmDemo"; import { supabase } from '@/utils/supabase'; -import { vrmDataProvider } from '@/features/vrmStore/vrmDataProvider'; +import { vrmDataProvider } from '@/features/vrmStore/db/vrmDataProvider'; import { BlobToBase64 } from '@/utils/blobDataUtils'; export default function Import() { diff --git a/src/pages/index.tsx b/src/pages/index.tsx index aaa24b0c..25a48697 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,14 +1,16 @@ import { Fragment, + useCallback, useContext, useEffect, + useRef, useState, } from "react"; import Link from "next/link"; import { Menu, Transition } from '@headlessui/react' import { clsx } from "clsx"; import { M_PLUS_2, Montserrat } from "next/font/google"; -import { useTranslation, Trans } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import { ChatBubbleLeftIcon, ChatBubbleLeftRightIcon, @@ -44,7 +46,10 @@ import { AlertContext } from "@/features/alert/alertContext"; import { config, updateConfig } from '@/utils/config'; import { isTauri } from '@/utils/isTauri'; import { langs } from '@/i18n/langs'; -import { VrmStoreProvider } from "@/features/vrmStore/vrmStoreContext"; +import { UsersIcon } from "@heroicons/react/20/solid"; +import { CharacterListPage } from "@/components/character/CharacterListPage"; +import { CharacterDetailsPage } from "@/components/character/CharacterDetailsPage"; +import { CharacterStoreContextProvider } from "@/features/characters/characterStoreContext"; const m_plus_2 = M_PLUS_2({ variable: "--font-m-plus-2", @@ -79,13 +84,15 @@ export default function Home() { const [showSettings, setShowSettings] = useState(false); const [showChatLog, setShowChatLog] = useState(false); + const [showCharacters, setShowCharacters] = useState(false); const [showDebug, setShowDebug] = useState(false); // null indicates havent loaded config yet const [muted, setMuted] = useState(null); const [webcamEnabled, setWebcamEnabled] = useState(false); const [showLanguageSelector, setShowLanguageSelector] = useState(false); - + const [settingsUpdated, setSettingsUpdated] = useState(false); + const [showNotification, setShowNotification] = useState(false); useEffect(() => { if (muted === null) { @@ -124,6 +131,49 @@ export default function Home() { // this exists to prevent build errors with ssr useEffect(() => setShowContent(true), []); + + // useEffect(() => { + // showSettingsUpdatedNotification(); + // }, [bgColor, bgUrl, vrmUrl, youtubeVideoID, animationUrl]); + + const showSettingsUpdatedNotification = (): () => void => { + const timeOutId = setTimeout(() => { + if (settingsUpdated) { + setShowNotification(true); + setTimeout(() => { + setShowNotification(false); + }, 5000); + } + }, 1000); + return () => clearTimeout(timeOutId); + } + + const vrmFileInputRef = useRef(null); + const handleClickOpenVrmFile = useCallback(() => { + vrmFileInputRef.current?.click(); + }, []); + + const handleChangeVrmFile = useCallback( + (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files) return; + + const file = files[0]; + if (!file) return; + + const file_type = file.name.split(".").pop(); + + if (file_type === "vrm") { + const blob = new Blob([file], { type: "application/octet-stream" }); + const url = window.URL.createObjectURL(blob); + viewer.loadVrm(url); + } + + event.target.value = ""; + }, + [viewer] + ); + if (!showContent) return <>; return ( @@ -147,14 +197,7 @@ export default function Home() { { webcamEnabled && } { showDebug && setShowDebug(false) }/> } - - - {showSettings && ( - setShowSettings(false)} - /> - )} - + @@ -262,6 +305,15 @@ export default function Home() { +
+
+ +
{showChatLog && } + + {/* ADD PROVIDER */} + {showSettings && ( + setShowSettings(false)} + /> + )} + + {showCharacters && ( + + + + )} {! showChatLog && ( <> @@ -314,6 +387,14 @@ export default function Home() { + +
); } diff --git a/src/pages/share.tsx b/src/pages/share.tsx index ca1e8877..8da3d4f3 100644 --- a/src/pages/share.tsx +++ b/src/pages/share.tsx @@ -16,7 +16,7 @@ import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type'; import 'filepond/dist/filepond.min.css'; import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css'; -import { vrmDataProvider } from "@/features/vrmStore/vrmDataProvider"; +import { vrmDataProvider } from "@/features/vrmStore/db/vrmDataProvider"; registerPlugin( FilePondPluginImagePreview, @@ -114,7 +114,7 @@ export default function Share() { useEffect(() => { if (vrmLoadedFromIndexedDb) { - vrmDataProvider.addItemUrl(vrmHash, vrmUrl); + vrmDataProvider.updateLoadedLocalVrm(vrmHash, vrmUrl); updateConfig('vrm_url', vrmUrl); updateConfig('vrm_save_type', 'web'); setVrmSaveType('web'); diff --git a/src/utils/config.ts b/src/utils/config.ts index b287e2eb..25a2dc86 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -10,6 +10,8 @@ const defaults = { bg_color: process.env.NEXT_PUBLIC_BG_COLOR ?? '', bg_url: process.env.NEXT_PUBLIC_BG_URL ?? '/bg/bg-landscape1.jpg', vrm_url: process.env.NEXT_PUBLIC_VRM_HASH ?? '/vrm/AvatarSample_A.vrm', + character_id: '-1', + character_tag: '', vrm_hash: '', vrm_save_type: 'web', youtube_videoid: '', From 7a54b267dfc915f1e0544c1c3375ee417d5efbad Mon Sep 17 00:00:00 2001 From: d_pelekhatyi Date: Wed, 8 May 2024 09:02:30 +0300 Subject: [PATCH 2/2] tag and systemPrompt components --- .../character/CharacterDetailsPage.tsx | 18 ++- src/components/character/CharactersPage.tsx | 114 ++++++++++++++++++ .../character/SystemPromptComponent.tsx | 41 +++++++ src/components/character/TagComponent.tsx | 36 ++++++ src/components/settings.tsx | 29 ----- .../settings/BackgroundColorPage.tsx | 1 - src/components/settings/SystemPromptPage.tsx | 51 -------- src/features/characters/characterStore.ts | 9 ++ .../characters/characterStoreContext.tsx | 6 + src/pages/index.tsx | 6 +- 10 files changed, 221 insertions(+), 90 deletions(-) create mode 100644 src/components/character/CharactersPage.tsx create mode 100644 src/components/character/SystemPromptComponent.tsx create mode 100644 src/components/character/TagComponent.tsx delete mode 100644 src/components/settings/SystemPromptPage.tsx diff --git a/src/components/character/CharacterDetailsPage.tsx b/src/components/character/CharacterDetailsPage.tsx index b1202758..fd153ff2 100644 --- a/src/components/character/CharacterDetailsPage.tsx +++ b/src/components/character/CharacterDetailsPage.tsx @@ -1,15 +1,15 @@ import { useTranslation } from "react-i18next"; -import { BasicPage } from "../settings/common"; +import { BasicPage, Link } from "../settings/common"; import { TextButton } from "../textButton"; import { useCharacterStoreContext } from "@/features/characters/characterStoreContext"; import { NameComponent } from "./NameComponent"; +import { TagComponent } from "./TagComponent"; +import { SystemPromptComponent } from "./SystemPromptComponent"; export function CharacterDetailsPage({ - setSettingsUpdated, - handleClickOpenVrmFile, + setSettingsUpdated }: { setSettingsUpdated: (updated: boolean) => void; - handleClickOpenVrmFile: () => void; }) { const { t } = useTranslation(); const characterContext = useCharacterStoreContext(); @@ -19,13 +19,19 @@ export function CharacterDetailsPage({ title={t("Character Model")} description={t("character_desc", "Select the Character to play")} > -
+ {/*
*/}
  • +
  • + +
  • +
  • + +
-
+ {/*
*/} { characterContext.saveCharacter(); }} diff --git a/src/components/character/CharactersPage.tsx b/src/components/character/CharactersPage.tsx new file mode 100644 index 00000000..3dcb482b --- /dev/null +++ b/src/components/character/CharactersPage.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; +import { Link } from "../settings/common"; +import { ArrowUturnLeftIcon, ChevronRightIcon, HomeIcon } from "@heroicons/react/20/solid"; +import { TextButton } from "../textButton"; +import { CharacterDetailsPage } from "./CharacterDetailsPage"; + +export const CharactersPage = ({ + setSettingsUpdated, + handleClickOpenVrmFile, + onClickClose +}: { + setSettingsUpdated: (updated: boolean) => void; + handleClickOpenVrmFile: () => void; + onClickClose: () => void; +}) => { + const [breadcrumbs, setBreadcrumbs] = useState([]); + const [page, setPage] = useState('current_character'); + + function renderPage() { + switch(page) { + case 'current_character': + return ; + + default: + throw new Error('page not found'); + } + } + + return ( +
+
+
+ + +
+ +
+
+
+ { + if (breadcrumbs.length === 0) { + onClickClose(); + return; + } + if (breadcrumbs.length === 1) { + setPage('main_menu'); + setBreadcrumbs([]); + return; + } + + const prevPage = breadcrumbs[breadcrumbs.length - 2]; + setPage(prevPage.key); + setBreadcrumbs(breadcrumbs.slice(0, -1)); + }} + > + + + { renderPage() } +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/character/SystemPromptComponent.tsx b/src/components/character/SystemPromptComponent.tsx new file mode 100644 index 00000000..19c8ff05 --- /dev/null +++ b/src/components/character/SystemPromptComponent.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next'; + +import { updateConfig, defaultConfig } from "@/utils/config"; +import { FormRow, ResetToDefaultButton } from '../settings/common'; + +export function SystemPromptComponent({ + systemPrompt, + setSystemPrompt, + setSettingsUpdated, +}: { + systemPrompt: string; + setSystemPrompt: (prompt: string) => void; + setSettingsUpdated: (updated: boolean) => void; +}) { + const { t } = useTranslation(); + + return ( + +